From 886d7598f3346317e333afc696ba2a8f475c8a1b Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Sat, 22 Nov 2025 04:40:58 +0000 Subject: [PATCH] Switch to using mqtt --- custom_components/oasis_mini/__init__.py | 141 +++-- custom_components/oasis_mini/binary_sensor.py | 21 +- custom_components/oasis_mini/button.py | 43 +- custom_components/oasis_mini/config_flow.py | 127 ++--- custom_components/oasis_mini/const.py | 2 +- custom_components/oasis_mini/coordinator.py | 63 ++- custom_components/oasis_mini/entity.py | 31 +- custom_components/oasis_mini/helpers.py | 18 +- custom_components/oasis_mini/image.py | 49 +- custom_components/oasis_mini/light.py | 41 +- custom_components/oasis_mini/manifest.json | 5 +- custom_components/oasis_mini/media_player.py | 41 +- custom_components/oasis_mini/number.py | 68 ++- .../oasis_mini/pyoasiscontrol/__init__.py | 7 + .../pyoasiscontrol/clients/__init__.py | 7 + .../pyoasiscontrol/clients/cloud_client.py | 191 +++++++ .../pyoasiscontrol/clients/http_client.py | 215 +++++++ .../pyoasiscontrol/clients/mqtt_client.py | 517 +++++++++++++++++ .../pyoasiscontrol/clients/transport.py | 85 +++ .../oasis_mini/pyoasiscontrol/const.py | 106 ++++ .../oasis_mini/pyoasiscontrol/device.py | 327 +++++++++++ .../oasis_mini/pyoasiscontrol/exceptions.py | 5 + .../tracks.json | 94 +-- .../{pyoasismini => pyoasiscontrol}/utils.py | 0 .../oasis_mini/pyoasismini/__init__.py | 535 ------------------ .../oasis_mini/pyoasismini/const.py | 16 - custom_components/oasis_mini/select.py | 220 +++---- custom_components/oasis_mini/sensor.py | 36 +- custom_components/oasis_mini/strings.json | 15 +- .../oasis_mini/translations/en.json | 15 +- custom_components/oasis_mini/update.py | 33 +- hacs.json | 4 +- requirements.txt | 1 + update_tracks.py | 14 +- 34 files changed, 2036 insertions(+), 1057 deletions(-) create mode 100644 custom_components/oasis_mini/pyoasiscontrol/__init__.py create mode 100644 custom_components/oasis_mini/pyoasiscontrol/clients/__init__.py create mode 100644 custom_components/oasis_mini/pyoasiscontrol/clients/cloud_client.py create mode 100644 custom_components/oasis_mini/pyoasiscontrol/clients/http_client.py create mode 100644 custom_components/oasis_mini/pyoasiscontrol/clients/mqtt_client.py create mode 100644 custom_components/oasis_mini/pyoasiscontrol/clients/transport.py create mode 100644 custom_components/oasis_mini/pyoasiscontrol/const.py create mode 100644 custom_components/oasis_mini/pyoasiscontrol/device.py create mode 100644 custom_components/oasis_mini/pyoasiscontrol/exceptions.py rename custom_components/oasis_mini/{pyoasismini => pyoasiscontrol}/tracks.json (99%) rename custom_components/oasis_mini/{pyoasismini => pyoasiscontrol}/utils.py (100%) delete mode 100644 custom_components/oasis_mini/pyoasismini/__init__.py delete mode 100644 custom_components/oasis_mini/pyoasismini/const.py diff --git a/custom_components/oasis_mini/__init__.py b/custom_components/oasis_mini/__init__.py index c96ed32..7f02a30 100755 --- a/custom_components/oasis_mini/__init__.py +++ b/custom_components/oasis_mini/__init__.py @@ -1,4 +1,4 @@ -"""Support for Oasis Mini.""" +"""Support for Oasis devices.""" from __future__ import annotations @@ -6,17 +6,16 @@ import logging from typing import Any from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_EMAIL, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady -import homeassistant.helpers.device_registry as dr +from homeassistant.exceptions import ConfigEntryAuthFailed import homeassistant.helpers.entity_registry as er -from .const import DOMAIN -from .coordinator import OasisMiniCoordinator +from .coordinator import OasisDeviceCoordinator from .helpers import create_client +from .pyoasiscontrol import OasisMqttClient, UnauthenticatedError -type OasisMiniConfigEntry = ConfigEntry[OasisMiniCoordinator] +type OasisDeviceConfigEntry = ConfigEntry[OasisDeviceCoordinator] _LOGGER = logging.getLogger(__name__) @@ -29,90 +28,116 @@ PLATFORMS = [ Platform.NUMBER, Platform.SELECT, Platform.SENSOR, - # Platform.SWITCH, Platform.UPDATE, ] -async def async_setup_entry(hass: HomeAssistant, entry: OasisMiniConfigEntry) -> bool: - """Set up Oasis Mini from a config entry.""" - client = create_client(entry.data | entry.options) - coordinator = OasisMiniCoordinator(hass, client) +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) + try: + user = await cloud_client.async_get_user() + except UnauthenticatedError as err: + raise ConfigEntryAuthFailed(err) from err + + mqtt_client = OasisMqttClient() + mqtt_client.start() + + coordinator = OasisDeviceCoordinator(hass, cloud_client, mqtt_client) try: await coordinator.async_config_entry_first_refresh() except Exception as ex: _LOGGER.exception(ex) - if not entry.unique_id: - if not (serial_number := coordinator.device.serial_number): - dev_reg = dr.async_get(hass) - devices = dr.async_entries_for_config_entry(dev_reg, entry.entry_id) - serial_number = next( - ( - identifier[1] - for identifier in devices[0].identifiers - if identifier[0] == DOMAIN - ), - None, - ) - hass.config_entries.async_update_entry(entry, unique_id=serial_number) + if entry.unique_id != (user_id := str(user["id"])): + hass.config_entries.async_update_entry(entry, unique_id=user_id) if not coordinator.data: - await client.session.close() - raise ConfigEntryNotReady - - if entry.unique_id != coordinator.device.serial_number: - await client.session.close() - raise ConfigEntryError("Serial number mismatch") + _LOGGER.warning("No devices associated with account") entry.runtime_data = coordinator + def _on_oasis_update() -> None: + coordinator.async_update_listeners() + + for device in coordinator.data: + device.add_update_listener(_on_oasis_update) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) return True -async def async_unload_entry(hass: HomeAssistant, entry: OasisMiniConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: OasisDeviceConfigEntry +) -> bool: """Unload a config entry.""" - await entry.runtime_data.device.session.close() + mqtt_client = entry.runtime_data.mqtt_client + await mqtt_client.async_close() + + cloud_client = entry.runtime_data.cloud_client + await cloud_client.async_close() + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_remove_entry(hass: HomeAssistant, entry: OasisMiniConfigEntry) -> None: +async def async_remove_entry( + hass: HomeAssistant, entry: OasisDeviceConfigEntry +) -> None: """Handle removal of an entry.""" - if entry.options: - client = create_client(entry.data | entry.options) - await client.async_cloud_logout() - await client.session.close() + cloud_client = create_client(hass, entry.data) + try: + await cloud_client.async_logout() + except Exception as ex: + _LOGGER.exception(ex) + await cloud_client.async_close() -async def update_listener(hass: HomeAssistant, entry: OasisMiniConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_migrate_entry(hass: HomeAssistant, entry: OasisDeviceConfigEntry): """Migrate old entry.""" _LOGGER.debug( "Migrating configuration from version %s.%s", entry.version, entry.minor_version ) - if entry.version == 1 and entry.minor_version == 1: - # Need to update previous playlist select entity to queue - @callback - def migrate_unique_id(entity_entry: er.RegistryEntry) -> dict[str, Any] | None: - """Migrate the playlist unique ID to queue.""" - if entity_entry.domain == "select" and entity_entry.unique_id.endswith( - "-playlist" - ): - unique_id = entity_entry.unique_id.replace("-playlist", "-queue") - return {"new_unique_id": unique_id} - return None + if entry.version > 1: + # This means the user has downgraded from a future version + return False - await er.async_migrate_entries(hass, entry.entry_id, migrate_unique_id) - hass.config_entries.async_update_entry(entry, minor_version=2, version=1) + if entry.version == 1: + new_data = {**entry.data} + new_options = {**entry.options} + + if entry.minor_version < 2: + # Need to update previous playlist select entity to queue + @callback + def migrate_unique_id( + entity_entry: er.RegistryEntry, + ) -> dict[str, Any] | None: + """Migrate the playlist unique ID to queue.""" + if entity_entry.domain == "select" and entity_entry.unique_id.endswith( + "-playlist" + ): + unique_id = entity_entry.unique_id.replace("-playlist", "-queue") + return {"new_unique_id": unique_id} + return None + + await er.async_migrate_entries(hass, entry.entry_id, migrate_unique_id) + + if entry.minor_version < 3: + # Auth is now required, host is dropped + new_data = {**entry.options} + new_options = {} + + hass.config_entries.async_update_entry( + entry, + data=new_data, + options=new_options, + minor_version=3, + title=new_data.get(CONF_EMAIL, "Oasis Control"), + unique_id=None, + version=1, + ) _LOGGER.debug( "Migration to configuration version %s.%s successful", diff --git a/custom_components/oasis_mini/binary_sensor.py b/custom_components/oasis_mini/binary_sensor.py index 742a868..5c5330c 100644 --- a/custom_components/oasis_mini/binary_sensor.py +++ b/custom_components/oasis_mini/binary_sensor.py @@ -1,4 +1,4 @@ -"""Oasis Mini binary sensor entity.""" +"""Oasis device binary sensor entity.""" from __future__ import annotations @@ -11,20 +11,21 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import OasisMiniConfigEntry -from .coordinator import OasisMiniCoordinator -from .entity import OasisMiniEntity +from . import OasisDeviceConfigEntry +from .coordinator import OasisDeviceCoordinator +from .entity import OasisDeviceEntity async def async_setup_entry( hass: HomeAssistant, - entry: OasisMiniConfigEntry, + entry: OasisDeviceConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up Oasis Mini sensors using config entry.""" - coordinator: OasisMiniCoordinator = entry.runtime_data + """Set up Oasis device sensors using config entry.""" + coordinator: OasisDeviceCoordinator = entry.runtime_data async_add_entities( - OasisMiniBinarySensorEntity(coordinator, descriptor) + OasisDeviceBinarySensorEntity(coordinator, device, descriptor) + for device in coordinator.data for descriptor in DESCRIPTORS ) @@ -46,8 +47,8 @@ DESCRIPTORS = { } -class OasisMiniBinarySensorEntity(OasisMiniEntity, BinarySensorEntity): - """Oasis Mini binary sensor entity.""" +class OasisDeviceBinarySensorEntity(OasisDeviceEntity, BinarySensorEntity): + """Oasis device binary sensor entity.""" @property def is_on(self) -> bool: diff --git a/custom_components/oasis_mini/button.py b/custom_components/oasis_mini/button.py index 19174da..ad295f3 100644 --- a/custom_components/oasis_mini/button.py +++ b/custom_components/oasis_mini/button.py @@ -1,4 +1,4 @@ -"""Oasis Mini button entity.""" +"""Oasis device button entity.""" from __future__ import annotations @@ -15,53 +15,54 @@ 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 +from .coordinator import OasisDeviceCoordinator +from .entity import OasisDeviceEntity from .helpers import add_and_play_track -from .pyoasismini import OasisMini -from .pyoasismini.const import TRACKS +from .pyoasiscontrol import OasisDevice +from .pyoasiscontrol.const import TRACKS async def async_setup_entry( hass: HomeAssistant, - entry: OasisMiniConfigEntry, + entry: OasisDeviceConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up Oasis Mini button using config entry.""" + """Set up Oasis device button using config entry.""" + coordinator: OasisDeviceCoordinator = entry.runtime_data async_add_entities( - [ - OasisMiniButtonEntity(entry.runtime_data, descriptor) - for descriptor in DESCRIPTORS - ] + OasisDeviceButtonEntity(coordinator, device, descriptor) + for device in coordinator.data + for descriptor in DESCRIPTORS ) -async def play_random_track(device: OasisMini) -> None: +async def play_random_track(device: OasisDevice) -> None: """Play random track.""" track = random.choice(list(TRACKS)) await add_and_play_track(device, track) @dataclass(frozen=True, kw_only=True) -class OasisMiniButtonEntityDescription(ButtonEntityDescription): - """Oasis Mini button entity description.""" +class OasisDeviceButtonEntityDescription(ButtonEntityDescription): + """Oasis device button entity description.""" - press_fn: Callable[[OasisMini], Awaitable[None]] + press_fn: Callable[[OasisDevice], Awaitable[None]] DESCRIPTORS = ( - OasisMiniButtonEntityDescription( + OasisDeviceButtonEntityDescription( key="reboot", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, press_fn=lambda device: device.async_reboot(), ), - OasisMiniButtonEntityDescription( + OasisDeviceButtonEntityDescription( key="random_track", translation_key="random_track", press_fn=play_random_track, ), - OasisMiniButtonEntityDescription( + OasisDeviceButtonEntityDescription( key="sleep", translation_key="sleep", press_fn=lambda device: device.async_sleep(), @@ -69,10 +70,10 @@ DESCRIPTORS = ( ) -class OasisMiniButtonEntity(OasisMiniEntity, ButtonEntity): - """Oasis Mini button entity.""" +class OasisDeviceButtonEntity(OasisDeviceEntity, ButtonEntity): + """Oasis device button entity.""" - entity_description: OasisMiniButtonEntityDescription + entity_description: OasisDeviceButtonEntityDescription async def async_press(self) -> None: """Press the button.""" diff --git a/custom_components/oasis_mini/config_flow.py b/custom_components/oasis_mini/config_flow.py index 5fec557..bdc420f 100755 --- a/custom_components/oasis_mini/config_flow.py +++ b/custom_components/oasis_mini/config_flow.py @@ -1,87 +1,52 @@ -"""Config flow for Oasis Mini integration.""" +"""Config flow for Oasis device integration.""" from __future__ import annotations import asyncio import logging -from typing import Any +from typing import Any, Mapping from aiohttp import ClientConnectorError from httpx import ConnectError, HTTPStatusError import voluptuous as vol -from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_HOST, CONF_PASSWORD -from homeassistant.core import callback -from homeassistant.helpers.schema_config_entry_flow import ( - SchemaCommonFlowHandler, - SchemaFlowError, - SchemaFlowFormStep, - SchemaOptionsFlowHandler, -) +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD -from . import OasisMiniConfigEntry from .const import DOMAIN -from .coordinator import OasisMiniCoordinator from .helpers import create_client +from .pyoasiscontrol import UnauthenticatedError _LOGGER = logging.getLogger(__name__) - -STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) -OPTIONS_SCHEMA = vol.Schema( +STEP_USER_DATA_SCHEMA = vol.Schema( {vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str} ) -async def cloud_login( - handler: SchemaCommonFlowHandler, user_input: dict[str, Any] -) -> dict[str, Any]: - """Cloud login.""" - coordinator: OasisMiniCoordinator = handler.parent_handler.config_entry.runtime_data - - try: - await coordinator.device.async_cloud_login( - email=user_input[CONF_EMAIL], password=user_input[CONF_PASSWORD] - ) - user_input[CONF_ACCESS_TOKEN] = coordinator.device.access_token - except Exception as ex: - raise SchemaFlowError("invalid_auth") from ex - - del user_input[CONF_PASSWORD] - return user_input - - -OPTIONS_FLOW = { - "init": SchemaFlowFormStep(OPTIONS_SCHEMA, validate_user_input=cloud_login) -} - - -class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN): - """Handle a config flow for Oasis Mini.""" +class OasisDeviceConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Oasis devices.""" VERSION = 1 - MINOR_VERSION = 2 + MINOR_VERSION = 3 - @staticmethod - @callback - def async_get_options_flow( - config_entry: OasisMiniConfigEntry, - ) -> SchemaOptionsFlowHandler: - """Get the options flow for this handler.""" - return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) - - async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo + async def async_step_reauth( + self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: - """Handle DHCP discovery.""" - host = {CONF_HOST: discovery_info.ip} - await self.validate_client(host) - self._abort_if_unique_id_configured(updates=host) - # This should never happen since we only listen to DHCP requests - # for configured devices. - return self.async_abort(reason="already_configured") + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Dialog that informs the user that reauth is required.""" + entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + assert entry + + suggested_values = user_input or entry.data + return await self._async_step( + "reauth_confirm", STEP_USER_DATA_SCHEMA, user_input, suggested_values + ) async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -115,20 +80,22 @@ class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: if not (errors := await self.validate_client(user_input)): - if step_id != "reconfigure": - self._abort_if_unique_id_configured(updates=user_input) - if existing_entry := self.hass.config_entries.async_get_entry( - self.context.get("entry_id") - ): - self.hass.config_entries.async_update_entry( - existing_entry, data=user_input + entry_id = self.context.get("entry_id") + existing_entry = self.hass.config_entries.async_get_entry(entry_id) + if existing_entry and existing_entry.unique_id: + self._abort_if_unique_id_mismatch(reason="wrong_account") + if existing_entry: + return self.async_update_reload_and_abort( + existing_entry, + unique_id=self.unique_id, + title=user_input[CONF_EMAIL], + data=user_input, + reload_even_if_entry_is_unchanged=False, ) - await self.hass.config_entries.async_reload(existing_entry.entry_id) - return self.async_abort(reason="reconfigure_successful") + self._abort_if_unique_id_configured(updates=user_input) return self.async_create_entry( - title=f"Oasis Mini {self.unique_id}", - data=user_input, + title=user_input[CONF_EMAIL], data=user_input ) return self.async_show_form( @@ -142,21 +109,29 @@ class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN): errors = {} try: async with asyncio.timeout(10): - client = create_client(user_input) - await self.async_set_unique_id(await client.async_get_serial_number()) + client = create_client(self.hass, user_input) + await client.async_login( + email=user_input[CONF_EMAIL], password=user_input[CONF_PASSWORD] + ) + user_input[CONF_ACCESS_TOKEN] = client.access_token + user = await client.async_get_user() + await self.async_set_unique_id(str(user["id"])) + del user_input[CONF_PASSWORD] if not self.unique_id: - errors["base"] = "invalid_host" + errors["base"] = "invalid_auth" + except UnauthenticatedError: + errors["base"] = "invalid_auth" except asyncio.TimeoutError: errors["base"] = "timeout_connect" except ConnectError: - errors["base"] = "invalid_host" + errors["base"] = "invalid_auth" except ClientConnectorError: - errors["base"] = "invalid_host" + errors["base"] = "invalid_auth" except HTTPStatusError as err: errors["base"] = str(err) except Exception as ex: # pylint: disable=broad-except _LOGGER.error(ex) errors["base"] = "unknown" finally: - await client.session.close() + await client.async_close() return errors diff --git a/custom_components/oasis_mini/const.py b/custom_components/oasis_mini/const.py index 6c21381..7b8464c 100755 --- a/custom_components/oasis_mini/const.py +++ b/custom_components/oasis_mini/const.py @@ -1,4 +1,4 @@ -"""Constants for the Oasis Mini integration.""" +"""Constants for the Oasis devices integration.""" from typing import Final diff --git a/custom_components/oasis_mini/coordinator.py b/custom_components/oasis_mini/coordinator.py index 0509225..da04913 100644 --- a/custom_components/oasis_mini/coordinator.py +++ b/custom_components/oasis_mini/coordinator.py @@ -1,4 +1,4 @@ -"""Oasis Mini coordinator.""" +"""Oasis devices coordinator.""" from __future__ import annotations @@ -11,18 +11,23 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN -from .pyoasismini import OasisMini +from .pyoasiscontrol import OasisCloudClient, OasisDevice, OasisMqttClient _LOGGER = logging.getLogger(__name__) -class OasisMiniCoordinator(DataUpdateCoordinator[str]): - """Oasis Mini data update coordinator.""" +class OasisDeviceCoordinator(DataUpdateCoordinator[list[OasisDevice]]): + """Oasis device data update coordinator.""" attempt: int = 0 last_updated: datetime | None = None - def __init__(self, hass: HomeAssistant, device: OasisMini) -> None: + def __init__( + self, + hass: HomeAssistant, + cloud_client: OasisCloudClient, + mqtt_client: OasisMqttClient, + ) -> None: """Initialize.""" super().__init__( hass, @@ -31,32 +36,46 @@ class OasisMiniCoordinator(DataUpdateCoordinator[str]): update_interval=timedelta(seconds=10), always_update=False, ) - self.device = device + self.cloud_client = cloud_client + self.mqtt_client = mqtt_client - async def _async_update_data(self): + async def _async_update_data(self) -> list[OasisDevice]: """Update the data.""" - data: str | None = None + devices: list[OasisDevice] = [] self.attempt += 1 try: async with async_timeout.timeout(10): - if not self.device.mac_address: - await self.device.async_get_mac_address() - if not self.device.serial_number: - await self.device.async_get_serial_number() - if not self.device.software_version: - await self.device.async_get_software_version() - data = await self.device.async_get_status() + 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"), + ) + 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 device.mac_address: + await device.async_get_mac_address() + # if not device.software_version: + # await device.async_get_software_version() + # data = await self.device.async_get_status() + # devices = self.cloud_client.mac_address self.attempt = 0 - await self.device.async_get_current_track_details() - await self.device.async_get_playlist_details() - await self.device.async_cloud_get_playlists() + # 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 - if self.attempt > 2 or not (data or self.data): + if self.attempt > 2 or not (devices or self.data): raise UpdateFailed( - f"Couldn't read from the Oasis Mini after {self.attempt} attempts" + f"Couldn't read from the Oasis device after {self.attempt} attempts" ) from ex - if data != self.data: + if devices != self.data: self.last_updated = datetime.now() - return data + return devices diff --git a/custom_components/oasis_mini/entity.py b/custom_components/oasis_mini/entity.py index 2234096..afb31f5 100644 --- a/custom_components/oasis_mini/entity.py +++ b/custom_components/oasis_mini/entity.py @@ -1,4 +1,4 @@ -"""Oasis Mini entity.""" +"""Oasis device entity.""" from __future__ import annotations @@ -7,36 +7,35 @@ from homeassistant.helpers.entity import DeviceInfo, EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import OasisMiniCoordinator -from .pyoasismini import OasisMini +from .coordinator import OasisDeviceCoordinator +from .pyoasiscontrol import OasisDevice -class OasisMiniEntity(CoordinatorEntity[OasisMiniCoordinator]): - """Base class for Oasis Mini entities.""" +class OasisDeviceEntity(CoordinatorEntity[OasisDeviceCoordinator]): + """Base class for Oasis device entities.""" _attr_has_entity_name = True def __init__( - self, coordinator: OasisMiniCoordinator, description: EntityDescription + self, + coordinator: OasisDeviceCoordinator, + device: OasisDevice, + description: EntityDescription, ) -> None: - """Construct an Oasis Mini entity.""" + """Construct an Oasis device entity.""" super().__init__(coordinator) + self.device = device self.entity_description = description - device = coordinator.device + serial_number = device.serial_number self._attr_unique_id = f"{serial_number}-{description.key}" self._attr_device_info = DeviceInfo( connections={(CONNECTION_NETWORK_MAC, format_mac(device.mac_address))}, identifiers={(DOMAIN, serial_number)}, - name=f"Oasis Mini {serial_number}", - manufacturer="Kinetic Oasis", - model="Oasis Mini", + name=f"{device.model} {serial_number}", + manufacturer=device.manufacturer, + model=device.model, serial_number=serial_number, sw_version=device.software_version, ) - - @property - def device(self) -> OasisMini: - """Return the device.""" - return self.coordinator.device diff --git a/custom_components/oasis_mini/helpers.py b/custom_components/oasis_mini/helpers.py index c75cd91..e5202b4 100755 --- a/custom_components/oasis_mini/helpers.py +++ b/custom_components/oasis_mini/helpers.py @@ -1,23 +1,27 @@ -"""Helpers for the Oasis Mini integration.""" +"""Helpers for the Oasis devices integration.""" from __future__ import annotations import logging from typing import Any -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .pyoasismini import TRACKS, OasisMini +from .pyoasiscontrol import OasisCloudClient, OasisDevice +from .pyoasiscontrol.const import TRACKS _LOGGER = logging.getLogger(__name__) -def create_client(data: dict[str, Any]) -> OasisMini: - """Create a Oasis Mini local client.""" - return OasisMini(data[CONF_HOST], data.get(CONF_ACCESS_TOKEN)) +def create_client(hass: HomeAssistant, data: dict[str, Any]) -> OasisCloudClient: + """Create a Oasis cloud client.""" + session = async_get_clientsession(hass) + return OasisCloudClient(session=session, access_token=data.get(CONF_ACCESS_TOKEN)) -async def add_and_play_track(device: OasisMini, track: int) -> None: +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) diff --git a/custom_components/oasis_mini/image.py b/custom_components/oasis_mini/image.py index 4f495ea..286eb1f 100644 --- a/custom_components/oasis_mini/image.py +++ b/custom_components/oasis_mini/image.py @@ -1,4 +1,4 @@ -"""Oasis Mini image entity.""" +"""Oasis device image entity.""" from __future__ import annotations @@ -7,27 +7,45 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import UNDEFINED -from . import OasisMiniConfigEntry -from .coordinator import OasisMiniCoordinator -from .entity import OasisMiniEntity -from .pyoasismini.const import TRACKS -from .pyoasismini.utils import draw_svg +from . import OasisDeviceConfigEntry +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( + hass: HomeAssistant, + entry: OasisDeviceConfigEntry, + 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 + ) + IMAGE = ImageEntityDescription(key="image", name=None) -class OasisMiniImageEntity(OasisMiniEntity, ImageEntity): - """Oasis Mini image entity.""" +class OasisDeviceImageEntity(OasisDeviceEntity, ImageEntity): + """Oasis device image entity.""" _attr_content_type = "image/svg+xml" _track_id: int | None = None _progress: int = 0 def __init__( - self, coordinator: OasisMiniCoordinator, description: ImageEntityDescription + self, + coordinator: OasisDeviceCoordinator, + device: OasisDevice, + description: ImageEntityDescription, ) -> None: """Initialize the entity.""" - super().__init__(coordinator, description) + super().__init__(coordinator, device, description) ImageEntity.__init__(self, coordinator.hass) self._handle_coordinator_update() @@ -44,7 +62,7 @@ class OasisMiniImageEntity(OasisMiniEntity, ImageEntity): """Handle updated data from the coordinator.""" if ( self._track_id != self.device.track_id - or (self._progress != self.device.progress and self.device.access_token) + or self._progress != self.device.progress ) and (self.device.status == "playing" or self._cached_image is None): self._attr_image_last_updated = self.coordinator.last_updated self._track_id = self.device.track_id @@ -64,12 +82,3 @@ class OasisMiniImageEntity(OasisMiniEntity, ImageEntity): if self.hass: super()._handle_coordinator_update() - - -async def async_setup_entry( - hass: HomeAssistant, - entry: OasisMiniConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up Oasis Mini camera using config entry.""" - async_add_entities([OasisMiniImageEntity(entry.runtime_data, IMAGE)]) diff --git a/custom_components/oasis_mini/light.py b/custom_components/oasis_mini/light.py index e2b7759..f423cf4 100644 --- a/custom_components/oasis_mini/light.py +++ b/custom_components/oasis_mini/light.py @@ -1,4 +1,4 @@ -"""Oasis Mini light entity.""" +"""Oasis device light entity.""" from __future__ import annotations @@ -23,13 +23,30 @@ from homeassistant.util.color import ( value_to_brightness, ) -from . import OasisMiniConfigEntry -from .entity import OasisMiniEntity -from .pyoasismini import LED_EFFECTS +from . import OasisDeviceConfigEntry +from .coordinator import OasisDeviceCoordinator +from .entity import OasisDeviceEntity +from .pyoasiscontrol.const import LED_EFFECTS -class OasisMiniLightEntity(OasisMiniEntity, LightEntity): - """Oasis Mini light entity.""" +async def async_setup_entry( + hass: HomeAssistant, + entry: OasisDeviceConfigEntry, + 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 + ) + + +DESCRIPTOR = LightEntityDescription(key="led", translation_key="led") + + +class OasisDeviceLightEntity(OasisDeviceEntity, LightEntity): + """Oasis device light entity.""" _attr_supported_features = LightEntityFeature.EFFECT @@ -104,15 +121,3 @@ class OasisMiniLightEntity(OasisMiniEntity, LightEntity): brightness=brightness, color=color, led_effect=led_effect ) await self.coordinator.async_request_refresh() - - -DESCRIPTOR = LightEntityDescription(key="led", translation_key="led") - - -async def async_setup_entry( - hass: HomeAssistant, - entry: OasisMiniConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up Oasis Mini lights using config entry.""" - async_add_entities([OasisMiniLightEntity(entry.runtime_data, DESCRIPTOR)]) diff --git a/custom_components/oasis_mini/manifest.json b/custom_components/oasis_mini/manifest.json index 0bdd6a6..2d42fbd 100755 --- a/custom_components/oasis_mini/manifest.json +++ b/custom_components/oasis_mini/manifest.json @@ -1,13 +1,14 @@ { "domain": "oasis_mini", - "name": "Oasis Mini", + "name": "Oasis Control", "codeowners": ["@natekspencer"], "config_flow": true, "dhcp": [{ "registered_devices": true }], "documentation": "https://github.com/natekspencer/hacs-oasis_mini", - "integration_type": "device", + "integration_type": "hub", "iot_class": "local_polling", "issue_tracker": "https://github.com/natekspencer/hacs-oasis_mini/issues", "loggers": ["custom_components.oasis_mini"], + "requirements": ["aiomqtt"], "version": "0.0.0" } diff --git a/custom_components/oasis_mini/media_player.py b/custom_components/oasis_mini/media_player.py index 96254ee..29803d9 100644 --- a/custom_components/oasis_mini/media_player.py +++ b/custom_components/oasis_mini/media_player.py @@ -1,4 +1,4 @@ -"""Oasis Mini media player entity.""" +"""Oasis device media player entity.""" from __future__ import annotations @@ -18,15 +18,32 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import OasisMiniConfigEntry +from . import OasisDeviceConfigEntry from .const import DOMAIN -from .entity import OasisMiniEntity +from .coordinator import OasisDeviceCoordinator +from .entity import OasisDeviceEntity from .helpers import get_track_id -from .pyoasismini.const import TRACKS +from .pyoasiscontrol.const import TRACKS -class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity): - """Oasis Mini media player entity.""" +async def async_setup_entry( + hass: HomeAssistant, + entry: OasisDeviceConfigEntry, + 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 + ) + + +DESCRIPTOR = MediaPlayerEntityDescription(key="oasis_mini", name=None) + + +class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity): + """Oasis device media player entity.""" _attr_media_image_remotely_accessible = True _attr_supported_features = ( @@ -210,15 +227,3 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity): self.abort_if_busy() await self.device.async_clear_playlist() await self.coordinator.async_request_refresh() - - -DESCRIPTOR = MediaPlayerEntityDescription(key="oasis_mini", name=None) - - -async def async_setup_entry( - hass: HomeAssistant, - entry: OasisMiniConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up Oasis Mini media_players using config entry.""" - async_add_entities([OasisMiniMediaPlayerEntity(entry.runtime_data, DESCRIPTOR)]) diff --git a/custom_components/oasis_mini/number.py b/custom_components/oasis_mini/number.py index 96ab3af..08af451 100644 --- a/custom_components/oasis_mini/number.py +++ b/custom_components/oasis_mini/number.py @@ -1,4 +1,4 @@ -"""Oasis Mini number entity.""" +"""Oasis device number entity.""" from __future__ import annotations @@ -10,26 +10,29 @@ from homeassistant.components.number import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import OasisMiniConfigEntry -from .entity import OasisMiniEntity -from .pyoasismini import BALL_SPEED_MAX, BALL_SPEED_MIN, LED_SPEED_MAX, LED_SPEED_MIN +from . import OasisDeviceConfigEntry +from .coordinator import OasisDeviceCoordinator +from .entity import OasisDeviceEntity +from .pyoasiscontrol.device import ( + BALL_SPEED_MAX, + BALL_SPEED_MIN, + LED_SPEED_MAX, + LED_SPEED_MIN, +) -class OasisMiniNumberEntity(OasisMiniEntity, NumberEntity): - """Oasis Mini number entity.""" - - @property - def native_value(self) -> str | None: - """Return the value reported by the number.""" - return getattr(self.device, self.entity_description.key) - - async def async_set_native_value(self, value: float) -> None: - """Set new value.""" - if self.entity_description.key == "ball_speed": - await self.device.async_set_ball_speed(value) - elif self.entity_description.key == "led_speed": - await self.device.async_set_led(led_speed=value) - await self.coordinator.async_request_refresh() +async def async_setup_entry( + hass: HomeAssistant, + entry: OasisDeviceConfigEntry, + 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 + ) DESCRIPTORS = { @@ -50,15 +53,18 @@ DESCRIPTORS = { } -async def async_setup_entry( - hass: HomeAssistant, - entry: OasisMiniConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up Oasis Mini numbers using config entry.""" - async_add_entities( - [ - OasisMiniNumberEntity(entry.runtime_data, descriptor) - for descriptor in DESCRIPTORS - ] - ) +class OasisDeviceNumberEntity(OasisDeviceEntity, NumberEntity): + """Oasis device number entity.""" + + @property + def native_value(self) -> str | None: + """Return the value reported by the number.""" + return getattr(self.device, self.entity_description.key) + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + if self.entity_description.key == "ball_speed": + await self.device.async_set_ball_speed(value) + elif self.entity_description.key == "led_speed": + await self.device.async_set_led(led_speed=value) + await self.coordinator.async_request_refresh() diff --git a/custom_components/oasis_mini/pyoasiscontrol/__init__.py b/custom_components/oasis_mini/pyoasiscontrol/__init__.py new file mode 100644 index 0000000..13fa574 --- /dev/null +++ b/custom_components/oasis_mini/pyoasiscontrol/__init__.py @@ -0,0 +1,7 @@ +"""Oasis control.""" + +from .clients import OasisCloudClient, OasisMqttClient +from .device import OasisDevice +from .exceptions import UnauthenticatedError + +__all__ = ["OasisDevice", "OasisCloudClient", "OasisMqttClient", "UnauthenticatedError"] diff --git a/custom_components/oasis_mini/pyoasiscontrol/clients/__init__.py b/custom_components/oasis_mini/pyoasiscontrol/clients/__init__.py new file mode 100644 index 0000000..7e16568 --- /dev/null +++ b/custom_components/oasis_mini/pyoasiscontrol/clients/__init__.py @@ -0,0 +1,7 @@ +"""Oasis control clients.""" + +from .cloud_client import OasisCloudClient +from .http_client import OasisHttpClient +from .mqtt_client import OasisMqttClient + +__all__ = ["OasisCloudClient", "OasisHttpClient", "OasisMqttClient"] diff --git a/custom_components/oasis_mini/pyoasiscontrol/clients/cloud_client.py b/custom_components/oasis_mini/pyoasiscontrol/clients/cloud_client.py new file mode 100644 index 0000000..029a3da --- /dev/null +++ b/custom_components/oasis_mini/pyoasiscontrol/clients/cloud_client.py @@ -0,0 +1,191 @@ +"""Oasis cloud client.""" + +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Any +from urllib.parse import urljoin + +from aiohttp import ClientResponseError, ClientSession + +from ..exceptions import UnauthenticatedError +from ..utils import now + +_LOGGER = logging.getLogger(__name__) + +BASE_URL = "https://app.grounded.so" +PLAYLISTS_REFRESH_LIMITER = timedelta(minutes=5) + + +class OasisCloudClient: + """Cloud client for Oasis. + + Responsibilities: + - Manage aiohttp session (optionally owned) + - Manage access token + - Provide async_* helpers for: + * login/logout + * user info + * devices + * tracks/playlists + * 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, + *, + session: ClientSession | None = None, + access_token: str | None = None, + ) -> None: + self._session = session + 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 = {} + + @property + def session(self) -> ClientSession: + """Return (or lazily create) the aiohttp ClientSession.""" + if self._session is None or self._session.closed: + self._session = ClientSession() + self._owns_session = True + return self._session + + async def async_close(self) -> None: + """Close owned session (call from HA unload / cleanup).""" + if self._session and not self._session.closed and self._owns_session: + await self._session.close() + + @property + def access_token(self) -> str | None: + return self._access_token + + @access_token.setter + def access_token(self, value: str | None) -> None: + self._access_token = value + + async def async_login(self, email: str, password: str) -> None: + """Login via the cloud and store the access token.""" + response = await self._async_request( + "POST", + urljoin(BASE_URL, "api/auth/login"), + json={"email": email, "password": password}, + ) + token = response.get("access_token") if isinstance(response, dict) else None + self.access_token = token + _LOGGER.debug("Cloud login succeeded, token set: %s", bool(token)) + + async def async_logout(self) -> None: + """Logout from the cloud.""" + await self._async_auth_request("GET", "api/auth/logout") + self.access_token = None + + async def async_get_user(self) -> dict: + """Get current user info.""" + return await self._async_auth_request("GET", "api/auth/user") + + async def async_get_devices(self) -> list[dict[str, Any]]: + """Get user devices (raw JSON from API).""" + return await self._async_auth_request("GET", "api/user/devices") + + async def async_get_playlists( + 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(): + 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 + + async def async_get_track_info(self, track_id: int) -> dict[str, Any] | None: + """Get single track info from the cloud.""" + try: + return await self._async_auth_request("GET", f"api/track/{track_id}") + except ClientResponseError as err: + if err.status == 404: + return {"id": track_id, "name": f"Unknown Title (#{track_id})"} + except Exception as ex: # noqa: BLE001 + _LOGGER.exception("Error fetching track %s: %s", track_id, ex) + return None + + async def async_get_tracks( + self, tracks: list[int] | None = None + ) -> list[dict[str, Any]]: + """Get multiple tracks info from the cloud (handles pagination).""" + response = await self._async_auth_request( + "GET", + "api/track", + params={"ids[]": tracks or []}, + ) + if not response: + return [] + track_details = response.get("data", []) + while next_page_url := response.get("next_page_url"): + response = await self._async_auth_request("GET", next_page_url) + 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_auth_request(self, method: str, url: str, **kwargs: Any) -> Any: + """Perform an authenticated cloud request.""" + if not self.access_token: + raise UnauthenticatedError("Unauthenticated") + + headers = kwargs.pop("headers", {}) or {} + headers["Authorization"] = f"Bearer {self.access_token}" + + return await self._async_request( + method, + url if url.startswith("http") else urljoin(BASE_URL, url), + headers=headers, + **kwargs, + ) + + async def _async_request(self, method: str, url: str, **kwargs: Any) -> Any: + """Low-level HTTP helper for both cloud and (if desired) device HTTP.""" + session = self.session + _LOGGER.debug( + "%s %s", + method, + session._build_url(url).update_query( # pylint: disable=protected-access + kwargs.get("params"), + ), + ) + response = await session.request(method, url, **kwargs) + + if response.status == 200: + if response.content_type == "application/json": + return await response.json() + if response.content_type == "text/plain": + return await response.text() + if response.content_type == "text/html" and BASE_URL in url: + text = await response.text() + if "login-page" in text: + raise UnauthenticatedError("Unauthenticated") + return None + + if response.status == 401: + raise UnauthenticatedError("Unauthenticated") + + response.raise_for_status() diff --git a/custom_components/oasis_mini/pyoasiscontrol/clients/http_client.py b/custom_components/oasis_mini/pyoasiscontrol/clients/http_client.py new file mode 100644 index 0000000..513327a --- /dev/null +++ b/custom_components/oasis_mini/pyoasiscontrol/clients/http_client.py @@ -0,0 +1,215 @@ +"""Oasis HTTP client (per-device).""" + +from __future__ import annotations + +import logging +from typing import Any + +from aiohttp import ClientSession + +from ..const import AUTOPLAY_MAP +from ..device import OasisDevice +from ..utils import _bit_to_bool, _parse_int +from .transport import OasisClientProtocol + +_LOGGER = logging.getLogger(__name__) + + +class OasisHttpClient(OasisClientProtocol): + """HTTP-based Oasis transport. + + This client is typically used per-device (per host/IP). + It implements the OasisClientProtocol so OasisDevice can delegate + all commands through it. + """ + + def __init__(self, host: str, session: ClientSession | None = None) -> None: + self._host = host + self._session: ClientSession | None = session + self._owns_session: bool = session is None + + @property + def session(self) -> ClientSession: + if self._session is None or self._session.closed: + self._session = ClientSession() + self._owns_session = True + return self._session + + async def async_close(self) -> None: + """Close owned session.""" + if self._session and not self._session.closed and self._owns_session: + await self._session.close() + + @property + def url(self) -> str: + # These devices are plain HTTP, no TLS + return f"http://{self._host}/" + + async def _async_request(self, method: str, url: str, **kwargs: Any) -> Any: + """Low-level HTTP helper.""" + session = self.session + _LOGGER.debug( + "%s %s", + method, + session._build_url(url).update_query( # pylint: disable=protected-access + kwargs.get("params"), + ), + ) + resp = await session.request(method, url, **kwargs) + + if resp.status == 200: + if resp.content_type == "text/plain": + return await resp.text() + if resp.content_type == "application/json": + return await resp.json() + return None + + resp.raise_for_status() + + async def _async_get(self, **kwargs: Any) -> str | None: + return await self._async_request("GET", self.url, **kwargs) + + async def _async_command(self, **kwargs: Any) -> str | None: + result = await self._async_get(**kwargs) + _LOGGER.debug("Result: %s", result) + return result + + async def async_get_mac_address(self, device: OasisDevice) -> str | None: + """Fetch MAC address via HTTP GETMAC.""" + try: + mac = await self._async_get(params={"GETMAC": ""}) + if isinstance(mac, str): + return mac.strip() + except Exception: # noqa: BLE001 + _LOGGER.exception( + "Failed to get MAC address via HTTP for %s", device.serial_number + ) + return None + + async def async_send_ball_speed_command( + self, + device: OasisDevice, + speed: int, + ) -> None: + await self._async_command(params={"WRIOASISSPEED": speed}) + + async def async_send_led_command( + self, + device: OasisDevice, + led_effect: str, + color: str, + led_speed: int, + brightness: int, + ) -> None: + payload = f"{led_effect};0;{color};{led_speed};{brightness}" + await self._async_command(params={"WRILED": payload}) + + async def async_send_sleep_command(self, device: OasisDevice) -> None: + await self._async_command(params={"CMDSLEEP": ""}) + + async def async_send_move_job_command( + self, + device: OasisDevice, + from_index: int, + to_index: int, + ) -> None: + await self._async_command(params={"MOVEJOB": f"{from_index};{to_index}"}) + + async def async_send_change_track_command( + self, + device: OasisDevice, + index: int, + ) -> None: + await self._async_command(params={"CMDCHANGETRACK": index}) + + async def async_send_add_joblist_command( + self, + device: OasisDevice, + tracks: list[int], + ) -> None: + # The old code passed the list directly; if the device expects CSV: + await self._async_command(params={"ADDJOBLIST": ",".join(map(str, tracks))}) + + async def async_send_set_playlist_command( + self, + device: OasisDevice, + playlist: list[int], + ) -> None: + await self._async_command(params={"WRIJOBLIST": ",".join(map(str, playlist))}) + # optional: optimistic state update + device.update_from_status_dict({"playlist": playlist}) + + async def async_send_set_repeat_playlist_command( + self, + device: OasisDevice, + repeat: bool, + ) -> None: + await self._async_command(params={"WRIREPEATJOB": 1 if repeat else 0}) + + async def async_send_set_autoplay_command( + self, + device: OasisDevice, + option: str, + ) -> None: + await self._async_command(params={"WRIWAITAFTER": option}) + + async def async_send_upgrade_command( + self, + device: OasisDevice, + beta: bool, + ) -> None: + await self._async_command(params={"CMDUPGRADE": 1 if beta else 0}) + + async def async_send_play_command(self, device: OasisDevice) -> None: + await self._async_command(params={"CMDPLAY": ""}) + + async def async_send_pause_command(self, device: OasisDevice) -> None: + await self._async_command(params={"CMDPAUSE": ""}) + + async def async_send_stop_command(self, device: OasisDevice) -> None: + await self._async_command(params={"CMDSTOP": ""}) + + async def async_send_reboot_command(self, device: OasisDevice) -> None: + await self._async_command(params={"CMDBOOT": ""}) + + async def async_get_status(self, device: OasisDevice) -> None: + """Fetch status via GETSTATUS and update the device.""" + raw_status = await self._async_get(params={"GETSTATUS": ""}) + if raw_status is None: + return + + _LOGGER.debug("Status for %s: %s", device.serial_number, raw_status) + + values = raw_status.split(";") + if len(values) < 7: + _LOGGER.warning( + "Unexpected status format for %s: %s", device.serial_number, values + ) + return + + playlist = [_parse_int(track) for track in values[3].split(",") if track] + shift = len(values) - 18 if len(values) > 17 else 0 + + try: + status: dict[str, Any] = { + "status_code": _parse_int(values[0]), + "error": _parse_int(values[1]), + "ball_speed": _parse_int(values[2]), + "playlist": playlist, + "playlist_index": min(_parse_int(values[4]), len(playlist)), + "progress": _parse_int(values[5]), + "led_effect": values[6], + "led_speed": _parse_int(values[8]), + "brightness": _parse_int(values[9]), + "color": values[10] if "#" in values[10] else None, + "busy": _bit_to_bool(values[11 + shift]), + "download_progress": _parse_int(values[12 + shift]), + "max_brightness": _parse_int(values[13 + shift]), + "repeat_playlist": _bit_to_bool(values[15 + shift]), + "autoplay": AUTOPLAY_MAP.get(value := values[16 + shift], value), + } + except Exception: # noqa: BLE001 + _LOGGER.exception("Error parsing HTTP status for %s", device.serial_number) + return + + device.update_from_status_dict(status) diff --git a/custom_components/oasis_mini/pyoasiscontrol/clients/mqtt_client.py b/custom_components/oasis_mini/pyoasiscontrol/clients/mqtt_client.py new file mode 100644 index 0000000..44af614 --- /dev/null +++ b/custom_components/oasis_mini/pyoasiscontrol/clients/mqtt_client.py @@ -0,0 +1,517 @@ +"""Oasis MQTT client (multi-device).""" + +from __future__ import annotations + +import asyncio +import base64 +from datetime import UTC, datetime +import logging +import ssl +from typing import Any, Final + +import aiomqtt + +from ..const import AUTOPLAY_MAP +from ..device import OasisDevice +from ..utils import _bit_to_bool +from .transport import OasisClientProtocol + +_LOGGER = logging.getLogger(__name__) + +# mqtt connection parameters +HOST: Final = "mqtt.grounded.so" +PORT: Final = 8084 +PATH: Final = "mqtt" +USERNAME: Final = "YXBw" +PASSWORD: Final = "RWdETFlKMDczfi4t" +RECONNECT_INTERVAL: Final = 4 + + +class OasisMqttClient(OasisClientProtocol): + """MQTT-based Oasis transport using WSS. + + Responsibilities: + - Maintain a single MQTT connection to: + wss://mqtt.grounded.so:8084/mqtt + - Subscribe only to /STATUS/# for devices it knows about. + - Publish commands to /COMMAND/CMD + - Map MQTT payloads to OasisDevice.update_from_status_dict() + """ + + def __init__(self) -> None: + # MQTT connection state + self._client: aiomqtt.Client | None = None + self._loop_task: asyncio.Task | None = None + self._connected_at: datetime | None = None + + self._connected_event: asyncio.Event = asyncio.Event() + self._stop_event: asyncio.Event = asyncio.Event() + + # Known devices by serial + self._devices: dict[str, OasisDevice] = {} + + # Per-device events + self._first_status_events: dict[str, asyncio.Event] = {} + self._mac_events: dict[str, asyncio.Event] = {} + + # Subscription bookkeeping + self._subscribed_serials: set[str] = set() + self._subscription_lock = asyncio.Lock() + + def register_device(self, device: OasisDevice) -> None: + """Register a device so MQTT messages can be routed to it.""" + if not device.serial_number: + raise ValueError("Device must have serial_number set before registration") + + serial = device.serial_number + self._devices[serial] = device + + # Ensure we have per-device events + self._first_status_events.setdefault(serial, asyncio.Event()) + self._mac_events.setdefault(serial, asyncio.Event()) + + # If we're already connected, subscribe to this device's topics + if self._client is not None: + try: + loop = asyncio.get_running_loop() + loop.create_task(self._subscribe_serial(serial)) + except RuntimeError: + # No running loop (unlikely in HA), so just log + _LOGGER.debug( + "Could not schedule subscription for %s (no running loop)", serial + ) + + if not device.client: + device.attach_client(self) + + def unregister_device(self, device: OasisDevice) -> None: + serial = device.serial_number + if not serial: + return + + self._devices.pop(serial, None) + self._first_status_events.pop(serial, None) + self._mac_events.pop(serial, None) + + # If connected and we were subscribed, unsubscribe + if self._client is not None and serial in self._subscribed_serials: + try: + loop = asyncio.get_running_loop() + loop.create_task(self._unsubscribe_serial(serial)) + except RuntimeError: + _LOGGER.debug( + "Could not schedule unsubscription for %s (no running loop)", + serial, + ) + + async def _subscribe_serial(self, serial: str) -> None: + """Subscribe to STATUS topics for a single device.""" + if not self._client: + return + + async with self._subscription_lock: + if not self._client or serial in self._subscribed_serials: + return + + topic = f"{serial}/STATUS/#" + await self._client.subscribe([(topic, 1)]) + self._subscribed_serials.add(serial) + _LOGGER.info("Subscribed to %s", topic) + + async def _unsubscribe_serial(self, serial: str) -> None: + """Unsubscribe from STATUS topics for a single device.""" + if not self._client: + return + + async with self._subscription_lock: + if not self._client or serial not in self._subscribed_serials: + return + + topic = f"{serial}/STATUS/#" + await self._client.unsubscribe(topic) + self._subscribed_serials.discard(serial) + _LOGGER.info("Unsubscribed from %s", topic) + + async def _resubscribe_all(self) -> None: + """Resubscribe to all known devices after (re)connect.""" + self._subscribed_serials.clear() + for serial in list(self._devices): + await self._subscribe_serial(serial) + + def start(self) -> None: + """Start MQTT connection loop.""" + if self._loop_task is None or self._loop_task.done(): + self._stop_event.clear() + loop = asyncio.get_running_loop() + self._loop_task = loop.create_task(self._mqtt_loop()) + + async def async_close(self) -> None: + """Close connection loop and MQTT client.""" + await self.stop() + + async def stop(self) -> None: + """Stop MQTT connection loop.""" + self._stop_event.set() + + if self._loop_task: + self._loop_task.cancel() + try: + await self._loop_task + except asyncio.CancelledError: + pass + + if self._client: + try: + await self._client.disconnect() + except Exception: + _LOGGER.exception("Error disconnecting MQTT client") + finally: + self._client = None + + async def wait_until_ready( + self, device: OasisDevice, timeout: float = 10.0, request_status: bool = True + ) -> bool: + """ + Wait until: + 1. MQTT client is connected + 2. Device sends at least one STATUS message + + If request_status=True, a request status command is sent *after* connection. + """ + serial = device.serial_number + if not serial: + raise RuntimeError("Device has no serial_number set") + + first_status_event = self._first_status_events.setdefault( + serial, asyncio.Event() + ) + + # Wait for MQTT connection + try: + await asyncio.wait_for(self._connected_event.wait(), timeout=timeout) + except asyncio.TimeoutError: + _LOGGER.debug( + "Timeout (%.1fs) waiting for MQTT connection (device %s)", + timeout, + serial, + ) + return False + + # Optionally request a status refresh + if request_status: + try: + first_status_event.clear() + await self.async_get_status(device) + except Exception: + _LOGGER.debug( + "Could not request status for %s (not fully connected yet?)", + serial, + ) + + # Wait for first status + try: + await asyncio.wait_for(first_status_event.wait(), timeout=timeout) + return True + except asyncio.TimeoutError: + _LOGGER.debug( + "Timeout (%.1fs) waiting for first STATUS message from %s", + timeout, + serial, + ) + return False + + async def async_get_mac_address(self, device: OasisDevice) -> str | None: + """For MQTT, GETSTATUS causes MAC_ADDRESS to be published.""" + # If already known on the device, return it + if device.mac_address: + return device.mac_address + + serial = device.serial_number + if not serial: + raise RuntimeError("Device has no serial_number set") + + mac_event = self._mac_events.setdefault(serial, asyncio.Event()) + mac_event.clear() + + # Ask device to refresh status (including MAC_ADDRESS) + await self.async_get_status(device) + + try: + await asyncio.wait_for(mac_event.wait(), timeout=3.0) + except asyncio.TimeoutError: + _LOGGER.debug("Timed out waiting for MAC_ADDRESS for %s", serial) + + return device.mac_address + + async def async_send_ball_speed_command( + self, + device: OasisDevice, + speed: int, + ) -> None: + payload = f"WRIOASISSPEED={speed}" + await self._publish_command(device, payload) + + async def async_send_led_command( + self, + device: OasisDevice, + led_effect: str, + color: str, + led_speed: int, + brightness: int, + ) -> None: + payload = f"WRILED={led_effect};0;{color};{led_speed};{brightness}" + await self._publish_command(device, payload) + + async def async_send_sleep_command(self, device: OasisDevice) -> None: + await self._publish_command(device, "CMDSLEEP") + + async def async_send_move_job_command( + self, + device: OasisDevice, + from_index: int, + to_index: int, + ) -> None: + payload = f"MOVEJOB={from_index};{to_index}" + await self._publish_command(device, payload) + + async def async_send_change_track_command( + self, + device: OasisDevice, + index: int, + ) -> None: + payload = f"CMDCHANGETRACK={index}" + await self._publish_command(device, payload) + + async def async_send_add_joblist_command( + self, + device: OasisDevice, + tracks: list[int], + ) -> None: + track_str = ",".join(map(str, tracks)) + payload = f"ADDJOBLIST={track_str}" + await self._publish_command(device, payload) + + async def async_send_set_playlist_command( + self, + device: OasisDevice, + playlist: list[int], + ) -> None: + track_str = ",".join(map(str, playlist)) + 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, + repeat: bool, + ) -> None: + payload = f"WRIREPEATJOB={1 if repeat else 0}" + await self._publish_command(device, payload) + + async def async_send_set_autoplay_command( + self, + device: OasisDevice, + option: str, + ) -> None: + payload = f"WRIWAITAFTER={option}" + await self._publish_command(device, payload) + + async def async_send_upgrade_command( + self, + device: OasisDevice, + beta: bool, + ) -> None: + payload = f"CMDUPGRADE={1 if beta else 0}" + await self._publish_command(device, payload) + + async def async_send_play_command(self, device: OasisDevice) -> None: + await self._publish_command(device, "CMDPLAY") + + async def async_send_pause_command(self, device: OasisDevice) -> None: + await self._publish_command(device, "CMDPAUSE") + + async def async_send_stop_command(self, device: OasisDevice) -> None: + await self._publish_command(device, "CMDSTOP") + + async def async_send_reboot_command(self, device: OasisDevice) -> None: + await self._publish_command(device, "CMDBOOT") + + async def async_get_status(self, device: OasisDevice) -> None: + """Ask device to publish STATUS topics.""" + await self._publish_command(device, "GETSTATUS") + + async def _publish_command(self, device: OasisDevice, payload: str) -> None: + if not self._client: + raise RuntimeError("MQTT client not connected yet") + + serial = device.serial_number + if not serial: + raise RuntimeError("Device has no serial_number set") + + topic = f"{serial}/COMMAND/CMD" + _LOGGER.debug("MQTT publish %s => %s", topic, payload) + await self._client.publish(topic, payload.encode(), qos=1) + + async def _mqtt_loop(self) -> None: + loop = asyncio.get_running_loop() + tls_context = await loop.run_in_executor(None, ssl.create_default_context) + + while not self._stop_event.is_set(): + try: + _LOGGER.debug( + "Connecting MQTT WSS to wss://%s:%s/%s", + HOST, + PORT, + PATH, + ) + + async with aiomqtt.Client( + hostname=HOST, + port=PORT, + transport="websockets", + tls_context=tls_context, + username=base64.b64decode(USERNAME).decode(), + password=base64.b64decode(PASSWORD).decode(), + keepalive=30, + websocket_path=f"/{PATH}", + ) as client: + self._client = client + self._connected_event.set() + self._connected_at = datetime.now(UTC) + _LOGGER.info("Connected to MQTT broker") + + # Subscribe only to STATUS topics for known devices + await self._resubscribe_all() + + async for msg in client.messages: + if self._stop_event.is_set(): + break + await self._handle_status_message(msg) + + except asyncio.CancelledError: + break + except Exception: + _LOGGER.debug("MQTT connection error") + + finally: + if self._connected_event.is_set(): + self._connected_event.clear() + if self._connected_at: + _LOGGER.debug( + "MQTT was connected for %s", + datetime.now(UTC) - self._connected_at, + ) + self._connected_at = None + self._client = None + self._subscribed_serials.clear() + + if not self._stop_event.is_set(): + _LOGGER.debug( + "Disconnected from broker, retrying in %.1fs", RECONNECT_INTERVAL + ) + await asyncio.sleep(RECONNECT_INTERVAL) + + async def _handle_status_message(self, msg: aiomqtt.Message) -> None: + """Map MQTT STATUS topics → OasisDevice.update_from_status_dict payloads.""" + + topic_str = str(msg.topic) if msg.topic is not None else "" + payload = msg.payload.decode(errors="replace") + + parts = topic_str.split("/") + # Expect: "/STATUS/" + if len(parts) < 3: + return + + serial, _, status_name = parts[:3] + + device = self._devices.get(serial) + if not device: + # Ignore devices we don't know about + _LOGGER.debug("Received MQTT for unknown device %s: %s", serial, topic_str) + return + + data: dict[str, Any] = {} + + try: + if status_name == "OASIS_STATUS": + data["status_code"] = int(payload) + elif status_name == "OASIS_ERROR": + data["error"] = int(payload) + elif status_name == "OASIS_SPEEED": + data["ball_speed"] = int(payload) + elif status_name == "JOBLIST": + data["playlist"] = [int(x) for x in payload.split(",") if x] + elif status_name == "CURRENTJOB": + data["playlist_index"] = int(payload) + elif status_name == "CURRENTLINE": + data["progress"] = int(payload) + elif status_name == "LED_EFFECT": + data["led_effect"] = payload + elif status_name == "LED_EFFECT_COLOR": + data["led_effect_color"] = payload + elif status_name == "LED_SPEED": + data["led_speed"] = int(payload) + elif status_name == "LED_BRIGHTNESS": + data["brightness"] = int(payload) + elif status_name == "LED_MAX": + data["max_brightness"] = int(payload) + elif status_name == "LED_EFFECT_PARAM": + data["color"] = payload if payload.startswith("#") else None + elif status_name == "SYSTEM_BUSY": + data["busy"] = payload in ("1", "true", "True") + elif status_name == "DOWNLOAD_PROGRESS": + data["download_progress"] = int(payload) + elif status_name == "REPEAT_JOB": + data["repeat_playlist"] = payload in ("1", "true", "True") + elif status_name == "WAIT_AFTER_JOB": + data["autoplay"] = AUTOPLAY_MAP.get(payload, payload) + elif status_name == "AUTO_CLEAN": + data["auto_clean"] = payload in ("1", "true", "True") + elif status_name == "SOFTWARE_VER": + data["software_version"] = payload + elif status_name == "MAC_ADDRESS": + data["mac_address"] = payload + mac_event = self._mac_events.setdefault(serial, asyncio.Event()) + mac_event.set() + elif status_name == "WIFI_SSID": + data["wifi_ssid"] = payload + elif status_name == "WIFI_IP": + data["wifi_ip"] = payload + elif status_name == "WIFI_PDNS": + data["wifi_pdns"] = payload + elif status_name == "WIFI_SDNS": + data["wifi_sdns"] = payload + elif status_name == "WIFI_GATE": + data["wifi_gate"] = payload + elif status_name == "WIFI_SUB": + data["wifi_sub"] = payload + elif status_name == "WIFI_STATUS": + data["wifi_connected"] = _bit_to_bool(payload) + elif status_name == "SCHEDULE": + data["schedule"] = payload + elif status_name == "ENVIRONMENT": + data["environment"] = payload + else: + _LOGGER.warning( + "Unknown status received for %s: %s=%s", + serial, + status_name, + payload, + ) + except Exception: # noqa: BLE001 + _LOGGER.exception( + "Error parsing MQTT payload for %s %s: %r", serial, status_name, payload + ) + return + + if data: + device.update_from_status_dict(data) + + first_status_event = self._first_status_events.setdefault( + serial, asyncio.Event() + ) + if not first_status_event.is_set(): + first_status_event.set() diff --git a/custom_components/oasis_mini/pyoasiscontrol/clients/transport.py b/custom_components/oasis_mini/pyoasiscontrol/clients/transport.py new file mode 100644 index 0000000..3b3e1aa --- /dev/null +++ b/custom_components/oasis_mini/pyoasiscontrol/clients/transport.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from ..device import OasisDevice + + +@runtime_checkable +class OasisClientProtocol(Protocol): + """Transport/client interface for an Oasis device. + + Concrete implementations: + - MQTT client (remote connection) + - HTTP client (direct LAN) + """ + + async def async_get_mac_address(self, device: OasisDevice) -> str | None: ... + + async def async_send_ball_speed_command( + self, + device: OasisDevice, + speed: int, + ) -> None: ... + + async def async_send_led_command( + self, + device: OasisDevice, + led_effect: str, + color: str, + led_speed: int, + brightness: int, + ) -> None: ... + + async def async_send_sleep_command(self, device: OasisDevice) -> None: ... + + async def async_send_move_job_command( + self, + device: OasisDevice, + from_index: int, + to_index: int, + ) -> None: ... + + async def async_send_change_track_command( + self, + device: OasisDevice, + index: int, + ) -> None: ... + + async def async_send_add_joblist_command( + self, + device: OasisDevice, + tracks: list[int], + ) -> None: ... + + async def async_send_set_playlist_command( + self, + device: OasisDevice, + playlist: list[int], + ) -> None: ... + + async def async_send_set_repeat_playlist_command( + self, + device: OasisDevice, + repeat: bool, + ) -> None: ... + + async def async_send_set_autoplay_command( + self, + device: OasisDevice, + option: str, + ) -> None: ... + + async def async_send_upgrade_command( + self, + device: OasisDevice, + beta: bool, + ) -> None: ... + + async def async_send_play_command(self, device: OasisDevice) -> None: ... + + async def async_send_pause_command(self, device: OasisDevice) -> None: ... + + async def async_send_stop_command(self, device: OasisDevice) -> None: ... + + async def async_send_reboot_command(self, device: OasisDevice) -> None: ... diff --git a/custom_components/oasis_mini/pyoasiscontrol/const.py b/custom_components/oasis_mini/pyoasiscontrol/const.py new file mode 100644 index 0000000..2977c2d --- /dev/null +++ b/custom_components/oasis_mini/pyoasiscontrol/const.py @@ -0,0 +1,106 @@ +"""Constants.""" + +from __future__ import annotations + +import json +import os +from typing import Any, Final + +__TRACKS_FILE = os.path.join(os.path.dirname(__file__), "tracks.json") +try: + with open(__TRACKS_FILE, "r", encoding="utf8") as file: + TRACKS: Final[dict[int, dict[str, Any]]] = { + int(k): v for k, v in json.load(file).items() + } +except Exception: # ignore: broad-except + TRACKS = {} + +AUTOPLAY_MAP: Final[dict[str, str]] = { + "0": "on", + "1": "off", + "2": "5 minutes", + "3": "10 minutes", + "4": "30 minutes", + "5": "24 hours", +} + +ERROR_CODE_MAP: Final[dict[int, str]] = { + 0: "None", + 1: "Error has occurred while reading the flash memory", + 2: "Error while starting the Wifi", + 3: "Error when starting DNS settings for your machine", + 4: "Failed to open the file to write", + 5: "Not enough memory to perform the upgrade", + 6: "Error while trying to upgrade your system", + 7: "Error while trying to download the new version of the software", + 8: "Error while reading the upgrading file", + 9: "Failed to start downloading the upgrade file", + 10: "Error while starting downloading the job file", + 11: "Error while opening the file folder", + 12: "Failed to delete a file", + 13: "Error while opening the job file", + 14: "You have wrong power adapter", + 15: "Failed to update the device IP on Oasis Server", + 16: "Your device failed centering itself", + 17: "There appears to be an issue with your Oasis Device", + 18: "Error while downloading the job file", +} + +LED_EFFECTS: Final[dict[str, str]] = { + "0": "Solid", + "1": "Rainbow", + "2": "Glitter", + "3": "Confetti", + "4": "Sinelon", + "5": "BPM", + "6": "Juggle", + "7": "Theater", + "8": "Color Wipe", + "9": "Sparkle", + "10": "Comet", + "11": "Follow Ball", + "12": "Follow Rainbow", + "13": "Chasing Comet", + "14": "Gradient Follow", + "15": "Cumulative Fill", + "16": "Multi Comets A", + "17": "Rainbow Chaser", + "18": "Twinkle Lights", + "19": "Tennis Game", + "20": "Breathing Exercise 4-7-8", + "21": "Cylon Scanner", + "22": "Palette Mode", + "23": "Aurora Flow", + "24": "Colorful Drops", + "25": "Color Snake", + "26": "Flickering Candles", + "27": "Digital Rain", + "28": "Center Explosion", + "29": "Rainbow Plasma", + "30": "Comet Race", + "31": "Color Waves", + "32": "Meteor Storm", + "33": "Firefly Flicker", + "34": "Ripple", + "35": "Jelly Bean", + "36": "Forest Rain", + "37": "Multi Comets", + "38": "Multi Comets with Background", + "39": "Rainbow Fill", + "40": "White Red Comet", + "41": "Color Comets", +} + +STATUS_CODE_MAP: Final[dict[int, str]] = { + 0: "booting", + 2: "stopped", + 3: "centering", + 4: "playing", + 5: "paused", + 6: "sleeping", + 9: "error", + 11: "updating", + 13: "downloading", + 14: "busy", + 15: "live", +} diff --git a/custom_components/oasis_mini/pyoasiscontrol/device.py b/custom_components/oasis_mini/pyoasiscontrol/device.py new file mode 100644 index 0000000..8a25d47 --- /dev/null +++ b/custom_components/oasis_mini/pyoasiscontrol/device.py @@ -0,0 +1,327 @@ +"""Oasis device.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any, Callable, Final, Iterable + +from .const import ERROR_CODE_MAP, LED_EFFECTS, STATUS_CODE_MAP, TRACKS + +if TYPE_CHECKING: # avoid runtime circular imports + from .clients.transport import OasisClientProtocol + +_LOGGER = logging.getLogger(__name__) + +BALL_SPEED_MAX: Final = 400 +BALL_SPEED_MIN: Final = 100 +LED_SPEED_MAX: Final = 90 +LED_SPEED_MIN: Final = -90 + +_STATE_FIELDS = ( + "autoplay", + "ball_speed", + "brightness", + "busy", + "color", + "download_progress", + "error", + "led_effect", + "led_speed", + "mac_address", + "max_brightness", + "playlist", + "playlist_index", + "progress", + "repeat_playlist", + "serial_number", + "software_version", + "status_code", +) + + +class OasisDevice: + """Oasis device model + behavior. + + Transport-agnostic; all I/O is delegated to an attached + OasisClientProtocol (MQTT, HTTP, etc.) via `attach_client`. + """ + + manufacturer: Final = "Kinetic Oasis" + + def __init__( + self, + *, + model: str | None = None, + serial_number: str | None = None, + ssid: str | None = None, + ip_address: str | None = None, + client: OasisClientProtocol | None = None, + ) -> None: + # Transport + self._client: OasisClientProtocol | None = client + self._listeners: list[Callable[[], None]] = [] + + # Details + self.model: str | None = model + self.serial_number: str | None = serial_number + self.ssid: str | None = ssid + self.ip_address: str | None = ip_address + + # Status + self.auto_clean: bool = False + self.autoplay: str = "off" + self.ball_speed: int = BALL_SPEED_MIN + self.brightness: int = 0 + self.busy: bool = False + self.color: str | None = None + self.download_progress: int = 0 + self.error: int = 0 + self.led_effect: str = "0" + self.led_speed: int = 0 + self.mac_address: str | None = None + self.max_brightness: int = 200 + self.playlist: list[int] = [] + self.playlist_index: int = 0 + self.progress: int = 0 + self.repeat_playlist: bool = False + self.software_version: str | None = None + self.status_code: int = 0 + self.wifi_connected: bool = False + self.wifi_ip: str | None = None + self.wifi_ssid: str | None = None + self.wifi_pdns: str | None = None + self.wifi_sdns: str | None = None + self.wifi_gate: str | None = None + self.wifi_sub: str | None = None + self.environment: str | None = None + self.schedule: Any | None = None + + # Track metadata cache (used if you hydrate from cloud) + self._track: dict | None = None + + def attach_client(self, client: OasisClientProtocol) -> None: + """Attach a transport client (MQTT, HTTP, etc.) to this device.""" + self._client = client + + @property + def client(self) -> OasisClientProtocol | None: + """Return the current transport client, if any.""" + return self._client + + def _require_client(self) -> OasisClientProtocol: + """Return the attached client or raise if missing.""" + if self._client is None: + raise RuntimeError( + f"No client/transport attached for device {self.serial_number!r}" + ) + return self._client + + def _update_field(self, name: str, value: Any) -> bool: + old = getattr(self, name, None) + if old != value: + _LOGGER.debug( + "%s changed: '%s' -> '%s'", + name.replace("_", " ").capitalize(), + old, + value, + ) + setattr(self, name, value) + return True + return False + + def update_from_status_dict(self, data: dict[str, Any]) -> None: + """Update device fields from a status payload (from any transport).""" + changed = False + for key, value in data.items(): + if hasattr(self, key): + if self._update_field(key, value): + changed = True + else: + _LOGGER.warning("Unknown field: %s=%s", key, value) + + if changed: + self._notify_listeners() + + def as_dict(self) -> dict[str, Any]: + """Return core state as a dict.""" + return {field: getattr(self, field) for field in _STATE_FIELDS} + + @property + def error_message(self) -> str | None: + """Return the error message, if any.""" + if self.status_code == 9: + return ERROR_CODE_MAP.get(self.error, f"Unknown ({self.error})") + return None + + @property + def status(self) -> str: + """Return human-readable status from status_code.""" + return STATUS_CODE_MAP.get(self.status_code, f"Unknown ({self.status_code})") + + @property + def track_id(self) -> int | None: + if not self.playlist: + return None + i = self.playlist_index + 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 + return None + + @property + def drawing_progress(self) -> float | None: + """Return drawing progress percentage for the current track.""" + # if not (self.track and (svg_content := self.track.get("svg_content"))): + # return None + # svg_content = decrypt_svg_content(svg_content) + # paths = svg_content.split("L") + total = self.track.get("reduced_svg_content_new", 0) # or len(paths) + percent = (100 * self.progress) / total + return percent + + @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, + {"name": f"Unknown Title (#{track_id})"}, + ) + for track_id in self.playlist + } + + def add_update_listener(self, listener: Callable[[], None]) -> Callable[[], None]: + """Register a callback for state changes. + + Returns an unsubscribe function. + """ + self._listeners.append(listener) + + def _unsub() -> None: + try: + self._listeners.remove(listener) + except ValueError: + pass + + return _unsub + + def _notify_listeners(self) -> None: + """Call all registered listeners.""" + for listener in list(self._listeners): + try: + listener() + except Exception: # noqa: BLE001 + _LOGGER.exception("Error in update listener") + + async def async_get_mac_address(self) -> str | None: + """Return the device MAC address, refreshing via transport if needed.""" + if self.mac_address: + return self.mac_address + + client = self._require_client() + mac = await client.async_get_mac_address(self) + if mac: + self._update_field("mac_address", mac) + return mac + + async def async_set_ball_speed(self, speed: int) -> None: + if not BALL_SPEED_MIN <= speed <= BALL_SPEED_MAX: + raise ValueError("Invalid speed specified") + client = self._require_client() + await client.async_send_ball_speed_command(self, speed) + + async def async_set_led( + self, + *, + led_effect: str | None = None, + color: str | None = None, + led_speed: int | None = None, + brightness: int | None = None, + ) -> None: + """Set the Oasis Mini LED (shared validation & attribute updates).""" + if led_effect is None: + led_effect = self.led_effect + if color is None: + color = self.color or "#ffffff" + if led_speed is None: + led_speed = self.led_speed + if brightness is None: + brightness = self.brightness + + if led_effect not in LED_EFFECTS: + raise ValueError("Invalid led effect specified") + if not LED_SPEED_MIN <= led_speed <= LED_SPEED_MAX: + raise ValueError("Invalid led speed specified") + if not 0 <= brightness <= self.max_brightness: + raise ValueError("Invalid brightness specified") + + client = self._require_client() + await client.async_send_led_command( + self, led_effect, color, led_speed, brightness + ) + + async def async_sleep(self) -> None: + client = self._require_client() + await client.async_send_sleep_command(self) + + async def async_move_track(self, from_index: int, to_index: int) -> None: + client = self._require_client() + await client.async_send_move_job_command(self, from_index, to_index) + + async def async_change_track(self, index: int) -> None: + client = self._require_client() + await client.async_send_change_track_command(self, index) + + async def async_add_track_to_playlist(self, track: int | Iterable[int]) -> None: + if isinstance(track, int): + tracks = [track] + else: + tracks = list(track) + client = self._require_client() + await client.async_send_add_joblist_command(self, tracks) + + async def async_set_playlist(self, playlist: int | Iterable[int]) -> None: + if isinstance(playlist, int): + playlist_list = [playlist] + else: + playlist_list = list(playlist) + client = self._require_client() + await client.async_send_set_playlist_command(self, playlist_list) + + async def async_set_repeat_playlist(self, repeat: bool) -> None: + client = self._require_client() + await client.async_send_set_repeat_playlist_command(self, repeat) + + async def async_set_autoplay(self, option: bool | int | str) -> None: + """Set autoplay / wait-after behavior.""" + if isinstance(option, bool): + option = 0 if option else 1 + client = self._require_client() + await client.async_send_set_autoplay_command(self, str(option)) + + async def async_upgrade(self, beta: bool = False) -> None: + client = self._require_client() + await client.async_send_upgrade_command(self, beta) + + async def async_play(self) -> None: + client = self._require_client() + await client.async_send_play_command(self) + + async def async_pause(self) -> None: + client = self._require_client() + await client.async_send_pause_command(self) + + async def async_stop(self) -> None: + client = self._require_client() + await client.async_send_stop_command(self) + + async def async_reboot(self) -> None: + client = self._require_client() + await client.async_send_reboot_command(self) diff --git a/custom_components/oasis_mini/pyoasiscontrol/exceptions.py b/custom_components/oasis_mini/pyoasiscontrol/exceptions.py new file mode 100644 index 0000000..11a5bab --- /dev/null +++ b/custom_components/oasis_mini/pyoasiscontrol/exceptions.py @@ -0,0 +1,5 @@ +"""Exceptions.""" + + +class UnauthenticatedError(Exception): + """Unauthenticated.""" diff --git a/custom_components/oasis_mini/pyoasismini/tracks.json b/custom_components/oasis_mini/pyoasiscontrol/tracks.json similarity index 99% rename from custom_components/oasis_mini/pyoasismini/tracks.json rename to custom_components/oasis_mini/pyoasiscontrol/tracks.json index e75df92..972583a 100644 --- a/custom_components/oasis_mini/pyoasismini/tracks.json +++ b/custom_components/oasis_mini/pyoasiscontrol/tracks.json @@ -248,7 +248,7 @@ "pattern_id": null, "clean_type": "clean_id", "png_image": "2024/07/555711592b95ead6bdb8ab6621218a19.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 11147 }, "911": { @@ -698,7 +698,7 @@ "pattern_id": null, "clean_type": "clean_out", "png_image": "2024/07/ff39e5bc1fec682169e3531a4ce4089a.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 23127 }, "1955": { @@ -1256,7 +1256,7 @@ "pattern_id": null, "clean_type": "clean_out", "png_image": "2024/07/164a40408dc19e33c638e01edeb716e0.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 20855 }, "2548": { @@ -1598,7 +1598,7 @@ "pattern_id": null, "clean_type": "clean_id", "png_image": "2024/07/396ceffefb40502a26cefdcd8f070f74.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 34616 }, "5531": { @@ -1706,7 +1706,7 @@ "pattern_id": null, "clean_type": "clean_out", "png_image": "2024/07/4170af889117c6274a51b794dbc1be39.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 18521 }, "731": { @@ -2552,7 +2552,7 @@ "pattern_id": null, "clean_type": "clean_id", "png_image": "2024/07/99a5030e029064697ccee0ab7a7b82e3.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 3906 }, "501": { @@ -2696,7 +2696,7 @@ "pattern_id": null, "clean_type": "clean_id", "png_image": "2025/07/c66f47ea5a96a6935e8d57242b4c0e08.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 51293 }, "8574": { @@ -2714,7 +2714,7 @@ "pattern_id": null, "clean_type": "clean_out", "png_image": "2025/07/ee07f942a9739708866a58ae0632113e.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 51577 }, "1300": { @@ -3416,7 +3416,7 @@ "pattern_id": null, "clean_type": "clean_id", "png_image": "2024/07/26a000c96aee5415e26eb465760503ac.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 2889 }, "145": { @@ -3434,7 +3434,7 @@ "pattern_id": null, "clean_type": "clean_out", "png_image": "2024/07/adff0d117feac08d537d856d5aa3b6df.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 6303 }, "144": { @@ -3452,7 +3452,7 @@ "pattern_id": null, "clean_type": "clean_out", "png_image": "2024/07/18aca44756f97d51d90c48fdb0a496de.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 731 }, "645": { @@ -3488,7 +3488,7 @@ "pattern_id": null, "clean_type": "clean_out", "png_image": "2024/07/4887db1097b9958a369e698950437507.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 27464 }, "140": { @@ -3506,7 +3506,7 @@ "pattern_id": null, "clean_type": "clean_id", "png_image": "2024/07/de850e58dd3b7681e2c0b979938be0f4.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 4733 }, "1173": { @@ -3740,7 +3740,7 @@ "pattern_id": null, "clean_type": "clean_out", "png_image": "2024/07/8f7b66e5693d7f6970032640fc08fa7d.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 5093 }, "9069": { @@ -3794,7 +3794,7 @@ "pattern_id": null, "clean_type": "clean_out", "png_image": "2024/07/fb6f9d798858a286b4cf0ecd457f5764.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 8308 }, "244": { @@ -4208,7 +4208,7 @@ "pattern_id": null, "clean_type": "clean_out", "png_image": "2024/07/11d2585915403f01c5ed206a1ce72823.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 15116 }, "7255": { @@ -4244,7 +4244,7 @@ "pattern_id": null, "clean_type": "clean_out", "png_image": "2024/07/7cb25ab3fbea0fc33a013e05bfc7b393.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 74399 }, "8438": { @@ -4784,7 +4784,7 @@ "pattern_id": null, "clean_type": "clean_out", "png_image": "2024/07/8c47865ce324527a1e1302b84374aba1.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 7666 }, "3854": { @@ -5846,7 +5846,7 @@ "pattern_id": null, "clean_type": "clean_out", "png_image": "2024/07/9ec8783a5927865ac447c1fbc52ad4b6.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 1747 }, "8296": { @@ -6332,7 +6332,7 @@ "pattern_id": null, "clean_type": "clean_out", "png_image": "2024/07/14391633a1b113214acea29dcb6ce956.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 6447 }, "496": { @@ -6746,7 +6746,7 @@ "pattern_id": null, "clean_type": "clean_id", "png_image": "2024/07/1e4d9af48b01078a79d41b8f640b9fba.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 8302 }, "5076": { @@ -6890,7 +6890,7 @@ "pattern_id": null, "clean_type": "clean_out", "png_image": "2024/07/ed0ef7b0c8bb0f58ad0b8cea1c110377.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 10346 }, "5427": { @@ -7340,7 +7340,7 @@ "pattern_id": null, "clean_type": "clean_out", "png_image": "2024/07/b80aac9461e2ea5bb28fff5432d0e96d.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 1413 }, "1881": { @@ -7754,7 +7754,7 @@ "pattern_id": null, "clean_type": "clean_id", "png_image": "2024/07/5527235b74c3f9327728caddf73eda5b.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 68851 }, "3261": { @@ -8384,7 +8384,7 @@ "pattern_id": null, "clean_type": "clean_out", "png_image": "2024/07/30ba21fa799c584805f76483ca58e0fc.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 11195 }, "8508": { @@ -8726,7 +8726,7 @@ "pattern_id": null, "clean_type": "clean_id", "png_image": "2024/07/9414dab5a2c902f9082c5285b648c12c.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 7386 }, "150": { @@ -8744,7 +8744,7 @@ "pattern_id": null, "clean_type": "clean_out", "png_image": "2024/07/d1bc84624a5f781e658543a8ff6dacda.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 15135 }, "4120": { @@ -9410,7 +9410,7 @@ "pattern_id": null, "clean_type": "clean_id", "png_image": "2024/07/2c696d0c73ba27516e9b10c5ff4273a9.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 5915 }, "8637": { @@ -10130,7 +10130,7 @@ "pattern_id": null, "clean_type": "clean_out", "png_image": "2024/07/9367571926389a83f5bd360b59564d27.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 16093 }, "6913": { @@ -10382,7 +10382,7 @@ "pattern_id": null, "clean_type": "clean_id", "png_image": "2024/07/fa971e69ebddd04cafbb84d8f34aff27.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 13617 }, "3838": { @@ -10580,7 +10580,7 @@ "pattern_id": null, "clean_type": "clean_out", "png_image": "2024/07/7f00648bad34e7ae8349c0276654d31d.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 5941 }, "190": { @@ -10760,7 +10760,7 @@ "pattern_id": null, "clean_type": "clean_id", "png_image": "2024/07/c968163900a6742849b25caff86da4c4.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 8766 }, "203": { @@ -10832,7 +10832,7 @@ "pattern_id": null, "clean_type": "clean_id", "png_image": "2025/07/559088369449d66e3a790b4c71c67d95.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 21301 }, "8576": { @@ -10850,7 +10850,7 @@ "pattern_id": null, "clean_type": "clean_out", "png_image": "2025/07/e89e5fcd99aec9f636295b300b8bcb01.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 21510 }, "149": { @@ -10868,7 +10868,7 @@ "pattern_id": null, "clean_type": "clean_out", "png_image": "2024/07/d164d6b9f954aeaf5c665893c5d9478d.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 6469 }, "9606": { @@ -11138,7 +11138,7 @@ "pattern_id": null, "clean_type": "clean_out", "png_image": "2024/07/01a76b971a4db7221f8e901f0d91a4a3.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 9704 }, "180": { @@ -11534,7 +11534,7 @@ "pattern_id": null, "clean_type": "clean_out", "png_image": "2024/07/1f3fe8bff4cc1f68314f39961b9e46dd.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 24652 }, "777": { @@ -11768,7 +11768,7 @@ "pattern_id": null, "clean_type": "clean_id", "png_image": "2024/07/6c30197a082cf046b85865fb3c03395b.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 18449 }, "119": { @@ -11786,7 +11786,7 @@ "pattern_id": null, "clean_type": "clean_id", "png_image": "2024/07/a2b1b7b63f600a00f30be0534a3c5be1.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 6509 }, "8559": { @@ -11840,7 +11840,7 @@ "pattern_id": null, "clean_type": "clean_out", "png_image": "2024/07/7ac471123e3d352ab6661e988f448cbb.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 6628 }, "8557": { @@ -13460,7 +13460,7 @@ "pattern_id": null, "clean_type": "clean_out", "png_image": "2024/07/27175262d2255a0bd9d72576757f89f9.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 22220 }, "8497": { @@ -14432,7 +14432,7 @@ "pattern_id": null, "clean_type": "clean_out", "png_image": "2024/07/ffe4e8a54516aae904bd99cea1852462.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 32447 }, "3203": { @@ -14612,7 +14612,7 @@ "pattern_id": null, "clean_type": "clean_id", "png_image": "2024/07/30bb7e2f31c39d2dac25825aa3729fdb.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 14843 }, "4583": { @@ -14666,7 +14666,7 @@ "pattern_id": null, "clean_type": "clean_out", "png_image": "2024/07/a0ce4bad2cb19f685a8ccfe2192a77eb.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 14870 }, "242": { @@ -15224,7 +15224,7 @@ "pattern_id": null, "clean_type": "clean_id", "png_image": "2024/07/9d3935dc019b2ce504612653c6e7ab72.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 17081 }, "1963": { @@ -15278,7 +15278,7 @@ "pattern_id": null, "clean_type": "clean_id", "png_image": "2024/07/9b6276f3acafe103a82b7123f31d65a8.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 8464 }, "1965": { @@ -15602,7 +15602,7 @@ "pattern_id": null, "clean_type": "clean_out", "png_image": "2024/07/79d0fed8ad1689b22dd4658660a89886.png", - "author": "Oasis Mini", + "author": "Kinetic Oasis", "reduced_svg_content_new": 8397 }, "7387": { diff --git a/custom_components/oasis_mini/pyoasismini/utils.py b/custom_components/oasis_mini/pyoasiscontrol/utils.py similarity index 100% rename from custom_components/oasis_mini/pyoasismini/utils.py rename to custom_components/oasis_mini/pyoasiscontrol/utils.py diff --git a/custom_components/oasis_mini/pyoasismini/__init__.py b/custom_components/oasis_mini/pyoasismini/__init__.py deleted file mode 100644 index 61b335b..0000000 --- a/custom_components/oasis_mini/pyoasismini/__init__.py +++ /dev/null @@ -1,535 +0,0 @@ -"""Oasis Mini API client.""" - -from __future__ import annotations - -import asyncio -from datetime import datetime, timedelta -import logging -from typing import Any, Awaitable, Final -from urllib.parse import urljoin - -from aiohttp import ClientResponseError, ClientSession - -from .const import TRACKS -from .utils import _bit_to_bool, _parse_int, decrypt_svg_content, now - -_LOGGER = logging.getLogger(__name__) - -STATUS_CODE_MAP = { - 0: "booting", - 2: "stopped", - 3: "centering", - 4: "playing", - 5: "paused", - 6: "sleeping", - 9: "error", - 11: "updating", - 13: "downloading", - 14: "busy", - 15: "live", -} - -ERROR_CODE_MAP = { - 0: "None", - 1: "Error has occurred while reading the flash memory", - 2: "Error while starting the Wifi", - 3: "Error when starting DNS settings for your machine", - 4: "Failed to open the file to write", - 5: "Not enough memory to perform the upgrade", - 6: "Error while trying to upgrade your system", - 7: "Error while trying to download the new version of the software", - 8: "Error while reading the upgrading file", - 9: "Failed to start downloading the upgrade file", - 10: "Error while starting downloading the job file", - 11: "Error while opening the file folder", - 12: "Failed to delete a file", - 13: "Error while opening the job file", - 14: "You have wrong power adapter", - 15: "Failed to update the device IP on Oasis Server", - 16: "Your device failed centering itself", - 17: "There appears to be an issue with your Oasis Device", - 18: "Error while downloading the job file", -} - -AUTOPLAY_MAP = { - "0": "on", - "1": "off", - "2": "5 minutes", - "3": "10 minutes", - "4": "30 minutes", - "5": "24 hours", -} - -LED_EFFECTS: Final[dict[str, str]] = { - "0": "Solid", - "1": "Rainbow", - "2": "Glitter", - "3": "Confetti", - "4": "Sinelon", - "5": "BPM", - "6": "Juggle", - "7": "Theater", - "8": "Color Wipe", - "9": "Sparkle", - "10": "Comet", - "11": "Follow Ball", - "12": "Follow Rainbow", - "13": "Chasing Comet", - "14": "Gradient Follow", - "15": "Cumulative Fill", - "16": "Multi Comets A", - "17": "Rainbow Chaser", - "18": "Twinkle Lights", - "19": "Tennis Game", - "20": "Breathing Exercise 4-7-8", - "21": "Cylon Scanner", - "22": "Palette Mode", - "23": "Aurora Flow", - "24": "Colorful Drops", - "25": "Color Snake", - "26": "Flickering Candles", - "27": "Digital Rain", - "28": "Center Explosion", - "29": "Rainbow Plasma", - "30": "Comet Race", - "31": "Color Waves", - "32": "Meteor Storm", - "33": "Firefly Flicker", - "34": "Ripple", - "35": "Jelly Bean", - "36": "Forest Rain", - "37": "Multi Comets", - "38": "Multi Comets with Background", - "39": "Rainbow Fill", - "40": "White Red Comet", - "41": "Color Comets", -} - -CLOUD_BASE_URL = "https://app.grounded.so" - -BALL_SPEED_MAX: Final = 400 -BALL_SPEED_MIN: Final = 100 -LED_SPEED_MAX: Final = 90 -LED_SPEED_MIN: Final = -90 - -PLAYLISTS_REFRESH_LIMITER = timedelta(minutes=5) - - -class OasisMini: - """Oasis Mini API client class.""" - - _access_token: str | None = None - _mac_address: str | None = None - _ip_address: str | None = None - _playlist: dict[int, dict[str, str]] = {} - _serial_number: str | None = None - _software_version: str | None = None - _track: dict | None = None - - autoplay: str - brightness: int - busy: bool - color: str | None = None - download_progress: int - error: int - led_effect: str - led_speed: int - max_brightness: int - playlist: list[int] - playlist_index: int - progress: int - repeat_playlist: bool - status_code: int - - playlists: list[dict[str, Any]] = [] - _playlists_next_refresh: datetime = now() - - def __init__( - self, - host: str, - access_token: str | None = None, - session: ClientSession | None = None, - ) -> None: - """Initialize the client.""" - self._host = host - self._access_token = access_token - self._session = session if session else ClientSession() - - @property - def access_token(self) -> str | None: - """Return the access token, if any.""" - return self._access_token - - @property - def mac_address(self) -> str | None: - """Return the mac address.""" - return self._mac_address - - @property - def drawing_progress(self) -> float | None: - """Return the drawing progress percent.""" - if not (self.track and (svg_content := self.track.get("svg_content"))): - return None - svg_content = decrypt_svg_content(svg_content) - paths = svg_content.split("L") - total = self.track.get("reduced_svg_content_new", 0) or len(paths) - percent = (100 * self.progress) / total - return percent - - @property - def error_message(self) -> str | None: - """Return the error message, if any.""" - if self.status_code == 9: - return ERROR_CODE_MAP.get(self.error, f"Unknown ({self.error})") - return None - - @property - def serial_number(self) -> str | None: - """Return the serial number.""" - return self._serial_number - - @property - def session(self) -> ClientSession: - """Return the session.""" - return self._session - - @property - def software_version(self) -> str | None: - """Return the software version.""" - return self._software_version - - @property - def status(self) -> str: - """Return the status.""" - return STATUS_CODE_MAP.get(self.status_code, f"Unknown ({self.status_code})") - - @property - def track(self) -> dict | None: - """Return the current track info.""" - if self._track and self._track.get("id") == self.track_id: - return self._track - return None - - @property - def track_id(self) -> int | None: - """Return the current track id.""" - if not self.playlist: - return None - i = self.playlist_index - return self.playlist[0] if i >= len(self.playlist) else self.playlist[i] - - @property - def url(self) -> str: - """Return the url.""" - return f"http://{self._host}/" - - async def async_add_track_to_playlist(self, track: int | list[int]) -> None: - """Add track to playlist.""" - if not track: - return - if isinstance(track, int): - track = [track] - if 0 in self.playlist: - playlist = [t for t in self.playlist if t] + track - return await self.async_set_playlist(playlist) - await self._async_command(params={"ADDJOBLIST": track}) - self.playlist.extend(track) - - async def async_change_track(self, index: int) -> None: - """Change the track.""" - if index >= len(self.playlist): - raise ValueError("Invalid index specified") - await self._async_command(params={"CMDCHANGETRACK": index}) - - async def async_clear_playlist(self) -> None: - """Clear the playlist.""" - await self.async_set_playlist([]) - - async def async_get_ip_address(self) -> str | None: - """Get the ip address.""" - self._ip_address = await self._async_get(params={"GETIP": ""}) - _LOGGER.debug("IP address: %s", self._ip_address) - return self._ip_address - - async def async_get_mac_address(self) -> str | None: - """Get the mac address.""" - self._mac_address = await self._async_get(params={"GETMAC": ""}) - _LOGGER.debug("MAC address: %s", self._mac_address) - return self._mac_address - - async def async_get_serial_number(self) -> str | None: - """Get the serial number.""" - self._serial_number = await self._async_get(params={"GETOASISID": ""}) - _LOGGER.debug("Serial number: %s", self._serial_number) - return self._serial_number - - async def async_get_software_version(self) -> str | None: - """Get the software version.""" - self._software_version = await self._async_get(params={"GETSOFTWAREVER": ""}) - _LOGGER.debug("Software version: %s", self._software_version) - return self._software_version - - async def async_get_status(self) -> str: - """Get the status from the device.""" - raw_status = await self._async_get(params={"GETSTATUS": ""}) - _LOGGER.debug("Status: %s", raw_status) - values = raw_status.split(";") - playlist = [_parse_int(track) for track in values[3].split(",") if track] - shift = len(values) - 18 if len(values) > 17 else 0 - status = { - "status_code": _parse_int(values[0]), # see status code map - "error": _parse_int(values[1]), - "ball_speed": _parse_int(values[2]), # 200 - 1000 - "playlist": playlist, - "playlist_index": min(_parse_int(values[4]), len(playlist)), # noqa: E501; index of above - "progress": _parse_int(values[5]), # 0 - max svg path - "led_effect": values[6], # led effect (code lookup) - "led_color_id": values[7], # led color id? - "led_speed": _parse_int(values[8]), # -90 - 90 - "brightness": _parse_int(values[9]), # noqa: E501; 0 - 200 in app, but seems to be 0 (off) to 304 (max), then repeats - "color": values[10] if "#" in values[10] else None, # hex color code - "busy": _bit_to_bool(values[11 + shift]), # noqa: E501; device is busy (downloading track, centering, software update)? - "download_progress": _parse_int(values[12 + shift]), - "max_brightness": _parse_int(values[13 + shift]), - "wifi_connected": _bit_to_bool(values[14 + shift]), - "repeat_playlist": _bit_to_bool(values[15 + shift]), - "autoplay": AUTOPLAY_MAP.get(value := values[16 + shift], value), - "autoclean": _bit_to_bool(values[17 + shift]) - if len(values) > 17 - else False, - } - for key, value in status.items(): - if (old_value := getattr(self, key, None)) != value: - _LOGGER.debug( - "%s changed: '%s' -> '%s'", - key.replace("_", " ").capitalize(), - old_value, - value, - ) - setattr(self, key, value) - return raw_status - - async def async_move_track(self, _from: int, _to: int) -> None: - """Move a track in the playlist.""" - await self._async_command(params={"MOVEJOB": f"{_from};{_to}"}) - - async def async_pause(self) -> None: - """Send pause command.""" - await self._async_command(params={"CMDPAUSE": ""}) - - async def async_play(self) -> None: - """Send play command.""" - if self.status_code == 15: - await self.async_stop() - if self.track_id: - await self._async_command(params={"CMDPLAY": ""}) - - async def async_reboot(self) -> None: - """Send reboot command.""" - - async def _no_response_needed(coro: Awaitable) -> None: - try: - await coro - except Exception as ex: - _LOGGER.error(ex) - - reboot = self._async_command(params={"CMDBOOT": ""}) - asyncio.create_task(_no_response_needed(reboot)) - - async def async_set_ball_speed(self, speed: int) -> None: - """Set the Oasis Mini ball speed.""" - if not BALL_SPEED_MIN <= speed <= BALL_SPEED_MAX: - raise ValueError("Invalid speed specified") - - await self._async_command(params={"WRIOASISSPEED": speed}) - - async def async_set_led( - self, - *, - led_effect: str | None = None, - color: str | None = None, - led_speed: int | None = None, - brightness: int | None = None, - ) -> None: - """Set the Oasis Mini led.""" - if led_effect is None: - led_effect = self.led_effect - if color is None: - color = self.color or "#ffffff" - if led_speed is None: - led_speed = self.led_speed - if brightness is None: - brightness = self.brightness - - if led_effect not in LED_EFFECTS: - raise ValueError("Invalid led effect specified") - if not LED_SPEED_MIN <= led_speed <= LED_SPEED_MAX: - raise ValueError("Invalid led speed specified") - if not 0 <= brightness <= self.max_brightness: - raise ValueError("Invalid brightness specified") - - await self._async_command( - params={"WRILED": f"{led_effect};0;{color};{led_speed};{brightness}"} - ) - - async def async_set_autoplay(self, option: bool | int | str) -> None: - """Set autoplay.""" - if isinstance(option, bool): - option = 0 if option else 1 - if str(option) not in AUTOPLAY_MAP: - raise ValueError("Invalid pause option specified") - await self._async_command(params={"WRIWAITAFTER": option}) - - async def async_set_playlist(self, playlist: list[int] | int) -> None: - """Set the playlist.""" - if isinstance(playlist, int): - playlist = [playlist] - if is_playing := (self.status_code == 4): - await self.async_stop() - await self._async_command(params={"WRIJOBLIST": ",".join(map(str, playlist))}) - self.playlist = playlist - if is_playing: - await self.async_play() - - async def async_set_repeat_playlist(self, repeat: bool) -> None: - """Set repeat playlist.""" - await self._async_command(params={"WRIREPEATJOB": 1 if repeat else 0}) - - async def async_sleep(self) -> None: - """Send sleep command.""" - await self._async_command(params={"CMDSLEEP": ""}) - - async def async_stop(self) -> None: - """Send stop command.""" - await self._async_command(params={"CMDSTOP": ""}) - - async def async_upgrade(self, beta: bool = False) -> None: - """Trigger a software upgrade.""" - await self._async_command(params={"CMDUPGRADE": 1 if beta else 0}) - - async def async_cloud_login(self, email: str, password: str) -> None: - """Login via the cloud.""" - response = await self._async_request( - "POST", - urljoin(CLOUD_BASE_URL, "api/auth/login"), - json={"email": email, "password": password}, - ) - self._access_token = response.get("access_token") - - async def async_cloud_logout(self) -> None: - """Login via the cloud.""" - await self._async_cloud_request("GET", "api/auth/logout") - - async def async_cloud_get_playlists( - self, personal_only: bool = False - ) -> list[dict[str, Any]]: - """Get playlists from the cloud.""" - if self._playlists_next_refresh <= now(): - if playlists := await self._async_cloud_request( - "GET", "api/playlist", params={"my_playlists": str(personal_only)} - ): - self.playlists = playlists - self._playlists_next_refresh = now() + PLAYLISTS_REFRESH_LIMITER - return self.playlists - - async def async_cloud_get_track_info(self, track_id: int) -> dict[str, Any] | None: - """Get cloud track info.""" - try: - return await self._async_cloud_request("GET", f"api/track/{track_id}") - except ClientResponseError as err: - if err.status == 404: - return {"id": track_id, "name": f"Unknown Title (#{track_id})"} - except Exception as ex: - _LOGGER.exception(ex) - return None - - async def async_cloud_get_tracks( - self, tracks: list[int] | None = None - ) -> list[dict[str, Any]]: - """Get tracks info from the cloud""" - response = await self._async_cloud_request( - "GET", "api/track", params={"ids[]": tracks or []} - ) - if not response: - return [] - track_details = response.get("data", []) - while next_page_url := response.get("next_page_url"): - response = await self._async_cloud_request("GET", next_page_url) - track_details += response.get("data", []) - return track_details - - async def async_cloud_get_latest_software_details(self) -> dict[str, int | str]: - """Get the latest software details from the cloud.""" - return await self._async_cloud_request("GET", "api/software/last-version") - - async def async_get_current_track_details(self) -> dict | None: - """Get current track info, refreshing if needed.""" - track_id = self.track_id - if (track := self._track) and track.get("id") == track_id: - return track - if track_id: - self._track = await self.async_cloud_get_track_info(track_id) - if not self._track: - self._track = TRACKS.get( - track_id, {"id": track_id, "name": f"Unknown Title (#{track_id})"} - ) - return self._track - - async def async_get_playlist_details(self) -> dict[int, dict[str, str]]: - """Get playlist info.""" - if set(self.playlist).difference(self._playlist.keys()): - tracks = await self.async_cloud_get_tracks(self.playlist) - all_tracks = TRACKS | { - track["id"]: { - "name": track["name"], - "author": ((track.get("author") or {}).get("person") or {}).get( - "name", "Oasis Mini" - ), - "image": track["image"], - } - for track in tracks - } - for track in self.playlist: - self._playlist[track] = all_tracks.get( - track, {"name": f"Unknown Title (#{track})"} - ) - return self._playlist - - async def _async_cloud_request(self, method: str, url: str, **kwargs: Any) -> Any: - """Perform a cloud request.""" - if not self.access_token: - return - - return await self._async_request( - method, - urljoin(CLOUD_BASE_URL, url), - headers={"Authorization": f"Bearer {self.access_token}"}, - **kwargs, - ) - - async def _async_command(self, **kwargs: Any) -> str | None: - """Send a command to the device.""" - result = await self._async_get(**kwargs) - _LOGGER.debug("Result: %s", result) - - async def _async_get(self, **kwargs: Any) -> str | None: - """Perform a GET request.""" - return await self._async_request("GET", self.url, **kwargs) - - async def _async_request(self, method: str, url: str, **kwargs) -> Any: - """Perform a request.""" - _LOGGER.debug( - "%s %s", - method, - self._session._build_url(url).update_query( # pylint: disable=protected-access - kwargs.get("params") - ), - ) - response = await self._session.request(method, url, **kwargs) - if response.status == 200: - if response.content_type == "application/json": - return await response.json() - if response.content_type == "text/plain": - return await response.text() - return None - response.raise_for_status() diff --git a/custom_components/oasis_mini/pyoasismini/const.py b/custom_components/oasis_mini/pyoasismini/const.py deleted file mode 100644 index 2a3a4f5..0000000 --- a/custom_components/oasis_mini/pyoasismini/const.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Constants.""" - -from __future__ import annotations - -import json -import os -from typing import Any, Final - -__TRACKS_FILE = os.path.join(os.path.dirname(__file__), "tracks.json") -try: - with open(__TRACKS_FILE, "r", encoding="utf8") as file: - TRACKS: Final[dict[int, dict[str, Any]]] = { - int(k): v for k, v in json.load(file).items() - } -except Exception: # ignore: broad-except - TRACKS = {} diff --git a/custom_components/oasis_mini/select.py b/custom_components/oasis_mini/select.py index 038758d..8e2da4a 100644 --- a/custom_components/oasis_mini/select.py +++ b/custom_components/oasis_mini/select.py @@ -1,4 +1,4 @@ -"""Oasis Mini select entity.""" +"""Oasis device select entity.""" from __future__ import annotations @@ -11,35 +11,130 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import OasisMiniConfigEntry -from .coordinator import OasisMiniCoordinator -from .entity import OasisMiniEntity -from .pyoasismini import AUTOPLAY_MAP, OasisMini -from .pyoasismini.const import TRACKS +from . import OasisDeviceConfigEntry +from .coordinator import OasisDeviceCoordinator +from .entity import OasisDeviceEntity +from .pyoasiscontrol import OasisDevice +from .pyoasiscontrol.const import AUTOPLAY_MAP, TRACKS + + +def playlists_update_handler(entity: OasisDeviceSelectEntity) -> None: + """Handle playlists updates.""" + # pylint: disable=protected-access + device = entity.device + counts = defaultdict(int) + options = [] + current_option: str | None = None + for playlist in device.playlists: + name = playlist["name"] + counts[name] += 1 + if counts[name] > 1: + name = f"{name} ({counts[name]})" + options.append(name) + if device.playlist == [pattern["id"] for pattern in playlist["patterns"]]: + current_option = name + entity._attr_options = options + entity._attr_current_option = current_option + + +def queue_update_handler(entity: OasisDeviceSelectEntity) -> None: + """Handle queue updates.""" + # pylint: disable=protected-access + device = entity.device + counts = defaultdict(int) + options = [] + for track in device.playlist: + name = device.playlist_details.get(track, {}).get( + "name", + TRACKS.get(track, {"id": track, "name": f"Unknown Title (#{track})"}).get( + "name", + device.track["name"] + if device.track and device.track["id"] == track + else str(track), + ), + ) + counts[name] += 1 + if counts[name] > 1: + name = f"{name} ({counts[name]})" + options.append(name) + entity._attr_options = options + index = min(device.playlist_index, len(options) - 1) + entity._attr_current_option = options[index] if options else None + + +async def async_setup_entry( + hass: HomeAssistant, + entry: OasisDeviceConfigEntry, + 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) @dataclass(frozen=True, kw_only=True) -class OasisMiniSelectEntityDescription(SelectEntityDescription): - """Oasis Mini select entity description.""" +class OasisDeviceSelectEntityDescription(SelectEntityDescription): + """Oasis device select entity description.""" - current_value: Callable[[OasisMini], Any] - select_fn: Callable[[OasisMini, int], Awaitable[None]] - update_handler: Callable[[OasisMiniSelectEntity], None] | None = None + current_value: Callable[[OasisDevice], Any] + select_fn: Callable[[OasisDevice, int], Awaitable[None]] + update_handler: Callable[[OasisDeviceSelectEntity], None] | None = None -class OasisMiniSelectEntity(OasisMiniEntity, SelectEntity): - """Oasis Mini select entity.""" +DESCRIPTORS = ( + OasisDeviceSelectEntityDescription( + key="autoplay", + translation_key="autoplay", + options=list(AUTOPLAY_MAP.values()), + current_value=lambda device: device.autoplay, + select_fn=lambda device, option: device.async_set_autoplay(option), + ), + OasisDeviceSelectEntityDescription( + key="queue", + translation_key="queue", + current_value=lambda device: (device.playlist.copy(), device.playlist_index), + select_fn=lambda device, option: device.async_change_track(option), + 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, option: device.async_set_playlist( + [pattern["id"] for pattern in device.playlists[option]["patterns"]] + ), + update_handler=playlists_update_handler, + ), +) - entity_description: OasisMiniSelectEntityDescription + +class OasisDeviceSelectEntity(OasisDeviceEntity, SelectEntity): + """Oasis device select entity.""" + + entity_description: OasisDeviceSelectEntityDescription _current_value: Any | None = None def __init__( self, - coordinator: OasisMiniCoordinator, + coordinator: OasisDeviceCoordinator, + device: OasisDevice, description: EntityDescription, ) -> None: - """Construct an Oasis Mini select entity.""" - super().__init__(coordinator, description) + """Construct an Oasis device select entity.""" + super().__init__(coordinator, device, description) self._handle_coordinator_update() async def async_select_option(self, option: str) -> None: @@ -62,94 +157,3 @@ class OasisMiniSelectEntity(OasisMiniEntity, SelectEntity): ) if self.hass: return super()._handle_coordinator_update() - - -def playlists_update_handler(entity: OasisMiniSelectEntity) -> None: - """Handle playlists updates.""" - # pylint: disable=protected-access - device = entity.device - counts = defaultdict(int) - options = [] - current_option: str | None = None - for playlist in device.playlists: - name = playlist["name"] - counts[name] += 1 - if counts[name] > 1: - name = f"{name} ({counts[name]})" - options.append(name) - if device.playlist == [pattern["id"] for pattern in playlist["patterns"]]: - current_option = name - entity._attr_options = options - entity._attr_current_option = current_option - - -def queue_update_handler(entity: OasisMiniSelectEntity) -> None: - """Handle queue updates.""" - # pylint: disable=protected-access - device = entity.device - counts = defaultdict(int) - options = [] - for track in device.playlist: - name = device._playlist.get(track, {}).get( - "name", - TRACKS.get(track, {"id": track, "name": f"Unknown Title (#{track})"}).get( - "name", - device.track["name"] - if device.track and device.track["id"] == track - else str(track), - ), - ) - counts[name] += 1 - if counts[name] > 1: - name = f"{name} ({counts[name]})" - options.append(name) - entity._attr_options = options - index = min(device.playlist_index, len(options) - 1) - entity._attr_current_option = options[index] if options else None - - -DESCRIPTORS = ( - OasisMiniSelectEntityDescription( - key="autoplay", - translation_key="autoplay", - options=list(AUTOPLAY_MAP.values()), - current_value=lambda device: device.autoplay, - select_fn=lambda device, option: device.async_set_autoplay(option), - ), - OasisMiniSelectEntityDescription( - key="queue", - translation_key="queue", - current_value=lambda device: (device.playlist.copy(), device.playlist_index), - select_fn=lambda device, option: device.async_change_track(option), - update_handler=queue_update_handler, - ), -) -CLOUD_DESCRIPTORS = ( - OasisMiniSelectEntityDescription( - key="playlists", - translation_key="playlist", - current_value=lambda device: (device.playlists, device.playlist.copy()), - select_fn=lambda device, option: device.async_set_playlist( - [pattern["id"] for pattern in device.playlists[option]["patterns"]] - ), - update_handler=playlists_update_handler, - ), -) - - -async def async_setup_entry( - hass: HomeAssistant, - entry: OasisMiniConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up Oasis Mini select using config entry.""" - coordinator: OasisMiniCoordinator = entry.runtime_data - entities = [ - OasisMiniSelectEntity(coordinator, descriptor) for descriptor in DESCRIPTORS - ] - if coordinator.device.access_token: - entities.extend( - OasisMiniSelectEntity(coordinator, descriptor) - for descriptor in CLOUD_DESCRIPTORS - ) - async_add_entities(entities) diff --git a/custom_components/oasis_mini/sensor.py b/custom_components/oasis_mini/sensor.py index e662f1f..3e03378 100644 --- a/custom_components/oasis_mini/sensor.py +++ b/custom_components/oasis_mini/sensor.py @@ -1,4 +1,4 @@ -"""Oasis Mini sensor entity.""" +"""Oasis device sensor entity.""" from __future__ import annotations @@ -11,26 +11,28 @@ from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import OasisMiniConfigEntry -from .coordinator import OasisMiniCoordinator -from .entity import OasisMiniEntity +from . import OasisDeviceConfigEntry +from .coordinator import OasisDeviceCoordinator +from .entity import OasisDeviceEntity async def async_setup_entry( hass: HomeAssistant, - entry: OasisMiniConfigEntry, + entry: OasisDeviceConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up Oasis Mini sensors using config entry.""" - coordinator: OasisMiniCoordinator = entry.runtime_data + """Set up Oasis device sensors using config entry.""" + coordinator: OasisDeviceCoordinator = entry.runtime_data entities = [ - OasisMiniSensorEntity(coordinator, descriptor) for descriptor in DESCRIPTORS + OasisDeviceSensorEntity(coordinator, device, descriptor) + for device in coordinator.data + for descriptor in DESCRIPTORS ] - if coordinator.device.access_token: - entities.extend( - OasisMiniSensorEntity(coordinator, descriptor) - for descriptor in CLOUD_DESCRIPTORS - ) + entities.extend( + OasisDeviceSensorEntity(coordinator, device, descriptor) + for device in coordinator.data + for descriptor in CLOUD_DESCRIPTORS + ) async_add_entities(entities) @@ -50,7 +52,9 @@ DESCRIPTORS = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ) - for key in ("error", "led_color_id", "status") + for key in ("error", "status") + # for key in ("error", "led_color_id", "status") + # for key in ("error_message", "led_color_id", "status") } CLOUD_DESCRIPTORS = ( @@ -65,8 +69,8 @@ CLOUD_DESCRIPTORS = ( ) -class OasisMiniSensorEntity(OasisMiniEntity, SensorEntity): - """Oasis Mini sensor entity.""" +class OasisDeviceSensorEntity(OasisDeviceEntity, SensorEntity): + """Oasis device sensor entity.""" @property def native_value(self) -> str | None: diff --git a/custom_components/oasis_mini/strings.json b/custom_components/oasis_mini/strings.json index 6a349d8..1f029c4 100755 --- a/custom_components/oasis_mini/strings.json +++ b/custom_components/oasis_mini/strings.json @@ -3,24 +3,29 @@ "step": { "user": { "data": { - "host": "[%key:common::config_flow::data::host%]" + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" } }, "reconfigure": { "data": { - "host": "[%key:common::config_flow::data::host%]" + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "invalid_host": "[%key:common::config_flow::error::invalid_host%]", "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "wrong_account": "Account used for the integration should not change" } }, "options": { @@ -141,4 +146,4 @@ "message": "Playlists are not currently supported" } } -} +} \ No newline at end of file diff --git a/custom_components/oasis_mini/translations/en.json b/custom_components/oasis_mini/translations/en.json index d9a0fd4..eef05e3 100755 --- a/custom_components/oasis_mini/translations/en.json +++ b/custom_components/oasis_mini/translations/en.json @@ -3,24 +3,29 @@ "step": { "user": { "data": { - "host": "Host" + "email": "Email", + "password": "Password" } }, "reconfigure": { "data": { - "host": "Host" + "email": "Email", + "password": "Password" } } }, "error": { "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", "invalid_host": "Invalid hostname or IP address", "timeout_connect": "Timeout establishing connection", "unknown": "Unexpected error" }, "abort": { - "already_configured": "Device is already configured", - "reconfigure_successful": "Re-configuration was successful" + "already_configured": "Account is already configured", + "reauth_successful": "Re-authentication was successful", + "reconfigure_successful": "Re-configuration was successful", + "wrong_account": "Account used for the integration should not change" } }, "options": { @@ -141,4 +146,4 @@ "message": "Playlists are not currently supported" } } -} +} \ No newline at end of file diff --git a/custom_components/oasis_mini/update.py b/custom_components/oasis_mini/update.py index 5d98585..ade7698 100644 --- a/custom_components/oasis_mini/update.py +++ b/custom_components/oasis_mini/update.py @@ -1,4 +1,4 @@ -"""Oasis Mini update entity.""" +"""Oasis device update entity.""" from __future__ import annotations @@ -15,9 +15,9 @@ from homeassistant.components.update import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import OasisMiniConfigEntry -from .coordinator import OasisMiniCoordinator -from .entity import OasisMiniEntity +from . import OasisDeviceConfigEntry +from .coordinator import OasisDeviceCoordinator +from .entity import OasisDeviceEntity _LOGGER = logging.getLogger(__name__) @@ -26,13 +26,16 @@ SCAN_INTERVAL = timedelta(hours=6) async def async_setup_entry( hass: HomeAssistant, - entry: OasisMiniConfigEntry, + entry: OasisDeviceConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up Oasis Mini updates using config entry.""" - coordinator: OasisMiniCoordinator = entry.runtime_data - if coordinator.device.access_token: - async_add_entities([OasisMiniUpdateEntity(coordinator, DESCRIPTOR)], True) + """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) DESCRIPTOR = UpdateEntityDescription( @@ -40,8 +43,8 @@ DESCRIPTOR = UpdateEntityDescription( ) -class OasisMiniUpdateEntity(OasisMiniEntity, UpdateEntity): - """Oasis Mini update entity.""" +class OasisDeviceUpdateEntity(OasisDeviceEntity, UpdateEntity): + """Oasis device update entity.""" _attr_supported_features = ( UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS @@ -68,16 +71,14 @@ class OasisMiniUpdateEntity(OasisMiniEntity, UpdateEntity): self, version: str | None, backup: bool, **kwargs: Any ) -> None: """Install an update.""" - version = await self.device.async_get_software_version() - if version == self.latest_version: + if self.latest_version == self.device.software_version: return await self.device.async_upgrade() async def async_update(self) -> None: """Update the entity.""" - await self.device.async_get_software_version() - software = await self.device.async_cloud_get_latest_software_details() - if not software: + client = self.coordinator.cloud_client + if not (software := await client.async_get_latest_software_details()): _LOGGER.warning("Unable to get latest software details") return self._attr_latest_version = software["version"] diff --git a/hacs.json b/hacs.json index ad7cf83..12c3d69 100644 --- a/hacs.json +++ b/hacs.json @@ -1,7 +1,7 @@ { - "name": "Oasis Mini", + "name": "Oasis Control", "homeassistant": "2024.5.0", "render_readme": true, "zip_release": true, "filename": "oasis_mini.zip" -} +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 3725fe7..5be7dac 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,7 @@ PyTurboJPEG # Integration aiohttp # should already be installed with Home Assistant +aiomqtt # asyncio MQTT client cryptography # should already be installed with Home Assistant # Development diff --git a/update_tracks.py b/update_tracks.py index 30a588a..abc00fb 100644 --- a/update_tracks.py +++ b/update_tracks.py @@ -7,8 +7,8 @@ import json import os from typing import Any -from custom_components.oasis_mini.pyoasismini import OasisMini -from custom_components.oasis_mini.pyoasismini.const import TRACKS +from custom_components.oasis_mini.pyoasiscontrol import OasisCloudClient +from custom_components.oasis_mini.pyoasiscontrol.const import TRACKS ACCESS_TOKEN = os.getenv("GROUNDED_TOKEN") @@ -16,15 +16,15 @@ ACCESS_TOKEN = os.getenv("GROUNDED_TOKEN") def get_author_name(data: dict) -> str: """Get author name from a dict.""" author = (data.get("author") or {}).get("user") or {} - return author.get("name") or author.get("nickname") or "Oasis Mini" + return author.get("name") or author.get("nickname") or "Kinetic Oasis" async def update_tracks() -> None: """Update tracks.""" - client = OasisMini("", ACCESS_TOKEN) + client = OasisCloudClient(access_token=ACCESS_TOKEN) try: - data = await client.async_cloud_get_tracks() + data = await client.async_get_tracks() except Exception as ex: print(type(ex).__name__, ex) await client.session.close() @@ -45,7 +45,7 @@ async def update_tracks() -> None: or TRACKS[track_id].get("author") != get_author_name(result) ): print(f"Updating track {track_id}: {result['name']}") - track_info = await client.async_cloud_get_track_info(int(track_id)) + track_info = await client.async_get_track_info(int(track_id)) if not track_info: print("No track info") break @@ -65,7 +65,7 @@ async def update_tracks() -> None: tracks = dict(sorted(tracks.items(), key=lambda t: t[1]["name"].lower())) with open( - "custom_components/oasis_mini/pyoasismini/tracks.json", "w", encoding="utf8" + "custom_components/oasis_mini/pyoasiscontrol/tracks.json", "w", encoding="utf8" ) as file: json.dump(tracks, file, indent=2, ensure_ascii=False)