1
0
mirror of https://github.com/natekspencer/hacs-oasis_mini.git synced 2025-11-13 15:43:52 -05:00

46 Commits

Author SHA1 Message Date
Nathan Spencer
8ee4076e8b Merge pull request #42 from natekspencer/update-tracks
Some checks failed
Validate repo / Validate with hassfest (push) Has been cancelled
Validate repo / Validate with HACS (push) Has been cancelled
Update tracks
2024-12-25 13:58:09 -07:00
natekspencer
09f4026480 Update tracks 2024-12-25 19:15:12 +00:00
Nathan Spencer
20c320ecd6 Merge pull request #41 from natekspencer/update-tracks
Some checks failed
Validate repo / Validate with hassfest (push) Has been cancelled
Validate repo / Validate with HACS (push) Has been cancelled
Update tracks
2024-11-28 12:43:09 -07:00
natekspencer
36fff5ec16 Update tracks 2024-11-26 19:17:03 +00:00
Nathan Spencer
d9cfb922c4 Merge pull request #39 from natekspencer/update-tracks
Some checks failed
Validate repo / Validate with hassfest (push) Has been cancelled
Validate repo / Validate with HACS (push) Has been cancelled
Update tracks
2024-11-15 09:59:11 -07:00
natekspencer
40a9c89cfc Update tracks 2024-11-14 19:15:53 +00:00
Nathan Spencer
74ae6b9155 Merge pull request #38 from natekspencer/update-tracks
Some checks failed
Validate repo / Validate with hassfest (push) Has been cancelled
Validate repo / Validate with HACS (push) Has been cancelled
Update tracks
2024-11-13 17:13:06 -07:00
natekspencer
bfb058b0aa Update tracks 2024-11-13 19:16:15 +00:00
Nathan Spencer
82ee3fe63b Merge pull request #37 from natekspencer/update-tracks
Some checks failed
Validate repo / Validate with hassfest (push) Has been cancelled
Validate repo / Validate with HACS (push) Has been cancelled
Update tracks
2024-11-12 12:29:03 -07:00
natekspencer
7b11c37ca8 Update tracks 2024-11-12 19:15:14 +00:00
Nathan Spencer
389ab22215 Merge pull request #35 from natekspencer/update-tracks
Some checks failed
Validate repo / Validate with hassfest (push) Has been cancelled
Validate repo / Validate with HACS (push) Has been cancelled
Update tracks
2024-10-21 10:50:10 -06:00
natekspencer
9e2a423d4e Update tracks 2024-10-18 19:15:45 +00:00
Nathan Spencer
04e98ee103 Merge pull request #34 from natekspencer/update-tracks
Some checks failed
Validate repo / Validate with hassfest (push) Has been cancelled
Validate repo / Validate with HACS (push) Has been cancelled
Update tracks
2024-10-04 13:20:23 -06:00
natekspencer
4945b1e6b7 Update tracks 2024-10-04 19:17:03 +00:00
Nathan Spencer
88537ee3c7 Merge pull request #33 from natekspencer/update-tracks
Some checks are pending
Validate repo / Validate with hassfest (push) Waiting to run
Validate repo / Validate with HACS (push) Waiting to run
Update tracks
2024-10-03 13:46:22 -06:00
natekspencer
d971cc55c6 Update tracks 2024-10-02 19:16:48 +00:00
Nathan Spencer
739ee874d3 Merge pull request #32 from natekspencer/update-tracks
Some checks failed
Validate repo / Validate with hassfest (push) Has been cancelled
Validate repo / Validate with HACS (push) Has been cancelled
Handle removed updated_at property for updating tracks
2024-09-18 14:56:22 -06:00
Nathan Spencer
78de49e12c Handle removed updated_at property 2024-09-18 20:54:40 +00:00
Nathan Spencer
57280d46fc Merge pull request #31 from natekspencer/update-tracks
Some checks failed
Validate repo / Validate with hassfest (push) Has been cancelled
Validate repo / Validate with HACS (push) Has been cancelled
Update tracks
2024-09-10 10:31:56 -06:00
natekspencer
51c4cee3f6 Update tracks 2024-09-05 19:14:58 +00:00
Nathan Spencer
782a794a32 Merge pull request #30 from natekspencer/update-tracks
Some checks failed
Validate repo / Validate with hassfest (push) Has been cancelled
Validate repo / Validate with HACS (push) Has been cancelled
Update tracks
2024-09-01 13:54:45 -06:00
natekspencer
2cd196f0f0 Update tracks 2024-09-01 19:14:44 +00:00
Nathan Spencer
02a073943b Merge pull request #28 from natekspencer/update-tracks
Some checks failed
Validate repo / Validate with hassfest (push) Has been cancelled
Validate repo / Validate with HACS (push) Has been cancelled
Update tracks
2024-08-30 14:20:45 -06:00
natekspencer
c7a8732ad5 Update tracks 2024-08-30 19:14:36 +00:00
Nathan Spencer
7b11d79de1 Merge pull request #27 from natekspencer/update-tracks-gha
Some checks are pending
Validate repo / Validate with hassfest (push) Waiting to run
Validate repo / Validate with HACS (push) Waiting to run
Add appropriate permissions
2024-08-30 12:41:05 -06:00
Nathan Spencer
de64e61666 Add appropriate permissions 2024-08-30 12:40:02 -06:00
Nathan Spencer
59134b0473 Merge pull request #26 from natekspencer/update-tracks-gha
Some checks failed
Validate repo / Validate with hassfest (push) Has been cancelled
Validate repo / Validate with HACS (push) Has been cancelled
Add GitHub Action for updating track details
2024-08-30 10:52:51 -06:00
Nathan Spencer
893ac4e327 Add GHA for updating track details 2024-08-30 10:50:56 -06:00
Nathan Spencer
37a18090b3 Merge pull request #22 from natekspencer/dev
Some checks failed
Validate repo / Validate with hassfest (push) Has been cancelled
Validate repo / Validate with HACS (push) Has been cancelled
Fix svg
2024-08-23 08:53:13 -06:00
Nathan Spencer
570e08c9a2 Fix svg 2024-08-23 14:51:31 +00:00
Nathan Spencer
b1f211d843 Merge pull request #20 from natekspencer/dev
Handle empty color code
2024-08-06 09:38:42 -06:00
Nathan Spencer
99bf3b2ef0 Handle empty color code 2024-08-06 09:36:59 -06:00
Nathan Spencer
3f4f7720c0 Merge pull request #19 from natekspencer/dev
Use runtime data instead of hass.data and other code cleanup
2024-08-04 14:08:16 -06:00
Nathan Spencer
6e13c22d43 Use runtime data instead of hass.data and other code cleanup 2024-08-04 14:06:26 -06:00
Nathan Spencer
f5bf50a801 Merge pull request #18 from natekspencer/dev
Better error handling
2024-08-03 17:33:21 -06:00
Nathan Spencer
33e62528ba Better error handling 2024-08-03 17:31:30 -06:00
Nathan Spencer
3014f0f11c Merge pull request #16 from natekspencer/dev
Handle invalid index bug in play random track button
2024-08-02 12:03:07 -06:00
Nathan Spencer
a44c035828 Handle invalid index bug in play random track button 2024-08-02 12:01:27 -06:00
Nathan Spencer
31276048dc Merge pull request #15 from natekspencer/natekspencer-patch-1
Create dependabot.yml
2024-08-02 07:24:40 -06:00
Nathan Spencer
742fc26a4f Create dependabot.yml 2024-08-02 07:21:26 -06:00
Nathan Spencer
3acd45da9d Merge pull request #14 from natekspencer/dev
Revert command timeout logic
2024-07-31 21:04:57 -06:00
Nathan Spencer
a736c72c8e Revert timeout changes, I'll fix later 2024-07-31 21:03:33 -06:00
Nathan Spencer
c87bb241ef Allow reboot command even if device is busy 2024-07-31 20:55:37 -06:00
Nathan Spencer
6ee81db9d4 Merge pull request #13 from natekspencer/dev
Add support for enqueue options in media_player.play_media service and other minor improvements
2024-07-31 19:28:56 -06:00
Nathan Spencer
6d6b7929d5 Fix hassfest error 2024-07-31 19:25:02 -06:00
Nathan Spencer
cc80c295f6 Add support for enqueue options in media_player.play_media service and other minor improvements 2024-07-31 19:16:15 -06:00
24 changed files with 4720 additions and 913 deletions

11
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,11 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "pip" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"

33
.github/workflows/update-tracks.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
name: Update tracks
on:
schedule:
- cron: "0 19 * * *"
permissions:
contents: write
pull-requests: write
jobs:
tracks:
name: Search and update new tracks
runs-on: ubuntu-latest
steps:
- name: Checkout the repo
uses: actions/checkout@v4
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install homeassistant
- name: Update tracks
env:
GROUNDED_TOKEN: ${{ secrets.GROUNDED_TOKEN }}
run: python update_tracks.py
- name: Create Pull Request
uses: peter-evans/create-pull-request@v6
with:
commit-message: Update tracks
title: Update tracks
body: Update tracks
base: main
labels: automated-pr, tracks
branch: update-tracks

View File

@@ -1,6 +1,9 @@
![Release](https://img.shields.io/github/v/release/natekspencer/hacs-oasis_mini?style=for-the-badge) [![Release](https://img.shields.io/github/v/release/natekspencer/hacs-oasis_mini?style=for-the-badge)](https://github.com/natekspencer/hacs-oasis_mini/releases)
[![Buy Me A Coffee/Beer](https://img.shields.io/badge/Buy_Me_A_☕/🍺-F16061?style=for-the-badge&logo=ko-fi&logoColor=white&labelColor=grey)](https://ko-fi.com/natekspencer) [![Buy Me A Coffee/Beer](https://img.shields.io/badge/Buy_Me_A_☕/🍺-F16061?style=for-the-badge&logo=ko-fi&logoColor=white&labelColor=grey)](https://ko-fi.com/natekspencer)
[![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg?style=for-the-badge)](https://github.com/hacs/integration) [![HACS Custom](https://img.shields.io/badge/HACS-Custom-41BDF5.svg?style=for-the-badge)](https://github.com/hacs/integration)
![Downloads](https://img.shields.io/github/downloads/natekspencer/hacs-oasis_mini/total?style=flat-square)
![Latest Downloads](https://img.shields.io/github/downloads/natekspencer/hacs-oasis_mini/latest/total?style=flat-square)
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="https://brands.home-assistant.io/oasis_mini/dark_logo.png"> <source media="(prefers-color-scheme: dark)" srcset="https://brands.home-assistant.io/oasis_mini/dark_logo.png">

View File

@@ -14,6 +14,8 @@ from .const import DOMAIN
from .coordinator import OasisMiniCoordinator from .coordinator import OasisMiniCoordinator
from .helpers import create_client from .helpers import create_client
type OasisMiniConfigEntry = ConfigEntry[OasisMiniCoordinator]
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
PLATFORMS = [ PLATFORMS = [
@@ -29,9 +31,8 @@ PLATFORMS = [
] ]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: OasisMiniConfigEntry) -> bool:
"""Set up Oasis Mini from a config entry.""" """Set up Oasis Mini from a config entry."""
hass.data.setdefault(DOMAIN, {})
client = create_client(entry.data | entry.options) client = create_client(entry.data | entry.options)
coordinator = OasisMiniCoordinator(hass, client) coordinator = OasisMiniCoordinator(hass, client)
@@ -62,7 +63,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await client.session.close() await client.session.close()
raise ConfigEntryError("Serial number mismatch") raise ConfigEntryError("Serial number mismatch")
hass.data[DOMAIN][entry.entry_id] = coordinator entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -70,15 +71,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: OasisMiniConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): await entry.runtime_data.device.session.close()
await hass.data[DOMAIN][entry.entry_id].device.session.close() return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
del hass.data[DOMAIN][entry.entry_id]
return unload_ok
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: async def async_remove_entry(hass: HomeAssistant, entry: OasisMiniConfigEntry) -> None:
"""Handle removal of an entry.""" """Handle removal of an entry."""
if entry.options: if entry.options:
client = create_client(entry.data | entry.options) client = create_client(entry.data | entry.options)
@@ -86,6 +85,6 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
await client.session.close() await client.session.close()
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: async def update_listener(hass: HomeAssistant, entry: OasisMiniConfigEntry) -> None:
"""Handle options update.""" """Handle options update."""
await hass.config_entries.async_reload(entry.entry_id) await hass.config_entries.async_reload(entry.entry_id)

View File

@@ -11,13 +11,11 @@ from homeassistant.components.button import (
ButtonEntity, ButtonEntity,
ButtonEntityDescription, ButtonEntityDescription,
) )
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory 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 .const import DOMAIN from . import OasisMiniConfigEntry
from .coordinator import OasisMiniCoordinator
from .entity import OasisMiniEntity from .entity import OasisMiniEntity
from .helpers import add_and_play_track from .helpers import add_and_play_track
from .pyoasismini import OasisMini from .pyoasismini import OasisMini
@@ -25,13 +23,14 @@ from .pyoasismini.const import TRACKS
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant,
entry: OasisMiniConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Oasis Mini button using config entry.""" """Set up Oasis Mini button using config entry."""
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities( async_add_entities(
[ [
OasisMiniButtonEntity(coordinator, entry, descriptor) OasisMiniButtonEntity(entry.runtime_data, descriptor)
for descriptor in DESCRIPTORS for descriptor in DESCRIPTORS
] ]
) )
@@ -39,7 +38,7 @@ async def async_setup_entry(
async def play_random_track(device: OasisMini) -> None: async def play_random_track(device: OasisMini) -> None:
"""Play random track.""" """Play random track."""
track = int(random.choice(list(TRACKS))) track = random.choice(list(TRACKS))
await add_and_play_track(device, track) await add_and_play_track(device, track)

View File

@@ -11,7 +11,7 @@ from httpx import ConnectError, HTTPStatusError
import voluptuous as vol import voluptuous as vol
from homeassistant.components import dhcp from homeassistant.components import dhcp
from homeassistant.config_entries import ConfigEntry, 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_HOST, CONF_PASSWORD
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers.schema_config_entry_flow import ( from homeassistant.helpers.schema_config_entry_flow import (
@@ -21,6 +21,7 @@ from homeassistant.helpers.schema_config_entry_flow import (
SchemaOptionsFlowHandler, SchemaOptionsFlowHandler,
) )
from . import OasisMiniConfigEntry
from .const import DOMAIN from .const import DOMAIN
from .coordinator import OasisMiniCoordinator from .coordinator import OasisMiniCoordinator
from .helpers import create_client from .helpers import create_client
@@ -38,9 +39,7 @@ async def cloud_login(
handler: SchemaCommonFlowHandler, user_input: dict[str, Any] handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Cloud login.""" """Cloud login."""
coordinator: OasisMiniCoordinator = handler.parent_handler.hass.data[DOMAIN][ coordinator: OasisMiniCoordinator = handler.parent_handler.config_entry.runtime_data
handler.parent_handler.config_entry.entry_id
]
try: try:
await coordinator.device.async_cloud_login( await coordinator.device.async_cloud_login(
@@ -66,7 +65,9 @@ class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN):
@staticmethod @staticmethod
@callback @callback
def async_get_options_flow(config_entry: ConfigEntry) -> SchemaOptionsFlowHandler: def async_get_options_flow(
config_entry: OasisMiniConfigEntry,
) -> SchemaOptionsFlowHandler:
"""Get the options flow for this handler.""" """Get the options flow for this handler."""
return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW)

View File

@@ -47,15 +47,14 @@ class OasisMiniCoordinator(DataUpdateCoordinator[str]):
if not self.device.software_version: if not self.device.software_version:
await self.device.async_get_software_version() await self.device.async_get_software_version()
data = await self.device.async_get_status() data = await self.device.async_get_status()
self.attempt = 0
await self.device.async_get_current_track_details() await self.device.async_get_current_track_details()
await self.device.async_get_playlist_details() await self.device.async_get_playlist_details()
except Exception as ex: # pylint:disable=broad-except except Exception as ex: # pylint:disable=broad-except
if self.attempt > 2 or not self.data: if self.attempt > 2 or not (data or self.data):
raise UpdateFailed( raise UpdateFailed(
f"Couldn't read from the Oasis Mini after {self.attempt} attempts" f"Couldn't read from the Oasis Mini after {self.attempt} attempts"
) from ex ) from ex
else:
self.attempt = 0
if data != self.data: if data != self.data:
self.last_updated = datetime.now() self.last_updated = datetime.now()

View File

@@ -2,9 +2,6 @@
from __future__ import annotations from __future__ import annotations
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac
from homeassistant.helpers.entity import DeviceInfo, EntityDescription from homeassistant.helpers.entity import DeviceInfo, EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -13,8 +10,6 @@ from .const import DOMAIN
from .coordinator import OasisMiniCoordinator from .coordinator import OasisMiniCoordinator
from .pyoasismini import OasisMini from .pyoasismini import OasisMini
_LOGGER = logging.getLogger(__name__)
class OasisMiniEntity(CoordinatorEntity[OasisMiniCoordinator]): class OasisMiniEntity(CoordinatorEntity[OasisMiniCoordinator]):
"""Base class for Oasis Mini entities.""" """Base class for Oasis Mini entities."""
@@ -22,10 +17,7 @@ class OasisMiniEntity(CoordinatorEntity[OasisMiniCoordinator]):
_attr_has_entity_name = True _attr_has_entity_name = True
def __init__( def __init__(
self, self, coordinator: OasisMiniCoordinator, description: EntityDescription
coordinator: OasisMiniCoordinator,
entry: ConfigEntry,
description: EntityDescription,
) -> None: ) -> None:
"""Construct an Oasis Mini entity.""" """Construct an Oasis Mini entity."""
super().__init__(coordinator) super().__init__(coordinator)
@@ -37,7 +29,7 @@ class OasisMiniEntity(CoordinatorEntity[OasisMiniCoordinator]):
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, format_mac(device.mac_address))}, connections={(CONNECTION_NETWORK_MAC, format_mac(device.mac_address))},
identifiers={(DOMAIN, serial_number)}, identifiers={(DOMAIN, serial_number)},
name=entry.title, name=f"Oasis Mini {serial_number}",
manufacturer="Kinetic Oasis", manufacturer="Kinetic Oasis",
model="Oasis Mini", model="Oasis Mini",
serial_number=serial_number, serial_number=serial_number,

View File

@@ -21,7 +21,7 @@ async def add_and_play_track(device: OasisMini, track: int) -> None:
# 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))): 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

@@ -3,11 +3,11 @@
from __future__ import annotations from __future__ import annotations
from homeassistant.components.image import Image, ImageEntity, ImageEntityDescription from homeassistant.components.image import Image, ImageEntity, ImageEntityDescription
from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import UNDEFINED
from .const import DOMAIN from . import OasisMiniConfigEntry
from .coordinator import OasisMiniCoordinator from .coordinator import OasisMiniCoordinator
from .entity import OasisMiniEntity from .entity import OasisMiniEntity
from .pyoasismini.const import TRACKS from .pyoasismini.const import TRACKS
@@ -24,13 +24,10 @@ class OasisMiniImageEntity(OasisMiniEntity, ImageEntity):
_progress: int = 0 _progress: int = 0
def __init__( def __init__(
self, self, coordinator: OasisMiniCoordinator, description: ImageEntityDescription
coordinator: OasisMiniCoordinator,
entry_id: str,
description: ImageEntityDescription,
) -> None: ) -> None:
"""Initialize the entity.""" """Initialize the entity."""
super().__init__(coordinator, entry_id, description) super().__init__(coordinator, description)
ImageEntity.__init__(self, coordinator.hass) ImageEntity.__init__(self, coordinator.hass)
self._handle_coordinator_update() self._handle_coordinator_update()
@@ -42,6 +39,7 @@ class OasisMiniImageEntity(OasisMiniEntity, ImageEntity):
) )
return self._cached_image.content return self._cached_image.content
@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 self._track_id != self.device.track_id or ( if self._track_id != self.device.track_id or (
@@ -51,20 +49,26 @@ class OasisMiniImageEntity(OasisMiniEntity, ImageEntity):
self._track_id = self.device.track_id self._track_id = self.device.track_id
self._progress = self.device.progress self._progress = self.device.progress
self._cached_image = None self._cached_image = None
if not self.device.access_token: if self.device.track and self.device.track.get("svg_content"):
self._attr_image_url = UNDEFINED
else:
self._attr_image_url = ( self._attr_image_url = (
f"https://app.grounded.so/uploads/{track['image']}" f"https://app.grounded.so/uploads/{track['image']}"
if (track := TRACKS.get(str(self.device.track_id))) if (
track := (self.device.track or TRACKS.get(self.device.track_id))
)
and "image" in track and "image" in track
else None else None
) )
if self.hass: if self.hass:
super()._handle_coordinator_update() super()._handle_coordinator_update()
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant,
entry: OasisMiniConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Oasis Mini camera using config entry.""" """Set up Oasis Mini camera using config entry."""
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities([OasisMiniImageEntity(entry.runtime_data, IMAGE)])
async_add_entities([OasisMiniImageEntity(coordinator, entry, IMAGE)])

View File

@@ -14,7 +14,6 @@ from homeassistant.components.light import (
LightEntityDescription, LightEntityDescription,
LightEntityFeature, LightEntityFeature,
) )
from homeassistant.config_entries import ConfigEntry
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 homeassistant.util.color import ( from homeassistant.util.color import (
@@ -24,8 +23,7 @@ from homeassistant.util.color import (
value_to_brightness, value_to_brightness,
) )
from .const import DOMAIN from . import OasisMiniConfigEntry
from .coordinator import OasisMiniCoordinator
from .entity import OasisMiniEntity from .entity import OasisMiniEntity
from .pyoasismini import LED_EFFECTS from .pyoasismini import LED_EFFECTS
@@ -70,8 +68,10 @@ class OasisMiniLightEntity(OasisMiniEntity, LightEntity):
return self.device.brightness > 0 return self.device.brightness > 0
@property @property
def rgb_color(self) -> tuple[int, int, int]: def rgb_color(self) -> tuple[int, int, int] | None:
"""Return the rgb color value [int, int, int].""" """Return the rgb color value [int, int, int]."""
if not self.device.color:
return None
return rgb_hex_to_rgb_list(self.device.color.replace("#", "")) return rgb_hex_to_rgb_list(self.device.color.replace("#", ""))
@property @property
@@ -110,8 +110,9 @@ DESCRIPTOR = LightEntityDescription(key="led", name="LED")
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant,
entry: OasisMiniConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Oasis Mini lights using config entry.""" """Set up Oasis Mini lights using config entry."""
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities([OasisMiniLightEntity(entry.runtime_data, DESCRIPTOR)])
async_add_entities([OasisMiniLightEntity(coordinator, entry, DESCRIPTOR)])

View File

@@ -3,10 +3,10 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
import logging
from typing import Any from typing import Any
from homeassistant.components.media_player import ( from homeassistant.components.media_player import (
MediaPlayerEnqueue,
MediaPlayerEntity, MediaPlayerEntity,
MediaPlayerEntityDescription, MediaPlayerEntityDescription,
MediaPlayerEntityFeature, MediaPlayerEntityFeature,
@@ -14,19 +14,14 @@ from homeassistant.components.media_player import (
MediaType, MediaType,
RepeatMode, RepeatMode,
) )
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant 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 .const import DOMAIN from . import OasisMiniConfigEntry
from .coordinator import OasisMiniCoordinator
from .entity import OasisMiniEntity from .entity import OasisMiniEntity
from .helpers import add_and_play_track
from .pyoasismini.const import TRACKS from .pyoasismini.const import TRACKS
_LOGGER = logging.getLogger(__name__)
class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity): class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
"""Oasis Mini media player entity.""" """Oasis Mini media player entity."""
@@ -39,6 +34,7 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
| MediaPlayerEntityFeature.PREVIOUS_TRACK | MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.NEXT_TRACK | MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.PLAY_MEDIA | MediaPlayerEntityFeature.PLAY_MEDIA
| MediaPlayerEntityFeature.MEDIA_ENQUEUE
| MediaPlayerEntityFeature.CLEAR_PLAYLIST | MediaPlayerEntityFeature.CLEAR_PLAYLIST
| MediaPlayerEntityFeature.REPEAT_SET | MediaPlayerEntityFeature.REPEAT_SET
) )
@@ -59,7 +55,7 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
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): if not (track := self.device.track):
track = TRACKS.get(str(self.device.track_id)) track = TRACKS.get(self.device.track_id)
if track and "image" in track: if track and "image" in track:
return f"https://app.grounded.so/uploads/{track['image']}" return f"https://app.grounded.so/uploads/{track['image']}"
return None return None
@@ -80,7 +76,7 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
if not self.device.track_id: if not self.device.track_id:
return None return None
if not (track := self.device.track): if not (track := self.device.track):
track = TRACKS.get(str(self.device.track_id), {}) track = TRACKS.get(self.device.track_id, {})
return track.get("name", f"Unknown Title (#{self.device.track_id})") return track.get("name", f"Unknown Title (#{self.device.track_id})")
@property @property
@@ -144,10 +140,14 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
await self.coordinator.async_request_refresh() await self.coordinator.async_request_refresh()
async def async_play_media( async def async_play_media(
self, media_type: MediaType | str, media_id: str, **kwargs: Any self,
media_type: MediaType | str,
media_id: str,
enqueue: MediaPlayerEnqueue | None = None,
**kwargs: Any,
) -> None: ) -> None:
"""Play a piece of media.""" """Play a piece of media."""
if media_id not in TRACKS: if media_id not in map(str, TRACKS):
media_id = next( media_id = next(
( (
id id
@@ -157,15 +157,38 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
media_id, media_id,
) )
try: try:
media_id = int(media_id) track = int(media_id)
except ValueError as err: except ValueError as err:
raise ServiceValidationError(f"Invalid media: {media_id}") from err raise ServiceValidationError(f"Invalid media: {media_id}") from err
await add_and_play_track(self.device, media_id) device = self.device
enqueue = MediaPlayerEnqueue.NEXT if not enqueue else enqueue
if enqueue == MediaPlayerEnqueue.REPLACE:
await device.async_set_playlist([track])
else:
await device.async_add_track_to_playlist(track)
if enqueue in (MediaPlayerEnqueue.NEXT, MediaPlayerEnqueue.PLAY):
# Move track to next item in the playlist
if (index := (len(device.playlist) - 1)) != device.playlist_index:
if index != (
_next := min(device.playlist_index + 1, len(device.playlist) - 1)
):
await device.async_move_track(index, _next)
if enqueue == MediaPlayerEnqueue.PLAY:
await device.async_change_track(_next)
if (
enqueue in (MediaPlayerEnqueue.PLAY, MediaPlayerEnqueue.REPLACE)
and device.status_code != 4
):
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."""
await self.device.async_set_playlist([0]) await self.device.async_clear_playlist()
await self.coordinator.async_request_refresh() await self.coordinator.async_request_refresh()
@@ -173,8 +196,9 @@ DESCRIPTOR = MediaPlayerEntityDescription(key="oasis_mini", name=None)
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant,
entry: OasisMiniConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Oasis Mini media_players using config entry.""" """Set up Oasis Mini media_players using config entry."""
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities([OasisMiniMediaPlayerEntity(entry.runtime_data, DESCRIPTOR)])
async_add_entities([OasisMiniMediaPlayerEntity(coordinator, entry, DESCRIPTOR)])

View File

@@ -7,12 +7,10 @@ from homeassistant.components.number import (
NumberEntityDescription, NumberEntityDescription,
NumberMode, NumberMode,
) )
from homeassistant.config_entries import ConfigEntry
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 .const import DOMAIN from . import OasisMiniConfigEntry
from .coordinator import OasisMiniCoordinator
from .entity import OasisMiniEntity from .entity import OasisMiniEntity
from .pyoasismini import BALL_SPEED_MAX, BALL_SPEED_MIN, LED_SPEED_MAX, LED_SPEED_MIN from .pyoasismini import BALL_SPEED_MAX, BALL_SPEED_MIN, LED_SPEED_MAX, LED_SPEED_MIN
@@ -53,13 +51,14 @@ DESCRIPTORS = {
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant,
entry: OasisMiniConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Oasis Mini numbers using config entry.""" """Set up Oasis Mini numbers using config entry."""
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities( async_add_entities(
[ [
OasisMiniNumberEntity(coordinator, entry, descriptor) OasisMiniNumberEntity(entry.runtime_data, descriptor)
for descriptor in DESCRIPTORS for descriptor in DESCRIPTORS
] ]
) )

View File

@@ -2,11 +2,13 @@
import asyncio import asyncio
import logging import logging
from typing import Any, Awaitable, Callable, Final from typing import Any, Awaitable, Final
from urllib.parse import urljoin from urllib.parse import urljoin
from aiohttp import ClientSession from aiohttp import ClientResponseError, ClientSession
import async_timeout
from .const import TRACKS
from .utils import _bit_to_bool from .utils import _bit_to_bool
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -15,12 +17,12 @@ STATUS_CODE_MAP = {
0: "booting", # maybe? 0: "booting", # maybe?
2: "stopped", 2: "stopped",
3: "centering", 3: "centering",
4: "running", 4: "playing",
5: "paused", 5: "paused",
9: "error", 9: "error",
11: "updating", 11: "updating",
13: "downloading", 13: "downloading",
15: "live drawing", 15: "live",
} }
AUTOPLAY_MAP = { AUTOPLAY_MAP = {
@@ -31,25 +33,6 @@ AUTOPLAY_MAP = {
"4": "30 minutes", "4": "30 minutes",
} }
ATTRIBUTES: Final[list[tuple[str, Callable[[str], Any]]]] = [
("status_code", int), # see status code map
("error", int), # error, 0 = none, and 10 = ?, 18 = can't download?
("ball_speed", int), # 200 - 1000
("playlist", lambda value: [int(track) for track in value.split(",") if track]), # noqa: E501 # comma separated track ids
("playlist_index", int), # index of above
("progress", int), # 0 - max svg path
("led_effect", str), # led effect (code lookup)
("led_color_id", str), # led color id?
("led_speed", int), # -90 - 90
("brightness", int), # noqa: E501 # 0 - 200 in app, but seems to be 0 (off) to 304 (max), then repeats
("color", str), # hex color code
("busy", _bit_to_bool), # noqa: E501 # device is busy (downloading track, centering, software update)?
("download_progress", int), # 0 - 100%
("max_brightness", int),
("wifi_connected", _bit_to_bool),
("repeat_playlist", _bit_to_bool),
("autoplay", AUTOPLAY_MAP.get),
]
LED_EFFECTS: Final[dict[str, str]] = { LED_EFFECTS: Final[dict[str, str]] = {
"0": "Solid", "0": "Solid",
@@ -90,7 +73,8 @@ class OasisMini:
autoplay: str autoplay: str
brightness: int brightness: int
color: str busy: bool
color: str | None = None
download_progress: int download_progress: int
error: int error: int
led_effect: str led_effect: str
@@ -175,12 +159,15 @@ class OasisMini:
async def async_add_track_to_playlist(self, track: int) -> None: async def async_add_track_to_playlist(self, track: int) -> None:
"""Add track to playlist.""" """Add track to playlist."""
if not track:
return
if 0 in self.playlist: if 0 in self.playlist:
playlist = [t for t in self.playlist if t] + [track] playlist = [t for t in self.playlist if t] + [track]
await self.async_set_playlist(playlist) return await self.async_set_playlist(playlist)
else:
await self._async_command(params={"ADDJOBLIST": track}) await self._async_command(params={"ADDJOBLIST": track})
self.playlist.append(track) self.playlist.append(track)
async def async_change_track(self, index: int) -> None: async def async_change_track(self, index: int) -> None:
"""Change the track.""" """Change the track."""
@@ -188,6 +175,10 @@ class OasisMini:
raise ValueError("Invalid index specified") raise ValueError("Invalid index specified")
await self._async_command(params={"CMDCHANGETRACK": index}) 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: async def async_get_ip_address(self) -> str | None:
"""Get the ip address.""" """Get the ip address."""
self._ip_address = await self._async_get(params={"GETIP": ""}) self._ip_address = await self._async_get(params={"GETIP": ""})
@@ -214,14 +205,39 @@ class OasisMini:
async def async_get_status(self) -> str: async def async_get_status(self) -> str:
"""Get the status from the device.""" """Get the status from the device."""
status = await self._async_get(params={"GETSTATUS": ""}) raw_status = await self._async_get(params={"GETSTATUS": ""})
_LOGGER.debug("Status: %s", status) _LOGGER.debug("Status: %s", raw_status)
for index, value in enumerate(status.split(";")): values = raw_status.split(";")
attr, func = ATTRIBUTES[index] playlist = [int(track) for track in values[3].split(",") if track]
if (old_value := getattr(self, attr, None)) != (value := func(value)): status = {
_LOGGER.debug("%s changed: '%s' -> '%s'", attr, old_value, value) "status_code": int(values[0]), # see status code map
setattr(self, attr, value) "error": int(values[1]), # noqa: E501; error, 0 = none, and 10 = ?, 18 = can't download?
return status "ball_speed": int(values[2]), # 200 - 1000
"playlist": playlist,
"playlist_index": min(int(values[4]), len(playlist)), # index of above
"progress": 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": int(values[8]), # -90 - 90
"brightness": int(values[9]) if values[10] else 0, # noqa: E501; 0 - 200 in app, but seems to be 0 (off) to 304 (max), then repeats
"color": values[10] or None, # hex color code
"busy": _bit_to_bool(values[11]), # noqa: E501; device is busy (downloading track, centering, software update)?
"download_progress": int(values[12]),
"max_brightness": int(values[13]),
"wifi_connected": _bit_to_bool(values[14]),
"repeat_playlist": _bit_to_bool(values[15]),
"autoplay": AUTOPLAY_MAP.get(values[16]),
}
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: async def async_move_track(self, _from: int, _to: int) -> None:
"""Move a track in the playlist.""" """Move a track in the playlist."""
@@ -235,7 +251,8 @@ class OasisMini:
"""Send play command.""" """Send play command."""
if self.status_code == 15: if self.status_code == 15:
await self.async_stop() await self.async_stop()
await self._async_command(params={"CMDPLAY": ""}) if self.track_id:
await self._async_command(params={"CMDPLAY": ""})
async def async_reboot(self) -> None: async def async_reboot(self) -> None:
"""Send reboot command.""" """Send reboot command."""
@@ -268,7 +285,7 @@ class OasisMini:
if led_effect is None: if led_effect is None:
led_effect = self.led_effect led_effect = self.led_effect
if color is None: if color is None:
color = self.color color = self.color or "#ffffff"
if led_speed is None: if led_speed is None:
led_speed = self.led_speed led_speed = self.led_speed
if brightness is None: if brightness is None:
@@ -294,9 +311,13 @@ class OasisMini:
await self._async_command(params={"WRIWAITAFTER": option}) await self._async_command(params={"WRIWAITAFTER": option})
async def async_set_playlist(self, playlist: list[int]) -> None: async def async_set_playlist(self, playlist: list[int]) -> None:
"""Set playlist.""" """Set the playlist."""
if is_playing := (self.status_code == 4):
await self.async_stop()
await self._async_command(params={"WRIJOBLIST": ",".join(map(str, playlist))}) await self._async_command(params={"WRIJOBLIST": ",".join(map(str, playlist))})
self.playlist = playlist self.playlist = playlist
if is_playing:
await self.async_play()
async def async_set_repeat_playlist(self, repeat: bool) -> None: async def async_set_repeat_playlist(self, repeat: bool) -> None:
"""Set repeat playlist.""" """Set repeat playlist."""
@@ -327,9 +348,12 @@ class OasisMini:
"""Get cloud track info.""" """Get cloud track info."""
try: try:
return await self._async_cloud_request("GET", f"api/track/{track_id}") 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: except Exception as ex:
_LOGGER.exception(ex) _LOGGER.exception(ex)
return None return None
async def async_cloud_get_tracks( async def async_cloud_get_tracks(
self, tracks: list[int] | None = None self, tracks: list[int] | None = None
@@ -338,6 +362,8 @@ class OasisMini:
response = await self._async_cloud_request( response = await self._async_cloud_request(
"GET", "api/track", params={"ids[]": tracks or []} "GET", "api/track", params={"ids[]": tracks or []}
) )
if not response:
return None
track_details = response.get("data", []) track_details = response.get("data", [])
while next_page_url := response.get("next_page_url"): while next_page_url := response.get("next_page_url"):
response = await self._async_cloud_request("GET", next_page_url) response = await self._async_cloud_request("GET", next_page_url)
@@ -350,17 +376,22 @@ class OasisMini:
async def async_get_current_track_details(self) -> dict | None: async def async_get_current_track_details(self) -> dict | None:
"""Get current track info, refreshing if needed.""" """Get current track info, refreshing if needed."""
if (track := self._track) and track.get("id") == self.track_id: track_id = self.track_id
if (track := self._track) and track.get("id") == track_id:
return track return track
if self.track_id: if track_id:
self._track = await self.async_cloud_get_track_info(self.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 return self._track
async def async_get_playlist_details(self) -> dict[int, dict[str, str]]: async def async_get_playlist_details(self) -> dict[int, dict[str, str]]:
"""Get playlist info.""" """Get playlist info."""
if set(self.playlist).difference(self._playlist.keys()): if set(self.playlist).difference(self._playlist.keys()):
tracks = await self.async_cloud_get_tracks(self.playlist) tracks = await self.async_cloud_get_tracks(self.playlist)
self._playlist = { all_tracks = TRACKS | {
track["id"]: { track["id"]: {
"name": track["name"], "name": track["name"],
"author": ((track.get("author") or {}).get("person") or {}).get( "author": ((track.get("author") or {}).get("person") or {}).get(
@@ -370,6 +401,10 @@ class OasisMini:
} }
for track in tracks for track in tracks
} }
for track in self.playlist:
self._playlist[track] = all_tracks.get(
track, {"name": f"Unknown Title (#{track})"}
)
return self._playlist return self._playlist
async def _async_cloud_request(self, method: str, url: str, **kwargs: Any) -> Any: async def _async_cloud_request(self, method: str, url: str, **kwargs: Any) -> Any:
@@ -395,6 +430,13 @@ class OasisMini:
async def _async_request(self, method: str, url: str, **kwargs) -> Any: async def _async_request(self, method: str, url: str, **kwargs) -> Any:
"""Perform a request.""" """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) response = await self._session.request(method, url, **kwargs)
if response.status == 200: if response.status == 200:
if response.content_type == "application/json": if response.content_type == "application/json":

View File

@@ -4,8 +4,13 @@ from __future__ import annotations
import json import json
import os import os
from typing import Final from typing import Any, Final
__TRACKS_FILE = os.path.join(os.path.dirname(__file__), "tracks.json") __TRACKS_FILE = os.path.join(os.path.dirname(__file__), "tracks.json")
with open(__TRACKS_FILE, "r", encoding="utf8") as file: try:
TRACKS: Final[dict[str, dict[str, str]]] = json.load(file) 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 = {}

File diff suppressed because it is too large Load Diff

View File

@@ -56,7 +56,7 @@ def draw_svg(track: dict, progress: int, model_id: str) -> str | None:
path.progress_arc_complete {{ stroke: {COLOR_DARK[1]}; }} path.progress_arc_complete {{ stroke: {COLOR_DARK[1]}; }}
path.track {{ stroke: {COLOR_LIGHT_SHADE[1]}; }} path.track {{ stroke: {COLOR_LIGHT_SHADE[1]}; }}
path.track_complete {{ stroke: {COLOR_MEDIUM_TINT[1]}; }} path.track_complete {{ stroke: {COLOR_MEDIUM_TINT[1]}; }}
}}""" }}""".replace("\n", " ").strip()
group = SubElement( group = SubElement(
svg, svg,

View File

@@ -6,12 +6,11 @@ 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.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
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 .const import DOMAIN from . import OasisMiniConfigEntry
from .coordinator import OasisMiniCoordinator from .coordinator import OasisMiniCoordinator
from .entity import OasisMiniEntity from .entity import OasisMiniEntity
from .pyoasismini import AUTOPLAY_MAP, OasisMini from .pyoasismini import AUTOPLAY_MAP, OasisMini
@@ -36,11 +35,10 @@ class OasisMiniSelectEntity(OasisMiniEntity, SelectEntity):
def __init__( def __init__(
self, self,
coordinator: OasisMiniCoordinator, coordinator: OasisMiniCoordinator,
entry: ConfigEntry[Any],
description: EntityDescription, description: EntityDescription,
) -> None: ) -> None:
"""Construct an Oasis Mini select entity.""" """Construct an Oasis Mini select entity."""
super().__init__(coordinator, entry, description) super().__init__(coordinator, 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:
@@ -48,6 +46,7 @@ class OasisMiniSelectEntity(OasisMiniEntity, SelectEntity):
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() await self.coordinator.async_request_refresh()
@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."""
new_value = self.entity_description.current_value(self.device) new_value = self.entity_description.current_value(self.device)
@@ -71,7 +70,7 @@ def playlist_update_handler(entity: OasisMiniSelectEntity) -> None:
options = [ options = [
device._playlist.get(track, {}).get( device._playlist.get(track, {}).get(
"name", "name",
TRACKS.get(str(track), {}).get( TRACKS.get(track, {"id": track, "name": f"Unknown Title (#{track})"}).get(
"name", "name",
device.track["name"] device.track["name"]
if device.track and device.track["id"] == track if device.track and device.track["id"] == track
@@ -89,7 +88,7 @@ DESCRIPTORS = (
OasisMiniSelectEntityDescription( OasisMiniSelectEntityDescription(
key="playlist", key="playlist",
name="Playlist", name="Playlist",
current_value=lambda device: (device.playlist, device.playlist_index), current_value=lambda device: (device.playlist.copy(), device.playlist_index),
select_fn=lambda device, option: device.async_change_track(option), select_fn=lambda device, option: device.async_change_track(option),
update_handler=playlist_update_handler, update_handler=playlist_update_handler,
), ),
@@ -104,13 +103,14 @@ DESCRIPTORS = (
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant,
entry: OasisMiniConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Oasis Mini select using config entry.""" """Set up Oasis Mini select using config entry."""
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities( async_add_entities(
[ [
OasisMiniSelectEntity(coordinator, entry, descriptor) OasisMiniSelectEntity(entry.runtime_data, descriptor)
for descriptor in DESCRIPTORS for descriptor in DESCRIPTORS
] ]
) )

View File

@@ -7,29 +7,29 @@ from homeassistant.components.sensor import (
SensorEntityDescription, SensorEntityDescription,
SensorStateClass, SensorStateClass,
) )
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, EntityCategory 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 .const import DOMAIN from . import OasisMiniConfigEntry
from .coordinator import OasisMiniCoordinator from .coordinator import OasisMiniCoordinator
from .entity import OasisMiniEntity from .entity import OasisMiniEntity
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant,
entry: OasisMiniConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Oasis Mini sensors using config entry.""" """Set up Oasis Mini sensors using config entry."""
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id] coordinator: OasisMiniCoordinator = entry.runtime_data
entities = [ entities = [
OasisMiniSensorEntity(coordinator, entry, descriptor) OasisMiniSensorEntity(coordinator, descriptor) for descriptor in DESCRIPTORS
for descriptor in DESCRIPTORS
] ]
if coordinator.device.access_token: if coordinator.device.access_token:
entities.extend( entities.extend(
[ [
OasisMiniSensorEntity(coordinator, entry, descriptor) OasisMiniSensorEntity(coordinator, descriptor)
for descriptor in CLOUD_DESCRIPTORS for descriptor in CLOUD_DESCRIPTORS
] ]
) )
@@ -49,6 +49,7 @@ DESCRIPTORS = {
SensorEntityDescription( SensorEntityDescription(
key=key, key=key,
name=key.replace("_", " ").capitalize(), name=key.replace("_", " ").capitalize(),
translation_key=key,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
) )

View File

@@ -26,7 +26,7 @@
"options": { "options": {
"step": { "step": {
"init": { "init": {
"description": "Add your cloud credentials to get additional information about your Oasis Mini", "description": "Add your cloud credentials to get additional information about your device",
"data": { "data": {
"email": "[%key:common::config_flow::data::email%]", "email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]" "password": "[%key:common::config_flow::data::password%]"
@@ -36,5 +36,23 @@
"error": { "error": {
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
} }
},
"entity": {
"sensor": {
"status": {
"name": "Status",
"state": {
"booting": "Booting",
"stopped": "Stopped",
"centering": "Centering",
"playing": "Playing",
"paused": "Paused",
"error": "Error",
"updating": "Updating",
"downloading": "Downloading",
"live": "Live drawing"
}
}
}
} }
} }

View File

@@ -5,23 +5,22 @@
# from typing import Any # from typing import Any
# from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription # from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
# from homeassistant.config_entries import ConfigEntry
# 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 .const import DOMAIN # from . import OasisMiniConfigEntry
# from .coordinator import OasisMiniCoordinator
# from .entity import OasisMiniEntity # from .entity import OasisMiniEntity
# async def async_setup_entry( # async def async_setup_entry(
# hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback # hass: HomeAssistant,
# entry: OasisMiniConfigEntry,
# async_add_entities: AddEntitiesCallback,
# ) -> None: # ) -> None:
# """Set up Oasis Mini switchs using config entry.""" # """Set up Oasis Mini switchs using config entry."""
# coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id]
# async_add_entities( # async_add_entities(
# [ # [
# OasisMiniSwitchEntity(coordinator, entry, descriptor) # OasisMiniSwitchEntity(entry.runtime_data, descriptor)
# for descriptor in DESCRIPTORS # for descriptor in DESCRIPTORS
# ] # ]
# ) # )

View File

@@ -26,7 +26,7 @@
"options": { "options": {
"step": { "step": {
"init": { "init": {
"description": "Add your cloud credentials to get additional information about your Oasis Mini", "description": "Add your cloud credentials to get additional information about your device",
"data": { "data": {
"email": "Email", "email": "Email",
"password": "Password" "password": "Password"
@@ -36,5 +36,23 @@
"error": { "error": {
"invalid_auth": "Invalid authentication" "invalid_auth": "Invalid authentication"
} }
},
"entity": {
"sensor": {
"status": {
"name": "Status",
"state": {
"booting": "Booting",
"stopped": "Stopped",
"centering": "Centering",
"playing": "Playing",
"paused": "Paused",
"error": "Error",
"updating": "Updating",
"downloading": "Downloading",
"live": "Live drawing"
}
}
}
} }
} }

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import timedelta from datetime import timedelta
import logging
from typing import Any from typing import Any
from homeassistant.components.update import ( from homeassistant.components.update import (
@@ -11,26 +12,27 @@ from homeassistant.components.update import (
UpdateEntityDescription, UpdateEntityDescription,
UpdateEntityFeature, UpdateEntityFeature,
) )
from homeassistant.config_entries import ConfigEntry
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 .const import DOMAIN from . import OasisMiniConfigEntry
from .coordinator import OasisMiniCoordinator from .coordinator import OasisMiniCoordinator
from .entity import OasisMiniEntity from .entity import OasisMiniEntity
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(hours=6) SCAN_INTERVAL = timedelta(hours=6)
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant,
entry: OasisMiniConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Oasis Mini updates using config entry.""" """Set up Oasis Mini updates using config entry."""
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id] coordinator: OasisMiniCoordinator = entry.runtime_data
if coordinator.device.access_token: if coordinator.device.access_token:
async_add_entities( async_add_entities([OasisMiniUpdateEntity(coordinator, DESCRIPTOR)], True)
[OasisMiniUpdateEntity(coordinator, entry, DESCRIPTOR)], True
)
DESCRIPTOR = UpdateEntityDescription( DESCRIPTOR = UpdateEntityDescription(
@@ -75,6 +77,9 @@ class OasisMiniUpdateEntity(OasisMiniEntity, UpdateEntity):
"""Update the entity.""" """Update the entity."""
await self.device.async_get_software_version() await self.device.async_get_software_version()
software = await self.device.async_cloud_get_latest_software_details() software = await self.device.async_cloud_get_latest_software_details()
if not software:
_LOGGER.warning("Unable to get latest software details")
return
self._attr_latest_version = software["version"] self._attr_latest_version = software["version"]
self._attr_release_summary = software["description"] self._attr_release_summary = software["description"]
self._attr_release_url = f"https://app.grounded.so/software/{software['id']}" self._attr_release_url = f"https://app.grounded.so/software/{software['id']}"

69
update_tracks.py Normal file
View File

@@ -0,0 +1,69 @@
"""Script to update track details from Grounded Labs."""
from __future__ import annotations
import asyncio
import json
import os
from typing import Any
from custom_components.oasis_mini.pyoasismini import OasisMini
from custom_components.oasis_mini.pyoasismini.const import TRACKS
ACCESS_TOKEN = os.getenv("GROUNDED_TOKEN")
async def update_tracks() -> None:
"""Update tracks."""
client = OasisMini("", ACCESS_TOKEN)
try:
data = await client.async_cloud_get_tracks()
except Exception as ex:
print(type(ex).__name__, ex)
await client.session.close()
return
if not isinstance(data, list):
print("Unexpected result:", data)
return
updated_tracks: dict[int, dict[str, Any]] = {}
for result in filter(lambda d: d["public"], data):
if (
(track_id := result["id"]) not in TRACKS
or result["name"] != TRACKS[track_id].get("name")
or result["image"] != TRACKS[track_id].get("image")
):
print(f"Updating track {track_id}: {result["name"]}")
track_info = await client.async_cloud_get_track_info(int(track_id))
if not track_info:
print("No track info")
break
author = (result.get("author") or {}).get("user") or {}
updated_tracks[track_id] = {
"id": track_id,
"name": result["name"],
"author": author.get("name") or author.get("nickname") or "Oasis Mini",
"image": result["image"],
"clean_pattern": track_info.get("cleanPattern", {}).get("id"),
"reduced_svg_content": track_info.get("reduced_svg_content"),
}
await client.session.close()
if not updated_tracks:
print("No updated tracks")
return
tracks = {k: v for k, v in TRACKS.items() if k in map(lambda d: d["id"], data)}
tracks.update(updated_tracks)
tracks = dict(sorted(tracks.items(), key=lambda t: t[1]["name"].lower()))
with open(
"custom_components/oasis_mini/pyoasismini/tracks.json", "w", encoding="utf8"
) as file:
json.dump(tracks, file, indent=2, ensure_ascii=False)
if __name__ == "__main__":
asyncio.run(update_tracks())