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

25 Commits

Author SHA1 Message Date
Nathan Spencer
cf21a5d995 Dynamically handle devices and other enhancements 2025-11-23 22:49:26 +00:00
Nathan Spencer
83de1d5606 Add additional helpers 2025-11-23 06:45:01 +00:00
Nathan Spencer
2a92212aad Get track info from the cloud when playlist or index changes 2025-11-23 00:13:45 +00:00
Nathan Spencer
ecad472bbd Better mqtt handling when connection is interrupted 2025-11-22 20:51:17 +00:00
Nathan Spencer
886d7598f3 Switch to using mqtt 2025-11-22 04:40:58 +00:00
Nathan Spencer
171a608314 Merge pull request #97 from natekspencer/update-tracks
Update tracks
2025-11-19 16:18:00 -07:00
natekspencer
5f01397b56 Update tracks 2025-11-19 23:16:54 +00:00
Nathan Spencer
b56d7fe805 Merge pull request #96 from natekspencer/update-tracks
Update tracks
2025-11-19 16:13:09 -07:00
natekspencer
1eecef9299 Update tracks 2025-11-19 23:12:08 +00:00
Nathan Spencer
bd7e3831a7 Merge pull request #95 from natekspencer/update-tracks
Update tracks
2025-11-19 16:10:12 -07:00
natekspencer
11f7a38b04 Update tracks 2025-11-19 23:09:30 +00:00
Nathan Spencer
152879f8e0 Merge pull request #94 from natekspencer/update-tracks
Update tracks
2025-11-19 16:06:46 -07:00
natekspencer
4a07fa3ebb Update tracks 2025-11-19 23:04:07 +00:00
Nathan Spencer
2687f1e597 Merge pull request #93 from natekspencer/update-tracks
Add manual trigger for update tracks Github action
2025-11-19 16:01:58 -07:00
Nathan Spencer
a4c6fd57dd Add manual trigger for update tracks Github action 2025-11-19 22:51:38 +00:00
Nathan Spencer
0cab687cef Merge pull request #87 from natekspencer/error-translations
Add error translations
2025-08-02 08:23:18 -06:00
Nathan Spencer
581f41c517 Add error translations 2025-08-02 14:21:34 +00:00
Nathan Spencer
7705d61a4f Merge pull request #86 from natekspencer/status-icons
Update status icons for busy and sleeping
2025-08-02 07:55:38 -06:00
Nathan Spencer
3a8e274d26 Update status icons for busy and sleeping 2025-08-02 13:54:35 +00:00
Nathan Spencer
6c6ce70932 Merge pull request #85 from natekspencer/cloud-playlists
Add cloud playlists
2025-08-02 07:52:24 -06:00
Nathan Spencer
8a72aba294 Add cloud playlists 2025-08-02 13:48:58 +00:00
Nathan Spencer
9949241c84 Merge pull request #83 from natekspencer/natekspencer-patch-1
Change schedule for update-tracks workflow
2025-07-24 13:38:59 -06:00
Nathan Spencer
b07fc68b21 Change schedule for update-tracks workflow 2025-07-24 13:37:49 -06:00
Nathan Spencer
91d03f11a8 Merge pull request #82 from natekspencer/update-tracks
Update tracks
2025-07-24 13:35:53 -06:00
natekspencer
4d2c7a0199 Update tracks 2025-07-24 19:20:41 +00:00
38 changed files with 4178 additions and 1334 deletions

View File

@@ -1,10 +1,14 @@
name: Update tracks name: Update tracks
on: on:
schedule: schedule:
- cron: "0 19 * * *" - cron: "0 19 * * 1"
workflow_dispatch: {}
permissions: permissions:
contents: write contents: write
pull-requests: write pull-requests: write
jobs: jobs:
tracks: tracks:
name: Search and update new tracks name: Search and update new tracks
@@ -12,16 +16,20 @@ jobs:
steps: steps:
- name: Checkout the repo - name: Checkout the repo
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Python 3.13 - name: Set up Python 3.13
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: "3.13" python-version: "3.13"
- name: Install dependencies - name: Install dependencies
run: pip install homeassistant run: pip install homeassistant
- name: Update tracks - name: Update tracks
env: env:
GROUNDED_TOKEN: ${{ secrets.GROUNDED_TOKEN }} GROUNDED_TOKEN: ${{ secrets.GROUNDED_TOKEN }}
run: python update_tracks.py run: python update_tracks.py
- name: Create Pull Request - name: Create Pull Request
uses: peter-evans/create-pull-request@v6 uses: peter-evans/create-pull-request@v6
with: with:

View File

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

View File

@@ -1,20 +1,26 @@
"""Support for Oasis Mini.""" """Support for Oasis devices."""
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Any, Callable, Iterable
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import CONF_EMAIL, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed
import homeassistant.helpers.device_registry as dr from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.entity_platform import AddEntitiesCallback
import homeassistant.helpers.entity_registry as er
import homeassistant.util.dt as dt_util
from .const import DOMAIN from .const import DOMAIN
from .coordinator import OasisMiniCoordinator from .coordinator import OasisDeviceCoordinator
from .entity import OasisDeviceEntity
from .helpers import create_client from .helpers import create_client
from .pyoasiscontrol import OasisDevice, OasisMqttClient, UnauthenticatedError
type OasisMiniConfigEntry = ConfigEntry[OasisMiniCoordinator] type OasisDeviceConfigEntry = ConfigEntry[OasisDeviceCoordinator]
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -27,65 +33,171 @@ PLATFORMS = [
Platform.NUMBER, Platform.NUMBER,
Platform.SELECT, Platform.SELECT,
Platform.SENSOR, Platform.SENSOR,
# Platform.SWITCH, Platform.SWITCH,
Platform.UPDATE, Platform.UPDATE,
] ]
async def async_setup_entry(hass: HomeAssistant, entry: OasisMiniConfigEntry) -> bool: def setup_platform_from_coordinator(
"""Set up Oasis Mini from a config entry.""" entry: OasisDeviceConfigEntry,
client = create_client(entry.data | entry.options) async_add_entities: AddEntitiesCallback,
coordinator = OasisMiniCoordinator(hass, client) make_entities: Callable[[OasisDevice], Iterable[OasisDeviceEntity]],
update_before_add: bool = False,
) -> None:
"""Generic pattern: add entities per device, including newly discovered ones."""
coordinator = entry.runtime_data
known_serials: set[str] = set()
@callback
def _check_devices() -> None:
devices = coordinator.data or []
new_devices: list[OasisDevice] = []
for device in devices:
serial = device.serial_number
if not serial or serial in known_serials:
continue
known_serials.add(serial)
new_devices.append(device)
if not new_devices:
return
if entities := make_entities(new_devices):
async_add_entities(entities, update_before_add)
# Initial population
_check_devices()
# Future updates (new devices discovered)
entry.async_on_unload(coordinator.async_add_listener(_check_devices))
async def async_setup_entry(hass: HomeAssistant, entry: OasisDeviceConfigEntry) -> bool:
"""Set up Oasis devices from a config entry."""
cloud_client = create_client(hass, entry.data)
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: try:
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()
except Exception as ex: except Exception as ex:
_LOGGER.exception(ex) _LOGGER.exception(ex)
if not entry.unique_id: if entry.unique_id != (user_id := str(user["id"])):
if not (serial_number := coordinator.device.serial_number): hass.config_entries.async_update_entry(entry, unique_id=user_id)
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 not coordinator.data: if not coordinator.data:
await client.session.close() _LOGGER.warning("No devices associated with account")
raise ConfigEntryNotReady
if entry.unique_id != coordinator.device.serial_number:
await client.session.close()
raise ConfigEntryError("Serial number mismatch")
entry.runtime_data = coordinator entry.runtime_data = coordinator
def _on_oasis_update() -> None:
coordinator.last_updated = dt_util.now()
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) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(update_listener))
return True 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.""" """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) 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.""" """Handle removal of an entry."""
if entry.options: cloud_client = create_client(hass, entry.data)
client = create_client(entry.data | entry.options) try:
await client.async_cloud_logout() await cloud_client.async_logout()
await client.session.close() except Exception as ex:
_LOGGER.exception(ex)
await cloud_client.async_close()
async def update_listener(hass: HomeAssistant, entry: OasisMiniConfigEntry) -> None: async def async_migrate_entry(hass: HomeAssistant, entry: OasisDeviceConfigEntry):
"""Handle options update.""" """Migrate old entry."""
await hass.config_entries.async_reload(entry.entry_id) _LOGGER.debug(
"Migrating configuration from version %s.%s", entry.version, entry.minor_version
)
if entry.version > 1:
# This means the user has downgraded from a future version
return False
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",
entry.version,
entry.minor_version,
)
return True
async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: OasisDeviceConfigEntry, device_entry: DeviceEntry
) -> bool:
"""Remove a config entry from a device."""
current_serials = {d.serial_number for d in (config_entry.runtime_data.data or [])}
return not any(
identifier
for identifier in device_entry.identifiers
if identifier[0] == DOMAIN and identifier[1] in current_serials
)

View File

@@ -1,4 +1,4 @@
"""Oasis Mini binary sensor entity.""" """Oasis device binary sensor entity."""
from __future__ import annotations from __future__ import annotations
@@ -11,22 +11,26 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import OasisMiniConfigEntry from . import OasisDeviceConfigEntry, setup_platform_from_coordinator
from .coordinator import OasisMiniCoordinator from .entity import OasisDeviceEntity
from .entity import OasisMiniEntity from .pyoasiscontrol import OasisDevice
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
entry: OasisMiniConfigEntry, entry: OasisDeviceConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Oasis Mini sensors using config entry.""" """Set up Oasis device sensors using config entry."""
coordinator: OasisMiniCoordinator = entry.runtime_data
async_add_entities( def make_entities(new_devices: list[OasisDevice]):
OasisMiniBinarySensorEntity(coordinator, descriptor) return [
OasisDeviceBinarySensorEntity(entry.runtime_data, device, descriptor)
for device in new_devices
for descriptor in DESCRIPTORS for descriptor in DESCRIPTORS
) ]
setup_platform_from_coordinator(entry, async_add_entities, make_entities)
DESCRIPTORS = { DESCRIPTORS = {
@@ -46,8 +50,8 @@ DESCRIPTORS = {
} }
class OasisMiniBinarySensorEntity(OasisMiniEntity, BinarySensorEntity): class OasisDeviceBinarySensorEntity(OasisDeviceEntity, BinarySensorEntity):
"""Oasis Mini binary sensor entity.""" """Oasis device binary sensor entity."""
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:

View File

@@ -1,4 +1,4 @@
"""Oasis Mini button entity.""" """Oasis device button entity."""
from __future__ import annotations from __future__ import annotations
@@ -13,55 +13,62 @@ from homeassistant.components.button import (
) )
from homeassistant.const import EntityCategory from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import OasisMiniConfigEntry from . import OasisDeviceConfigEntry, setup_platform_from_coordinator
from .entity import OasisMiniEntity from .entity import OasisDeviceEntity
from .helpers import add_and_play_track from .helpers import add_and_play_track
from .pyoasismini import OasisMini from .pyoasiscontrol import OasisDevice
from .pyoasismini.const import TRACKS from .pyoasiscontrol.const import TRACKS
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
entry: OasisMiniConfigEntry, entry: OasisDeviceConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Oasis Mini button using config entry.""" """Set up Oasis device button using config entry."""
async_add_entities(
[ def make_entities(new_devices: list[OasisDevice]):
OasisMiniButtonEntity(entry.runtime_data, descriptor) return [
OasisDeviceButtonEntity(entry.runtime_data, device, descriptor)
for device in new_devices
for descriptor in DESCRIPTORS for descriptor in DESCRIPTORS
] ]
)
setup_platform_from_coordinator(entry, async_add_entities, make_entities)
async def play_random_track(device: OasisMini) -> None: async def play_random_track(device: OasisDevice) -> None:
"""Play random track.""" """Play random track."""
track = random.choice(list(TRACKS)) track = random.choice(list(TRACKS))
try:
await add_and_play_track(device, track) await add_and_play_track(device, track)
except TimeoutError as err:
raise HomeAssistantError("Timeout adding track to queue") from err
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class OasisMiniButtonEntityDescription(ButtonEntityDescription): class OasisDeviceButtonEntityDescription(ButtonEntityDescription):
"""Oasis Mini button entity description.""" """Oasis device button entity description."""
press_fn: Callable[[OasisMini], Awaitable[None]] press_fn: Callable[[OasisDevice], Awaitable[None]]
DESCRIPTORS = ( DESCRIPTORS = (
OasisMiniButtonEntityDescription( OasisDeviceButtonEntityDescription(
key="reboot", key="reboot",
device_class=ButtonDeviceClass.RESTART, device_class=ButtonDeviceClass.RESTART,
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
press_fn=lambda device: device.async_reboot(), press_fn=lambda device: device.async_reboot(),
), ),
OasisMiniButtonEntityDescription( OasisDeviceButtonEntityDescription(
key="random_track", key="random_track",
translation_key="random_track", translation_key="random_track",
press_fn=play_random_track, press_fn=play_random_track,
), ),
OasisMiniButtonEntityDescription( OasisDeviceButtonEntityDescription(
key="sleep", key="sleep",
translation_key="sleep", translation_key="sleep",
press_fn=lambda device: device.async_sleep(), press_fn=lambda device: device.async_sleep(),
@@ -69,12 +76,11 @@ DESCRIPTORS = (
) )
class OasisMiniButtonEntity(OasisMiniEntity, ButtonEntity): class OasisDeviceButtonEntity(OasisDeviceEntity, ButtonEntity):
"""Oasis Mini button entity.""" """Oasis device button entity."""
entity_description: OasisMiniButtonEntityDescription entity_description: OasisDeviceButtonEntityDescription
async def async_press(self) -> None: async def async_press(self) -> None:
"""Press the button.""" """Press the button."""
await self.entity_description.press_fn(self.device) await self.entity_description.press_fn(self.device)
await self.coordinator.async_request_refresh()

View File

@@ -1,86 +1,52 @@
"""Config flow for Oasis Mini integration.""" """Config flow for Oasis device integration."""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging import logging
from typing import Any from typing import Any, Mapping
from aiohttp import ClientConnectorError from aiohttp import ClientConnectorError
from httpx import ConnectError, HTTPStatusError from httpx import ConnectError, HTTPStatusError
import voluptuous as vol import voluptuous as vol
from homeassistant.components import dhcp
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_HOST, CONF_PASSWORD from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import callback
from homeassistant.helpers.schema_config_entry_flow import (
SchemaCommonFlowHandler,
SchemaFlowError,
SchemaFlowFormStep,
SchemaOptionsFlowHandler,
)
from . import OasisMiniConfigEntry
from .const import DOMAIN from .const import DOMAIN
from .coordinator import OasisMiniCoordinator
from .helpers import create_client from .helpers import create_client
from .pyoasiscontrol import UnauthenticatedError
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
OPTIONS_SCHEMA = vol.Schema(
{vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str} {vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str}
) )
async def cloud_login( class OasisDeviceConfigFlow(ConfigFlow, domain=DOMAIN):
handler: SchemaCommonFlowHandler, user_input: dict[str, Any] """Handle a config flow for Oasis devices."""
) -> 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."""
VERSION = 1 VERSION = 1
MINOR_VERSION = 3
@staticmethod async def async_step_reauth(
@callback self, entry_data: Mapping[str, Any]
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
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle DHCP discovery.""" """Perform reauth upon an API authentication error."""
host = {CONF_HOST: discovery_info.ip} return await self.async_step_reauth_confirm()
await self.validate_client(host)
self._abort_if_unique_id_configured(updates=host) async def async_step_reauth_confirm(
# This should never happen since we only listen to DHCP requests self, user_input: dict[str, Any] | None = None
# for configured devices. ) -> ConfigFlowResult:
return self.async_abort(reason="already_configured") """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( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
@@ -114,20 +80,22 @@ class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None: if user_input is not None:
if not (errors := await self.validate_client(user_input)): if not (errors := await self.validate_client(user_input)):
if step_id != "reconfigure": entry_id = self.context.get("entry_id")
self._abort_if_unique_id_configured(updates=user_input) existing_entry = self.hass.config_entries.async_get_entry(entry_id)
if existing_entry := self.hass.config_entries.async_get_entry( if existing_entry and existing_entry.unique_id:
self.context.get("entry_id") self._abort_if_unique_id_mismatch(reason="wrong_account")
): if existing_entry:
self.hass.config_entries.async_update_entry( return self.async_update_reload_and_abort(
existing_entry, data=user_input existing_entry,
) unique_id=self.unique_id,
await self.hass.config_entries.async_reload(existing_entry.entry_id) title=user_input[CONF_EMAIL],
return self.async_abort(reason="reconfigure_successful")
return self.async_create_entry(
title=f"Oasis Mini {self.unique_id}",
data=user_input, data=user_input,
reload_even_if_entry_is_unchanged=False,
)
self._abort_if_unique_id_configured(updates=user_input)
return self.async_create_entry(
title=user_input[CONF_EMAIL], data=user_input
) )
return self.async_show_form( return self.async_show_form(
@@ -141,21 +109,29 @@ class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN):
errors = {} errors = {}
try: try:
async with asyncio.timeout(10): async with asyncio.timeout(10):
client = create_client(user_input) client = create_client(self.hass, user_input)
await self.async_set_unique_id(await client.async_get_serial_number()) 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: if not self.unique_id:
errors["base"] = "invalid_host" errors["base"] = "invalid_auth"
except UnauthenticatedError:
errors["base"] = "invalid_auth"
except asyncio.TimeoutError: except asyncio.TimeoutError:
errors["base"] = "timeout_connect" errors["base"] = "timeout_connect"
except ConnectError: except ConnectError:
errors["base"] = "invalid_host" errors["base"] = "invalid_auth"
except ClientConnectorError: except ClientConnectorError:
errors["base"] = "invalid_host" errors["base"] = "invalid_auth"
except HTTPStatusError as err: except HTTPStatusError as err:
errors["base"] = str(err) errors["base"] = str(err)
except Exception as ex: # pylint: disable=broad-except except Exception as ex: # pylint: disable=broad-except
_LOGGER.error(ex) _LOGGER.error(ex)
errors["base"] = "unknown" errors["base"] = "unknown"
finally: finally:
await client.session.close() await client.async_close()
return errors return errors

View File

@@ -1,4 +1,4 @@
"""Constants for the Oasis Mini integration.""" """Constants for the Oasis devices integration."""
from typing import Final from typing import Final

View File

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

View File

@@ -1,4 +1,4 @@
"""Oasis Mini entity.""" """Oasis device entity."""
from __future__ import annotations from __future__ import annotations
@@ -7,36 +7,39 @@ from homeassistant.helpers.entity import DeviceInfo, EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN from .const import DOMAIN
from .coordinator import OasisMiniCoordinator from .coordinator import OasisDeviceCoordinator
from .pyoasismini import OasisMini from .pyoasiscontrol import OasisDevice
class OasisMiniEntity(CoordinatorEntity[OasisMiniCoordinator]): class OasisDeviceEntity(CoordinatorEntity[OasisDeviceCoordinator]):
"""Base class for Oasis Mini entities.""" """Base class for Oasis device entities."""
_attr_has_entity_name = True _attr_has_entity_name = True
def __init__( def __init__(
self, coordinator: OasisMiniCoordinator, description: EntityDescription self,
coordinator: OasisDeviceCoordinator,
device: OasisDevice,
description: EntityDescription,
) -> None: ) -> None:
"""Construct an Oasis Mini entity.""" """Construct an Oasis device entity."""
super().__init__(coordinator) super().__init__(coordinator)
self.device = device
self.entity_description = description self.entity_description = description
device = coordinator.device
serial_number = device.serial_number serial_number = device.serial_number
self._attr_unique_id = f"{serial_number}-{description.key}" self._attr_unique_id = f"{serial_number}-{description.key}"
connections = set()
if mac_address := device.mac_address:
connections.add((CONNECTION_NETWORK_MAC, format_mac(mac_address)))
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, format_mac(device.mac_address))}, connections=connections,
identifiers={(DOMAIN, serial_number)}, identifiers={(DOMAIN, serial_number)},
name=f"Oasis Mini {serial_number}", name=device.name,
manufacturer="Kinetic Oasis", manufacturer=device.manufacturer,
model="Oasis Mini", model=device.model,
serial_number=serial_number, serial_number=serial_number,
sw_version=device.software_version, sw_version=device.software_version,
) )
@property
def device(self) -> OasisMini:
"""Return the device."""
return self.coordinator.device

View File

@@ -1,30 +1,43 @@
"""Helpers for the Oasis Mini integration.""" """Helpers for the Oasis devices integration."""
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
from typing import Any from typing import Any
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST import async_timeout
from .pyoasismini import TRACKS, OasisMini from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .pyoasiscontrol import OasisCloudClient, OasisDevice
from .pyoasiscontrol.const import TRACKS
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
def create_client(data: dict[str, Any]) -> OasisMini: def create_client(hass: HomeAssistant, data: dict[str, Any]) -> OasisCloudClient:
"""Create a Oasis Mini local client.""" """Create a Oasis cloud client."""
return OasisMini(data[CONF_HOST], data.get(CONF_ACCESS_TOKEN)) 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.""" """Add and play a track."""
async with async_timeout.timeout(10):
if track not in device.playlist: if track not in device.playlist:
await device.async_add_track_to_playlist(track) await device.async_add_track_to_playlist(track)
while track not in device.playlist:
await asyncio.sleep(0.1)
# Move track to next item in the playlist and then select it # Move track to next item in the playlist and then select it
if (index := device.playlist.index(track)) != device.playlist_index: if (index := device.playlist.index(track)) != device.playlist_index:
if index != (_next := min(device.playlist_index + 1, len(device.playlist) - 1)): if index != (
_next := min(device.playlist_index + 1, len(device.playlist) - 1)
):
await device.async_move_track(index, _next) await device.async_move_track(index, _next)
await device.async_change_track(_next) await device.async_change_track(_next)

View File

@@ -24,12 +24,14 @@
"status": { "status": {
"state": { "state": {
"booting": "mdi:loading", "booting": "mdi:loading",
"busy": "mdi:progress-clock",
"centering": "mdi:record-circle-outline", "centering": "mdi:record-circle-outline",
"downloading": "mdi:progress-download", "downloading": "mdi:progress-download",
"error": "mdi:alert-circle-outline", "error": "mdi:alert-circle-outline",
"live": "mdi:pencil-circle-outline", "live": "mdi:pencil-circle-outline",
"paused": "mdi:motion-pause-outline", "paused": "mdi:motion-pause-outline",
"playing": "mdi:motion-play-outline", "playing": "mdi:motion-play-outline",
"sleeping": "mdi:power-sleep",
"stopped": "mdi:stop-circle-outline", "stopped": "mdi:stop-circle-outline",
"updating": "mdi:update" "updating": "mdi:update"
} }

View File

@@ -1,4 +1,4 @@
"""Oasis Mini image entity.""" """Oasis device image entity."""
from __future__ import annotations from __future__ import annotations
@@ -6,70 +6,80 @@ from homeassistant.components.image import Image, ImageEntity, ImageEntityDescri
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import UNDEFINED from homeassistant.helpers.typing import UNDEFINED
from homeassistant.util import dt as dt_util
from . import OasisDeviceConfigEntry, setup_platform_from_coordinator
from .coordinator import OasisDeviceCoordinator
from .entity import OasisDeviceEntity
from .pyoasiscontrol import OasisDevice
async def async_setup_entry(
hass: HomeAssistant,
entry: OasisDeviceConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Oasis device image using config entry."""
def make_entities(new_devices: list[OasisDevice]):
return [
OasisDeviceImageEntity(entry.runtime_data, device, IMAGE)
for device in new_devices
]
setup_platform_from_coordinator(entry, async_add_entities, make_entities)
from . import OasisMiniConfigEntry
from .coordinator import OasisMiniCoordinator
from .entity import OasisMiniEntity
from .pyoasismini.const import TRACKS
from .pyoasismini.utils import draw_svg
IMAGE = ImageEntityDescription(key="image", name=None) IMAGE = ImageEntityDescription(key="image", name=None)
class OasisMiniImageEntity(OasisMiniEntity, ImageEntity): class OasisDeviceImageEntity(OasisDeviceEntity, ImageEntity):
"""Oasis Mini image entity.""" """Oasis device image entity."""
_attr_content_type = "image/svg+xml"
_track_id: int | None = None _track_id: int | None = None
_progress: int = 0 _progress: int = 0
def __init__( def __init__(
self, coordinator: OasisMiniCoordinator, description: ImageEntityDescription self,
coordinator: OasisDeviceCoordinator,
device: OasisDevice,
description: ImageEntityDescription,
) -> None: ) -> None:
"""Initialize the entity.""" """Initialize the entity."""
super().__init__(coordinator, description) super().__init__(coordinator, device, description)
ImageEntity.__init__(self, coordinator.hass) ImageEntity.__init__(self, coordinator.hass)
self._handle_coordinator_update() self._handle_coordinator_update()
def image(self) -> bytes | None: def image(self) -> bytes | None:
"""Return bytes of image.""" """Return bytes of image."""
if not self._cached_image: if not self._cached_image:
self._cached_image = Image( if (svg := self.device.create_svg()) is None:
self.content_type, draw_svg(self.device.track, self._progress, "1") self._attr_image_url = self.device.track_image_url
) self._attr_image_last_updated = dt_util.now()
return None
self._attr_content_type = "image/svg+xml"
self._cached_image = Image(self.content_type, svg)
return self._cached_image.content return self._cached_image.content
@callback @callback
def _handle_coordinator_update(self) -> None: def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator.""" """Handle updated data from the coordinator."""
if ( device = self.device
self._track_id != self.device.track_id
or (self._progress != self.device.progress and self.device.access_token) track_changed = self._track_id != device.track_id
) and (self.device.status == "playing" or self._cached_image is None): progress_changed = self._progress != device.progress
allow_update = device.status == "playing" or self._cached_image is None
if (track_changed or progress_changed) and allow_update:
self._attr_image_last_updated = self.coordinator.last_updated self._attr_image_last_updated = self.coordinator.last_updated
self._track_id = self.device.track_id self._track_id = device.track_id
self._progress = self.device.progress self._progress = device.progress
self._cached_image = None self._cached_image = None
if self.device.track and self.device.track.get("svg_content"):
if device.track and device.track.get("svg_content"):
self._attr_image_url = UNDEFINED self._attr_image_url = UNDEFINED
else: else:
self._attr_image_url = ( self._attr_image_url = device.track_image_url
f"https://app.grounded.so/uploads/{track['image']}"
if (
track := (self.device.track or TRACKS.get(self.device.track_id))
)
and "image" in track
else None
)
if self.hass: if self.hass:
super()._handle_coordinator_update() 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)])

View File

@@ -1,4 +1,4 @@
"""Oasis Mini light entity.""" """Oasis device light entity."""
from __future__ import annotations from __future__ import annotations
@@ -23,20 +23,40 @@ from homeassistant.util.color import (
value_to_brightness, value_to_brightness,
) )
from . import OasisMiniConfigEntry from . import OasisDeviceConfigEntry, setup_platform_from_coordinator
from .entity import OasisMiniEntity from .entity import OasisDeviceEntity
from .pyoasismini import LED_EFFECTS from .pyoasiscontrol import OasisDevice
from .pyoasiscontrol.const import LED_EFFECTS
class OasisMiniLightEntity(OasisMiniEntity, LightEntity): async def async_setup_entry(
"""Oasis Mini light entity.""" hass: HomeAssistant,
entry: OasisDeviceConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Oasis device lights using config entry."""
def make_entities(new_devices: list[OasisDevice]):
return [
OasisDeviceLightEntity(entry.runtime_data, device, DESCRIPTOR)
for device in new_devices
]
setup_platform_from_coordinator(entry, async_add_entities, make_entities)
DESCRIPTOR = LightEntityDescription(key="led", translation_key="led")
class OasisDeviceLightEntity(OasisDeviceEntity, LightEntity):
"""Oasis device light entity."""
_attr_supported_features = LightEntityFeature.EFFECT _attr_supported_features = LightEntityFeature.EFFECT
@property @property
def brightness(self) -> int: def brightness(self) -> int:
"""Return the brightness of this light between 0..255.""" """Return the brightness of this light between 0..255."""
scale = (1, self.device.max_brightness) scale = (1, self.device.brightness_max)
return value_to_brightness(scale, self.device.brightness) return value_to_brightness(scale, self.device.brightness)
@property @property
@@ -82,15 +102,14 @@ class OasisMiniLightEntity(OasisMiniEntity, LightEntity):
async def async_turn_off(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off.""" """Turn the entity off."""
await self.device.async_set_led(brightness=0) await self.device.async_set_led(brightness=0)
await self.coordinator.async_request_refresh()
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on.""" """Turn the entity on."""
if brightness := kwargs.get(ATTR_BRIGHTNESS): if brightness := kwargs.get(ATTR_BRIGHTNESS):
scale = (1, self.device.max_brightness) scale = (1, self.device.brightness_max)
brightness = math.ceil(brightness_to_value(scale, brightness)) brightness = math.ceil(brightness_to_value(scale, brightness))
else: else:
brightness = self.device.brightness or 100 brightness = self.device.brightness or self.device.brightness_on
if color := kwargs.get(ATTR_RGB_COLOR): if color := kwargs.get(ATTR_RGB_COLOR):
color = f"#{color_rgb_to_hex(*color)}" color = f"#{color_rgb_to_hex(*color)}"
@@ -103,16 +122,3 @@ class OasisMiniLightEntity(OasisMiniEntity, LightEntity):
await self.device.async_set_led( await self.device.async_set_led(
brightness=brightness, color=color, led_effect=led_effect 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)])

View File

@@ -1,13 +1,14 @@
{ {
"domain": "oasis_mini", "domain": "oasis_mini",
"name": "Oasis Mini", "name": "Oasis Control",
"codeowners": ["@natekspencer"], "codeowners": ["@natekspencer"],
"config_flow": true, "config_flow": true,
"dhcp": [{ "registered_devices": true }], "dhcp": [{ "registered_devices": true }],
"documentation": "https://github.com/natekspencer/hacs-oasis_mini", "documentation": "https://github.com/natekspencer/hacs-oasis_mini",
"integration_type": "device", "integration_type": "hub",
"iot_class": "local_polling", "iot_class": "local_polling",
"issue_tracker": "https://github.com/natekspencer/hacs-oasis_mini/issues", "issue_tracker": "https://github.com/natekspencer/hacs-oasis_mini/issues",
"loggers": ["custom_components.oasis_mini"], "loggers": ["custom_components.oasis_mini"],
"requirements": ["aiomqtt"],
"version": "0.0.0" "version": "0.0.0"
} }

View File

@@ -1,4 +1,4 @@
"""Oasis Mini media player entity.""" """Oasis device media player entity."""
from __future__ import annotations from __future__ import annotations
@@ -18,15 +18,34 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import OasisMiniConfigEntry from . import OasisDeviceConfigEntry, setup_platform_from_coordinator
from .const import DOMAIN from .const import DOMAIN
from .entity import OasisMiniEntity from .entity import OasisDeviceEntity
from .helpers import get_track_id from .helpers import get_track_id
from .pyoasismini.const import TRACKS from .pyoasiscontrol import OasisDevice
class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity): async def async_setup_entry(
"""Oasis Mini media player entity.""" hass: HomeAssistant,
entry: OasisDeviceConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Oasis device media_players using config entry."""
def make_entities(new_devices: list[OasisDevice]):
return [
OasisDeviceMediaPlayerEntity(entry.runtime_data, device, DESCRIPTOR)
for device in new_devices
]
setup_platform_from_coordinator(entry, async_add_entities, make_entities)
DESCRIPTOR = MediaPlayerEntityDescription(key="oasis_mini", name=None)
class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity):
"""Oasis device media player entity."""
_attr_media_image_remotely_accessible = True _attr_media_image_remotely_accessible = True
_attr_supported_features = ( _attr_supported_features = (
@@ -56,11 +75,7 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
@property @property
def media_image_url(self) -> str | None: def media_image_url(self) -> str | None:
"""Image url of current playing media.""" """Image url of current playing media."""
if not (track := self.device.track): return self.device.track_image_url
track = TRACKS.get(self.device.track_id)
if track and "image" in track:
return f"https://app.grounded.so/uploads/{track['image']}"
return None
@property @property
def media_position(self) -> int: def media_position(self) -> int:
@@ -75,11 +90,7 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
@property @property
def media_title(self) -> str | None: def media_title(self) -> str | None:
"""Title of current playing media.""" """Title of current playing media."""
if not self.device.track_id: return self.device.track_name
return None
if not (track := self.device.track):
track = TRACKS.get(self.device.track_id, {})
return track.get("name", f"Unknown Title (#{self.device.track_id})")
@property @property
def repeat(self) -> RepeatMode: def repeat(self) -> RepeatMode:
@@ -117,19 +128,16 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
"""Send pause command.""" """Send pause command."""
self.abort_if_busy() self.abort_if_busy()
await self.device.async_pause() await self.device.async_pause()
await self.coordinator.async_request_refresh()
async def async_media_play(self) -> None: async def async_media_play(self) -> None:
"""Send play command.""" """Send play command."""
self.abort_if_busy() self.abort_if_busy()
await self.device.async_play() await self.device.async_play()
await self.coordinator.async_request_refresh()
async def async_media_stop(self) -> None: async def async_media_stop(self) -> None:
"""Send stop command.""" """Send stop command."""
self.abort_if_busy() self.abort_if_busy()
await self.device.async_stop() await self.device.async_stop()
await self.coordinator.async_request_refresh()
async def async_set_repeat(self, repeat: RepeatMode) -> None: async def async_set_repeat(self, repeat: RepeatMode) -> None:
"""Set repeat mode.""" """Set repeat mode."""
@@ -137,7 +145,6 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
repeat != RepeatMode.OFF repeat != RepeatMode.OFF
and not (repeat == RepeatMode.ONE and self.repeat == RepeatMode.ALL) and not (repeat == RepeatMode.ONE and self.repeat == RepeatMode.ALL)
) )
await self.coordinator.async_request_refresh()
async def async_media_previous_track(self) -> None: async def async_media_previous_track(self) -> None:
"""Send previous track command.""" """Send previous track command."""
@@ -145,7 +152,6 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
if (index := self.device.playlist_index - 1) < 0: if (index := self.device.playlist_index - 1) < 0:
index = len(self.device.playlist) - 1 index = len(self.device.playlist) - 1
await self.device.async_change_track(index) await self.device.async_change_track(index)
await self.coordinator.async_request_refresh()
async def async_media_next_track(self) -> None: async def async_media_next_track(self) -> None:
"""Send next track command.""" """Send next track command."""
@@ -153,7 +159,6 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
if (index := self.device.playlist_index + 1) >= len(self.device.playlist): if (index := self.device.playlist_index + 1) >= len(self.device.playlist):
index = 0 index = 0
await self.device.async_change_track(index) await self.device.async_change_track(index)
await self.coordinator.async_request_refresh()
async def async_play_media( async def async_play_media(
self, self,
@@ -203,22 +208,7 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
): ):
await device.async_play() await device.async_play()
await self.coordinator.async_request_refresh()
async def async_clear_playlist(self) -> None: async def async_clear_playlist(self) -> None:
"""Clear players playlist.""" """Clear players playlist."""
self.abort_if_busy() self.abort_if_busy()
await self.device.async_clear_playlist() 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)])

View File

@@ -1,4 +1,4 @@
"""Oasis Mini number entity.""" """Oasis device number entity."""
from __future__ import annotations from __future__ import annotations
@@ -7,16 +7,60 @@ from homeassistant.components.number import (
NumberEntityDescription, NumberEntityDescription,
NumberMode, NumberMode,
) )
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import OasisMiniConfigEntry from . import OasisDeviceConfigEntry, setup_platform_from_coordinator
from .entity import OasisMiniEntity from .entity import OasisDeviceEntity
from .pyoasismini import BALL_SPEED_MAX, BALL_SPEED_MIN, LED_SPEED_MAX, LED_SPEED_MIN from .pyoasiscontrol import OasisDevice
from .pyoasiscontrol.device import (
BALL_SPEED_MAX,
BALL_SPEED_MIN,
LED_SPEED_MAX,
LED_SPEED_MIN,
)
class OasisMiniNumberEntity(OasisMiniEntity, NumberEntity): async def async_setup_entry(
"""Oasis Mini number entity.""" hass: HomeAssistant,
entry: OasisDeviceConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Oasis device numbers using config entry."""
def make_entities(new_devices: list[OasisDevice]):
return [
OasisDeviceNumberEntity(entry.runtime_data, device, descriptor)
for device in new_devices
for descriptor in DESCRIPTORS
]
setup_platform_from_coordinator(entry, async_add_entities, make_entities)
DESCRIPTORS = {
NumberEntityDescription(
key="ball_speed",
translation_key="ball_speed",
entity_category=EntityCategory.CONFIG,
mode=NumberMode.SLIDER,
native_max_value=BALL_SPEED_MAX,
native_min_value=BALL_SPEED_MIN,
),
NumberEntityDescription(
key="led_speed",
translation_key="led_speed",
entity_category=EntityCategory.CONFIG,
mode=NumberMode.SLIDER,
native_max_value=LED_SPEED_MAX,
native_min_value=LED_SPEED_MIN,
),
}
class OasisDeviceNumberEntity(OasisDeviceEntity, NumberEntity):
"""Oasis device number entity."""
@property @property
def native_value(self) -> str | None: def native_value(self) -> str | None:
@@ -25,40 +69,8 @@ class OasisMiniNumberEntity(OasisMiniEntity, NumberEntity):
async def async_set_native_value(self, value: float) -> None: async def async_set_native_value(self, value: float) -> None:
"""Set new value.""" """Set new value."""
value = int(value)
if self.entity_description.key == "ball_speed": if self.entity_description.key == "ball_speed":
await self.device.async_set_ball_speed(value) await self.device.async_set_ball_speed(value)
elif self.entity_description.key == "led_speed": elif self.entity_description.key == "led_speed":
await self.device.async_set_led(led_speed=value) await self.device.async_set_led(led_speed=value)
await self.coordinator.async_request_refresh()
DESCRIPTORS = {
NumberEntityDescription(
key="ball_speed",
translation_key="ball_speed",
mode=NumberMode.SLIDER,
native_max_value=BALL_SPEED_MAX,
native_min_value=BALL_SPEED_MIN,
),
NumberEntityDescription(
key="led_speed",
translation_key="led_speed",
mode=NumberMode.SLIDER,
native_max_value=LED_SPEED_MAX,
native_min_value=LED_SPEED_MIN,
),
}
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
]
)

View File

@@ -0,0 +1,7 @@
"""Oasis control."""
from .clients import OasisCloudClient, OasisMqttClient
from .device import OasisDevice
from .exceptions import UnauthenticatedError
__all__ = ["OasisDevice", "OasisCloudClient", "OasisMqttClient", "UnauthenticatedError"]

View File

@@ -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"]

View File

@@ -0,0 +1,236 @@
"""Oasis cloud client."""
from __future__ import annotations
import asyncio
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)
SOFTWARE_REFRESH_LIMITER = timedelta(hours=1)
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
"""
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
# playlists cache
self.playlists: list[dict[str, Any]] = []
self._playlists_next_refresh = now()
self._playlists_lock = asyncio.Lock()
self._playlist_details: dict[int, dict[str, str]] = {}
# software metadata cache
self._software_details: dict[str, int | str] | None = None
self._software_next_refresh = now()
self._software_lock = asyncio.Lock()
@property
def session(self) -> ClientSession:
"""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)."""
now_dt = now()
def _is_cache_valid() -> bool:
return self._playlists_next_refresh > now_dt and bool(self.playlists)
if _is_cache_valid():
return self.playlists
async with self._playlists_lock:
# Double-check in case another task just refreshed it
now_dt = now()
if _is_cache_valid():
return self.playlists
params = {"my_playlists": str(personal_only).lower()}
playlists = await self._async_auth_request(
"GET", "api/playlist", params=params
)
if not isinstance(playlists, list):
playlists = []
self.playlists = playlists
self._playlists_next_refresh = now_dt + PLAYLISTS_REFRESH_LIMITER
return self.playlists
async def async_get_track_info(self, track_id: int) -> dict[str, Any] | None:
"""Get single track info from the cloud."""
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, *, force_refresh: bool = False
) -> dict[str, int | str] | None:
"""Get latest software metadata from cloud (cached)."""
now_dt = now()
def _is_cache_valid() -> bool:
return (
not force_refresh
and self._software_details is not None
and self._software_next_refresh > now_dt
)
if _is_cache_valid():
return self._software_details
async with self._software_lock:
# Double-check in case another task just refreshed it
now_dt = now()
if _is_cache_valid():
return self._software_details
details = await self._async_auth_request("GET", "api/software/last-version")
if not isinstance(details, dict):
details = {}
self._software_details = details
self._software_next_refresh = now_dt + SOFTWARE_REFRESH_LIMITER
return self._software_details
async def _async_auth_request(self, method: str, url: str, **kwargs: Any) -> Any:
"""Perform an authenticated cloud request."""
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()

View File

@@ -0,0 +1,180 @@
"""Oasis HTTP client (per-device)."""
from __future__ import annotations
import logging
from typing import Any
from aiohttp import ClientSession
from ..device import OasisDevice
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)
device.update_from_status_string(raw_status)

View File

@@ -0,0 +1,617 @@
"""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, Iterable
import aiomqtt
from ..device import OasisDevice
from ..utils import _bit_to_bool, _parse_int
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
# Command queue behaviour
MAX_PENDING_COMMANDS: Final = 10
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 <serial>/STATUS/# for devices it knows about.
- Publish commands to <serial>/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()
# Pending command queue: (serial, payload)
self._command_queue: asyncio.Queue[tuple[str, str]] = asyncio.Queue(
maxsize=MAX_PENDING_COMMANDS
)
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())
# Attach ourselves as the client if the device doesn't already have one
if not device.client:
device.attach_client(self)
# 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
)
def register_devices(self, devices: Iterable[OasisDevice]) -> None:
"""Convenience method to register multiple devices."""
for device in devices:
self.register_device(device)
def unregister_device(self, device: OasisDevice) -> None:
serial = device.serial_number
if not serial:
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, device in self._devices.items():
await self._subscribe_serial(serial)
await self.async_get_all(device)
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
# Drop pending commands on stop
while not self._command_queue.empty():
try:
self._command_queue.get_nowait()
self._command_queue.task_done()
except asyncio.QueueEmpty:
break
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_auto_clean_command(
self, device: OasisDevice, auto_clean: bool
) -> None:
"""Send auto clean command."""
payload = f"WRIAUTOCLEAN={1 if auto_clean else 0}"
await self._publish_command(device, payload)
async def async_send_ball_speed_command(
self,
device: OasisDevice,
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, bool(brightness))
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)
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", True)
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_all(self, device: OasisDevice) -> None:
"""Request FULLSTATUS + SCHEDULE (compact snapshot)."""
await self._publish_command(device, "GETALL")
async def async_get_status(self, device: OasisDevice) -> None:
"""Ask device to publish STATUS topics."""
await self._publish_command(device, "GETSTATUS")
async def _enqueue_command(self, serial: str, payload: str) -> None:
"""Queue a command to be sent when connected.
If the queue is full, drop the oldest command to make room.
"""
if self._command_queue.full():
try:
dropped = self._command_queue.get_nowait()
self._command_queue.task_done()
_LOGGER.debug(
"Command queue full, dropping oldest command: %s", dropped
)
except asyncio.QueueEmpty:
# race: became empty between full() and get_nowait()
pass
await self._command_queue.put((serial, payload))
_LOGGER.debug("Queued command for %s: %s", serial, payload)
async def _flush_pending_commands(self) -> None:
"""Send any queued commands now that we're connected."""
if not self._client:
return
while not self._command_queue.empty():
try:
serial, payload = self._command_queue.get_nowait()
except asyncio.QueueEmpty:
break
try:
# Skip commands for unknown devices
if serial not in self._devices:
_LOGGER.debug(
"Skipping queued command for unknown device %s: %s",
serial,
payload,
)
self._command_queue.task_done()
continue
topic = f"{serial}/COMMAND/CMD"
_LOGGER.debug("Flushing queued MQTT command %s => %s", topic, payload)
await self._client.publish(topic, payload.encode(), qos=1)
except Exception:
_LOGGER.debug(
"Failed to flush queued command for %s, re-queuing", serial
)
# Put it back and break; we'll try again on next reconnect
await self._enqueue_command(serial, payload)
self._command_queue.task_done()
break
self._command_queue.task_done()
async def _publish_command(
self, device: OasisDevice, payload: str, wake: bool = False
) -> None:
serial = device.serial_number
if not serial:
raise RuntimeError("Device has no serial number set")
if wake and device.is_sleeping:
await self.async_get_all(device)
# If not connected, just queue the command
if not self._client or not self._connected_event.is_set():
_LOGGER.debug(
"MQTT not connected, queueing command for %s: %s", serial, payload
)
await self._enqueue_command(serial, payload)
return
topic = f"{serial}/COMMAND/CMD"
try:
_LOGGER.debug("MQTT publish %s => %s", topic, payload)
await self._client.publish(topic, payload.encode(), qos=1)
except Exception:
_LOGGER.debug(
"MQTT publish failed, queueing command for %s: %s", serial, payload
)
await self._enqueue_command(serial, payload)
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.info("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()
# Flush any queued commands now that we're connected
await self._flush_pending_commands()
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.info("MQTT connection error")
finally:
if self._connected_event.is_set():
self._connected_event.clear()
if self._connected_at:
_LOGGER.info(
"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.info(
"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: "<serial>/STATUS/<STATUS_NAME>"
if len(parts) < 3:
return
serial, _, status_name = parts[:3]
device = self._devices.get(serial)
if not device:
_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_color_id"] = 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["brightness_max"] = 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"] = _parse_int(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
elif status_name == "FULLSTATUS":
if parsed := device.parse_status_string(payload):
data = parsed
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()

View File

@@ -0,0 +1,93 @@
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_auto_clean_command(
self, device: OasisDevice, auto_clean: bool
) -> 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: ...
async def async_get_all(self, device: OasisDevice) -> None: ...
async def async_get_status(self, device: OasisDevice) -> None: ...

View File

@@ -0,0 +1,110 @@
"""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",
"6": "1 hour",
"7": "6 hours",
"8": "12 hours",
"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_SLEEPING: Final = 6
STATUS_CODE_MAP: Final[dict[int, str]] = {
0: "booting",
2: "stopped",
3: "centering",
4: "playing",
5: "paused",
STATUS_CODE_SLEEPING: "sleeping",
9: "error",
11: "updating",
13: "downloading",
14: "busy",
15: "live",
}

View File

@@ -0,0 +1,487 @@
"""Oasis device."""
from __future__ import annotations
import asyncio
import logging
from typing import TYPE_CHECKING, Any, Callable, Final, Iterable
from .const import (
ERROR_CODE_MAP,
LED_EFFECTS,
STATUS_CODE_MAP,
STATUS_CODE_SLEEPING,
TRACKS,
)
from .utils import _bit_to_bool, _parse_int, create_svg, decrypt_svg_content
if TYPE_CHECKING: # avoid runtime circular imports
from .clients import OasisCloudClient
from .clients.transport import OasisClientProtocol
_LOGGER = logging.getLogger(__name__)
BALL_SPEED_MAX: Final = 400
BALL_SPEED_MIN: Final = 100
BRIGHTNESS_DEFAULT: Final = 100
LED_SPEED_MAX: Final = 90
LED_SPEED_MIN: Final = -90
_STATE_FIELDS = (
"auto_clean",
"autoplay",
"ball_speed",
"brightness",
"busy",
"color",
"download_progress",
"error",
"led_effect",
"led_speed",
"mac_address",
"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,
name: str | None = None,
ssid: str | None = None,
ip_address: str | None = None,
cloud: OasisCloudClient | None = None,
client: OasisClientProtocol | None = None,
) -> None:
# Transport
self._cloud = cloud
self._client = client
self._listeners: list[Callable[[], None]] = []
# Details
self.model = model
self.serial_number = serial_number
self.name = name if name else f"{model} {serial_number}"
self.ssid = ssid
self.ip_address = ip_address
# Status
self.auto_clean: bool = False
self.autoplay: int = 0
self.ball_speed: int = BALL_SPEED_MIN
self._brightness: int = 0
self.brightness_max: int = 200
self.brightness_on: int = BRIGHTNESS_DEFAULT
self.busy: bool = False
self.color: str | None = None
self.download_progress: int = 0
self.error: int = 0
self.led_color_id: str = "0"
self.led_effect: str = "0"
self.led_speed: int = 0
self.mac_address: str | None = None
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
self._track: dict | None = None
self._track_task: asyncio.Task | None = None
@property
def brightness(self) -> int:
"""Return the brightness."""
return 0 if self.is_sleeping else self._brightness
@brightness.setter
def brightness(self, value: int) -> None:
self._brightness = value
if value:
self.brightness_on = value
@property
def is_sleeping(self) -> bool:
"""Return `True` if the status is set to sleeping."""
return self.status_code == STATUS_CODE_SLEEPING
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 %s changed: '%s' -> '%s'",
self.serial_number,
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
playlist_or_index_changed = False
for key, value in data.items():
if hasattr(self, key):
if self._update_field(key, value):
changed = True
if key in ("playlist", "playlist_index"):
playlist_or_index_changed = True
else:
_LOGGER.warning("Unknown field: %s=%s", key, value)
if playlist_or_index_changed:
self.schedule_track_refresh()
if changed:
self._notify_listeners()
def parse_status_string(self, raw_status: str) -> dict[str, Any] | None:
"""Parse a semicolon-separated status string into a state dict.
Used by:
- HTTP GETSTATUS response
- MQTT FULLSTATUS payload (includes software_version)
"""
if not raw_status:
return None
values = raw_status.split(";")
# We rely on indices 0..17 existing (18 fields)
if (n := len(values)) < 18:
_LOGGER.warning(
"Unexpected status format for %s: %s", self.serial_number, values
)
return None
playlist = [_parse_int(track) for track in values[3].split(",") if track]
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_color_id": values[7],
"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]),
"download_progress": _parse_int(values[12]),
"brightness_max": _parse_int(values[13]),
"wifi_connected": _bit_to_bool(values[14]),
"repeat_playlist": _bit_to_bool(values[15]),
"autoplay": _parse_int(values[16]),
"auto_clean": _bit_to_bool(values[17]),
}
# Optional trailing field(s)
if n > 18:
status["software_version"] = values[18]
except Exception: # noqa: BLE001
_LOGGER.exception(
"Error parsing status string for %s: %r", self.serial_number, raw_status
)
return None
return status
def update_from_status_string(self, raw_status: str) -> None:
"""Parse and apply a raw status string."""
if status := self.parse_status_string(raw_status):
self.update_from_status_dict(status)
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(self) -> dict | None:
"""Return cached track info if it matches the current `track_id`."""
if (track := self._track) and track["id"] == self.track_id:
return track
return TRACKS.get(self.track_id)
@property
def track_id(self) -> int | None:
if not self.playlist:
return None
i = self.playlist_index
return self.playlist[0] if i >= len(self.playlist) else self.playlist[i]
@property
def track_image_url(self) -> str | None:
"""Return the track image url, if any."""
if (track := self.track) and (image := track.get("image")):
return f"https://app.grounded.so/uploads/{image}"
return None
@property
def track_name(self) -> str | None:
"""Return the track name, if any."""
if track := self.track:
return track.get("name", f"Unknown Title (#{self.track_id})")
return None
@property
def drawing_progress(self) -> float | None:
"""Return drawing progress percentage for the current track."""
if not (self.track and (svg_content := self.track.get("svg_content"))):
return None
svg_content = decrypt_svg_content(svg_content)
paths = svg_content.split("L")
total = self.track.get("reduced_svg_content_new", 0) or len(paths)
percent = (100 * self.progress) / total
return min(percent, 100)
@property
def playlist_details(self) -> dict[int, dict[str, str]]:
"""Basic playlist details using built-in TRACKS metadata."""
return {
track_id: {self.track_id: self.track or {}, **TRACKS}.get(
track_id,
{"name": f"Unknown Title (#{track_id})"},
)
for track_id in self.playlist
}
def create_svg(self) -> str | None:
"""Create the current svg based on track and progress."""
return create_svg(self.track, self.progress)
def add_update_listener(self, listener: Callable[[], None]) -> Callable[[], None]:
"""Register a callback for state changes.
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_auto_clean(self, auto_clean: bool) -> None:
client = self._require_client()
await client.async_send_auto_clean_command(self, auto_clean)
async def async_set_ball_speed(self, speed: int) -> None:
if not BALL_SPEED_MIN <= speed <= BALL_SPEED_MAX:
raise ValueError("Invalid speed specified")
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 device 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.brightness_max:
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)
def schedule_track_refresh(self) -> None:
"""Schedule an async refresh of current track info if track_id changed."""
if not self._cloud:
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
_LOGGER.debug("No running loop; cannot schedule track refresh")
return
if self._track_task and not self._track_task.done():
self._track_task.cancel()
self._track_task = loop.create_task(self._async_refresh_current_track())
async def _async_refresh_current_track(self) -> None:
"""Refresh the current track info."""
if not self._cloud:
return
if (track_id := self.track_id) is None:
self._track = None
return
if self._track and self._track.get("id") == track_id:
return
try:
track = await self._cloud.async_get_track_info(track_id)
except Exception: # noqa: BLE001
_LOGGER.exception("Error fetching track info for %s", track_id)
return
if not track:
return
self._track = track
self._notify_listeners()

View File

@@ -0,0 +1,5 @@
"""Exceptions."""
class UnauthenticatedError(Exception):
"""Unauthenticated."""

View File

@@ -1,8 +1,9 @@
"""Oasis Mini utils.""" """Oasis control utils."""
from __future__ import annotations from __future__ import annotations
import base64 import base64
from datetime import UTC, datetime
import logging import logging
import math import math
from xml.etree.ElementTree import Element, SubElement, tostring from xml.etree.ElementTree import Element, SubElement, tostring
@@ -34,8 +35,8 @@ def _parse_int(val: str) -> int:
return 0 return 0
def draw_svg(track: dict, progress: int, model_id: str) -> str | None: def create_svg(track: dict, progress: int) -> str | None:
"""Draw SVG.""" """Create an SVG from a track based on progress."""
if track and (svg_content := track.get("svg_content")): if track and (svg_content := track.get("svg_content")):
try: try:
if progress is not None: if progress is not None:
@@ -177,3 +178,7 @@ def decrypt_svg_content(svg_content: dict[str, str]):
svg_content["decrypted"] = decrypted svg_content["decrypted"] = decrypted
return decrypted return decrypted
def now() -> datetime:
return datetime.now(UTC)

View File

@@ -1,488 +0,0 @@
"""Oasis Mini API client."""
from __future__ import annotations
import asyncio
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
_LOGGER = logging.getLogger(__name__)
STATUS_CODE_MAP = {
0: "booting", # maybe?
2: "stopped",
3: "centering",
4: "playing",
5: "paused",
6: "sleeping",
9: "error",
11: "updating",
13: "downloading",
14: "busy",
15: "live",
}
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
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
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 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]), # noqa: E501; error, 0 = none, and 10 = ?, 18 = can't download?
"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_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 None
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()

View File

@@ -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 = {}

View File

@@ -1,50 +1,145 @@
"""Oasis Mini select entity.""" """Oasis device select entity."""
from __future__ import annotations from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Awaitable, Callable from typing import Any, Awaitable, Callable
from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import OasisMiniConfigEntry from . import OasisDeviceConfigEntry, setup_platform_from_coordinator
from .coordinator import OasisMiniCoordinator from .coordinator import OasisDeviceCoordinator
from .entity import OasisMiniEntity from .entity import OasisDeviceEntity
from .pyoasismini import AUTOPLAY_MAP, OasisMini from .pyoasiscontrol import OasisDevice
from .pyoasismini.const import TRACKS from .pyoasiscontrol.const import AUTOPLAY_MAP, TRACKS
AUTOPLAY_MAP_LIST = list(AUTOPLAY_MAP)
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._cloud.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."""
def make_entities(new_devices: list[OasisDevice]):
return [
OasisDeviceSelectEntity(entry.runtime_data, device, descriptor)
for device in new_devices
for descriptor in DESCRIPTORS
]
setup_platform_from_coordinator(entry, async_add_entities, make_entities)
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class OasisMiniSelectEntityDescription(SelectEntityDescription): class OasisDeviceSelectEntityDescription(SelectEntityDescription):
"""Oasis Mini select entity description.""" """Oasis device select entity description."""
current_value: Callable[[OasisMini], Any] current_value: Callable[[OasisDevice], Any]
select_fn: Callable[[OasisMini, int], Awaitable[None]] select_fn: Callable[[OasisDevice, int], Awaitable[None]]
update_handler: Callable[[OasisMiniSelectEntity], None] | None = None update_handler: Callable[[OasisDeviceSelectEntity], None] | None = None
class OasisMiniSelectEntity(OasisMiniEntity, SelectEntity): DESCRIPTORS = (
"""Oasis Mini select entity.""" OasisDeviceSelectEntityDescription(
key="autoplay",
translation_key="autoplay",
entity_category=EntityCategory.CONFIG,
options=AUTOPLAY_MAP_LIST,
current_value=lambda device: str(device.autoplay),
select_fn=lambda device, index: (
device.async_set_autoplay(AUTOPLAY_MAP_LIST[index])
),
),
OasisDeviceSelectEntityDescription(
key="playlists",
translation_key="playlist",
current_value=lambda device: (device._cloud.playlists, device.playlist.copy()),
select_fn=lambda device, index: device.async_set_playlist(
[pattern["id"] for pattern in device._cloud.playlists[index]["patterns"]]
),
update_handler=playlists_update_handler,
),
OasisDeviceSelectEntityDescription(
key="queue",
translation_key="queue",
current_value=lambda device: (device.playlist.copy(), device.playlist_index),
select_fn=lambda device, index: device.async_change_track(index),
update_handler=queue_update_handler,
),
)
entity_description: OasisMiniSelectEntityDescription
class OasisDeviceSelectEntity(OasisDeviceEntity, SelectEntity):
"""Oasis device select entity."""
entity_description: OasisDeviceSelectEntityDescription
_current_value: Any | None = None _current_value: Any | None = None
def __init__( def __init__(
self, self,
coordinator: OasisMiniCoordinator, coordinator: OasisDeviceCoordinator,
device: OasisDevice,
description: EntityDescription, description: EntityDescription,
) -> None: ) -> None:
"""Construct an Oasis Mini select entity.""" """Construct an Oasis device select entity."""
super().__init__(coordinator, description) super().__init__(coordinator, device, description)
self._handle_coordinator_update() self._handle_coordinator_update()
async def async_select_option(self, option: str) -> None: async def async_select_option(self, option: str) -> None:
"""Change the selected option.""" """Change the selected option."""
await self.entity_description.select_fn(self.device, self.options.index(option)) await self.entity_description.select_fn(self.device, self.options.index(option))
await self.coordinator.async_request_refresh()
@callback @callback
def _handle_coordinator_update(self) -> None: def _handle_coordinator_update(self) -> None:
@@ -56,61 +151,8 @@ class OasisMiniSelectEntity(OasisMiniEntity, SelectEntity):
if update_handler := self.entity_description.update_handler: if update_handler := self.entity_description.update_handler:
update_handler(self) update_handler(self)
else: else:
self._attr_current_option = getattr( self._attr_current_option = str(
self.device, self.entity_description.key getattr(self.device, self.entity_description.key)
) )
if self.hass: if self.hass:
return super()._handle_coordinator_update() return super()._handle_coordinator_update()
def playlist_update_handler(entity: OasisMiniSelectEntity) -> None:
"""Handle playlist updates."""
# pylint: disable=protected-access
device = entity.device
options = [
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),
),
)
for track in device.playlist
]
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="playlist",
translation_key="playlist",
current_value=lambda device: (device.playlist.copy(), device.playlist_index),
select_fn=lambda device, option: device.async_change_track(option),
update_handler=playlist_update_handler,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: OasisMiniConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Oasis Mini select using config entry."""
async_add_entities(
[
OasisMiniSelectEntity(entry.runtime_data, descriptor)
for descriptor in DESCRIPTORS
]
)

View File

@@ -1,4 +1,4 @@
"""Oasis Mini sensor entity.""" """Oasis device sensor entity."""
from __future__ import annotations from __future__ import annotations
@@ -11,29 +11,26 @@ from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import OasisMiniConfigEntry from . import OasisDeviceConfigEntry, setup_platform_from_coordinator
from .coordinator import OasisMiniCoordinator from .entity import OasisDeviceEntity
from .entity import OasisMiniEntity from .pyoasiscontrol import OasisDevice
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
entry: OasisMiniConfigEntry, entry: OasisDeviceConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Oasis Mini sensors using config entry.""" """Set up Oasis device sensors using config entry."""
coordinator: OasisMiniCoordinator = entry.runtime_data
entities = [ def make_entities(new_devices: list[OasisDevice]):
OasisMiniSensorEntity(coordinator, descriptor) for descriptor in DESCRIPTORS return [
OasisDeviceSensorEntity(entry.runtime_data, device, descriptor)
for device in new_devices
for descriptor in DESCRIPTORS
] ]
if coordinator.device.access_token:
entities.extend( setup_platform_from_coordinator(entry, async_add_entities, make_entities)
[
OasisMiniSensorEntity(coordinator, descriptor)
for descriptor in CLOUD_DESCRIPTORS
]
)
async_add_entities(entities)
DESCRIPTORS = { DESCRIPTORS = {
@@ -45,17 +42,6 @@ DESCRIPTORS = {
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
), ),
} | {
SensorEntityDescription(
key=key,
translation_key=key,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
)
for key in ("error", "led_color_id", "status")
}
CLOUD_DESCRIPTORS = (
SensorEntityDescription( SensorEntityDescription(
key="drawing_progress", key="drawing_progress",
translation_key="drawing_progress", translation_key="drawing_progress",
@@ -64,11 +50,20 @@ CLOUD_DESCRIPTORS = (
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1, suggested_display_precision=1,
), ),
} | {
SensorEntityDescription(
key=key,
translation_key=key,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
) )
for key in ("error", "led_color_id", "status")
# for key in ("error_message", "led_color_id", "status")
}
class OasisMiniSensorEntity(OasisMiniEntity, SensorEntity): class OasisDeviceSensorEntity(OasisDeviceEntity, SensorEntity):
"""Oasis Mini sensor entity.""" """Oasis device sensor entity."""
@property @property
def native_value(self) -> str | None: def native_value(self) -> str | None:

View File

@@ -3,24 +3,29 @@
"step": { "step": {
"user": { "user": {
"data": { "data": {
"host": "[%key:common::config_flow::data::host%]" "email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
} }
}, },
"reconfigure": { "reconfigure": {
"data": { "data": {
"host": "[%key:common::config_flow::data::host%]" "email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
} }
} }
}, },
"error": { "error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "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%]", "invalid_host": "[%key:common::config_flow::error::invalid_host%]",
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
}, },
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" "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": { "options": {
@@ -69,10 +74,24 @@
}, },
"select": { "select": {
"autoplay": { "autoplay": {
"name": "Autoplay" "name": "Autoplay",
"state": {
"0": "on",
"1": "off",
"2": "5 minutes",
"3": "10 minutes",
"4": "30 minutes",
"6": "1 hour",
"7": "6 hours",
"8": "12 hours",
"5": "24 hours"
}
}, },
"playlist": { "playlist": {
"name": "Playlist" "name": "Playlist"
},
"queue": {
"name": "Queue"
} }
}, },
"sensor": { "sensor": {
@@ -83,7 +102,28 @@
"name": "Drawing progress" "name": "Drawing progress"
}, },
"error": { "error": {
"name": "Error" "name": "Error",
"state": {
"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_color_id": { "led_color_id": {
"name": "LED color ID" "name": "LED color ID"
@@ -104,6 +144,11 @@
"live": "Live drawing" "live": "Live drawing"
} }
} }
},
"switch": {
"auto_clean": {
"name": "Auto-clean"
}
} }
}, },
"exceptions": { "exceptions": {

View File

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

View File

@@ -3,24 +3,29 @@
"step": { "step": {
"user": { "user": {
"data": { "data": {
"host": "Host" "email": "Email",
"password": "Password"
} }
}, },
"reconfigure": { "reconfigure": {
"data": { "data": {
"host": "Host" "email": "Email",
"password": "Password"
} }
} }
}, },
"error": { "error": {
"cannot_connect": "Failed to connect", "cannot_connect": "Failed to connect",
"invalid_auth": "Invalid authentication",
"invalid_host": "Invalid hostname or IP address", "invalid_host": "Invalid hostname or IP address",
"timeout_connect": "Timeout establishing connection", "timeout_connect": "Timeout establishing connection",
"unknown": "Unexpected error" "unknown": "Unexpected error"
}, },
"abort": { "abort": {
"already_configured": "Device is already configured", "already_configured": "Account is already configured",
"reconfigure_successful": "Re-configuration was successful" "reauth_successful": "Re-authentication was successful",
"reconfigure_successful": "Re-configuration was successful",
"wrong_account": "Account used for the integration should not change"
} }
}, },
"options": { "options": {
@@ -69,10 +74,24 @@
}, },
"select": { "select": {
"autoplay": { "autoplay": {
"name": "Autoplay" "name": "Autoplay",
"state": {
"0": "on",
"1": "off",
"2": "5 minutes",
"3": "10 minutes",
"4": "30 minutes",
"6": "1 hour",
"7": "6 hours",
"8": "12 hours",
"5": "24 hours"
}
}, },
"playlist": { "playlist": {
"name": "Playlist" "name": "Playlist"
},
"queue": {
"name": "Queue"
} }
}, },
"sensor": { "sensor": {
@@ -83,7 +102,28 @@
"name": "Drawing progress" "name": "Drawing progress"
}, },
"error": { "error": {
"name": "Error" "name": "Error",
"state": {
"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_color_id": { "led_color_id": {
"name": "LED color ID" "name": "LED color ID"
@@ -104,6 +144,11 @@
"live": "Live drawing" "live": "Live drawing"
} }
} }
},
"switch": {
"auto_clean": {
"name": "Auto-clean"
}
} }
}, },
"exceptions": { "exceptions": {

View File

@@ -1,4 +1,4 @@
"""Oasis Mini update entity.""" """Oasis device update entity."""
from __future__ import annotations from __future__ import annotations
@@ -15,9 +15,9 @@ from homeassistant.components.update import (
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import OasisMiniConfigEntry from . import OasisDeviceConfigEntry, setup_platform_from_coordinator
from .coordinator import OasisMiniCoordinator from .entity import OasisDeviceEntity
from .entity import OasisMiniEntity from .pyoasiscontrol import OasisDevice
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -26,13 +26,18 @@ SCAN_INTERVAL = timedelta(hours=6)
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
entry: OasisMiniConfigEntry, entry: OasisDeviceConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Oasis Mini updates using config entry.""" """Set up Oasis device updates using config entry."""
coordinator: OasisMiniCoordinator = entry.runtime_data
if coordinator.device.access_token: def make_entities(new_devices: list[OasisDevice]):
async_add_entities([OasisMiniUpdateEntity(coordinator, DESCRIPTOR)], True) return [
OasisDeviceUpdateEntity(entry.runtime_data, device, DESCRIPTOR)
for device in new_devices
]
setup_platform_from_coordinator(entry, async_add_entities, make_entities, True)
DESCRIPTOR = UpdateEntityDescription( DESCRIPTOR = UpdateEntityDescription(
@@ -40,8 +45,8 @@ DESCRIPTOR = UpdateEntityDescription(
) )
class OasisMiniUpdateEntity(OasisMiniEntity, UpdateEntity): class OasisDeviceUpdateEntity(OasisDeviceEntity, UpdateEntity):
"""Oasis Mini update entity.""" """Oasis device update entity."""
_attr_supported_features = ( _attr_supported_features = (
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
@@ -68,16 +73,14 @@ class OasisMiniUpdateEntity(OasisMiniEntity, UpdateEntity):
self, version: str | None, backup: bool, **kwargs: Any self, version: str | None, backup: bool, **kwargs: Any
) -> None: ) -> None:
"""Install an update.""" """Install an update."""
version = await self.device.async_get_software_version() if self.latest_version == self.device.software_version:
if version == self.latest_version:
return return
await self.device.async_upgrade() await self.device.async_upgrade()
async def async_update(self) -> None: async def async_update(self) -> None:
"""Update the entity.""" """Update the entity."""
await self.device.async_get_software_version() client = self.coordinator.cloud_client
software = await self.device.async_cloud_get_latest_software_details() if not (software := await client.async_get_latest_software_details()):
if not software:
_LOGGER.warning("Unable to get latest software details") _LOGGER.warning("Unable to get latest software details")
return return
self._attr_latest_version = software["version"] self._attr_latest_version = software["version"]

View File

@@ -1,5 +1,5 @@
{ {
"name": "Oasis Mini", "name": "Oasis Control",
"homeassistant": "2024.5.0", "homeassistant": "2024.5.0",
"render_readme": true, "render_readme": true,
"zip_release": true, "zip_release": true,

View File

@@ -5,6 +5,7 @@ PyTurboJPEG
# Integration # Integration
aiohttp # should already be installed with Home Assistant aiohttp # should already be installed with Home Assistant
aiomqtt # asyncio MQTT client
cryptography # should already be installed with Home Assistant cryptography # should already be installed with Home Assistant
# Development # Development

View File

@@ -7,8 +7,8 @@ import json
import os import os
from typing import Any from typing import Any
from custom_components.oasis_mini.pyoasismini import OasisMini from custom_components.oasis_mini.pyoasiscontrol import OasisCloudClient
from custom_components.oasis_mini.pyoasismini.const import TRACKS from custom_components.oasis_mini.pyoasiscontrol.const import TRACKS
ACCESS_TOKEN = os.getenv("GROUNDED_TOKEN") ACCESS_TOKEN = os.getenv("GROUNDED_TOKEN")
@@ -16,15 +16,15 @@ ACCESS_TOKEN = os.getenv("GROUNDED_TOKEN")
def get_author_name(data: dict) -> str: def get_author_name(data: dict) -> str:
"""Get author name from a dict.""" """Get author name from a dict."""
author = (data.get("author") or {}).get("user") or {} 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: async def update_tracks() -> None:
"""Update tracks.""" """Update tracks."""
client = OasisMini("", ACCESS_TOKEN) client = OasisCloudClient(access_token=ACCESS_TOKEN)
try: try:
data = await client.async_cloud_get_tracks() data = await client.async_get_tracks()
except Exception as ex: except Exception as ex:
print(type(ex).__name__, ex) print(type(ex).__name__, ex)
await client.session.close() await client.session.close()
@@ -45,7 +45,7 @@ async def update_tracks() -> None:
or TRACKS[track_id].get("author") != get_author_name(result) or TRACKS[track_id].get("author") != get_author_name(result)
): ):
print(f"Updating track {track_id}: {result['name']}") 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: if not track_info:
print("No track info") print("No track info")
break break
@@ -65,7 +65,7 @@ async def update_tracks() -> None:
tracks = dict(sorted(tracks.items(), key=lambda t: t[1]["name"].lower())) tracks = dict(sorted(tracks.items(), key=lambda t: t[1]["name"].lower()))
with open( with open(
"custom_components/oasis_mini/pyoasismini/tracks.json", "w", encoding="utf8" "custom_components/oasis_mini/pyoasiscontrol/tracks.json", "w", encoding="utf8"
) as file: ) as file:
json.dump(tracks, file, indent=2, ensure_ascii=False) json.dump(tracks, file, indent=2, ensure_ascii=False)