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

34 Commits
0.7.1 ... 0.7.8

Author SHA1 Message Date
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 3260 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)
[![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>
<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 .helpers import create_client
type OasisMiniConfigEntry = ConfigEntry[OasisMiniCoordinator]
_LOGGER = logging.getLogger(__name__)
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."""
hass.data.setdefault(DOMAIN, {})
client = create_client(entry.data | entry.options)
coordinator = OasisMiniCoordinator(hass, client)
@@ -62,7 +63,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await client.session.close()
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)
@@ -70,15 +71,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
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."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
await hass.data[DOMAIN][entry.entry_id].device.session.close()
del hass.data[DOMAIN][entry.entry_id]
return unload_ok
await entry.runtime_data.device.session.close()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
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."""
if 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()
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
async def update_listener(hass: HomeAssistant, entry: OasisMiniConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)

View File

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

View File

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

View File

@@ -47,15 +47,14 @@ class OasisMiniCoordinator(DataUpdateCoordinator[str]):
if not self.device.software_version:
await self.device.async_get_software_version()
data = await self.device.async_get_status()
self.attempt = 0
await self.device.async_get_current_track_details()
await self.device.async_get_playlist_details()
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(
f"Couldn't read from the Oasis Mini after {self.attempt} attempts"
) from ex
else:
self.attempt = 0
if data != self.data:
self.last_updated = datetime.now()

View File

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

View File

@@ -3,11 +3,11 @@
from __future__ import annotations
from homeassistant.components.image import Image, ImageEntity, ImageEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
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 .entity import OasisMiniEntity
from .pyoasismini.const import TRACKS
@@ -24,13 +24,10 @@ class OasisMiniImageEntity(OasisMiniEntity, ImageEntity):
_progress: int = 0
def __init__(
self,
coordinator: OasisMiniCoordinator,
entry_id: str,
description: ImageEntityDescription,
self, coordinator: OasisMiniCoordinator, description: ImageEntityDescription
) -> None:
"""Initialize the entity."""
super().__init__(coordinator, entry_id, description)
super().__init__(coordinator, description)
ImageEntity.__init__(self, coordinator.hass)
self._handle_coordinator_update()
@@ -42,6 +39,7 @@ class OasisMiniImageEntity(OasisMiniEntity, ImageEntity):
)
return self._cached_image.content
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
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._progress = self.device.progress
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 = (
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
else None
)
if self.hass:
super()._handle_coordinator_update()
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
hass: HomeAssistant,
entry: OasisMiniConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Oasis Mini camera using config entry."""
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities([OasisMiniImageEntity(coordinator, entry, IMAGE)])
async_add_entities([OasisMiniImageEntity(entry.runtime_data, IMAGE)])

View File

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

View File

@@ -3,10 +3,10 @@
from __future__ import annotations
from datetime import datetime
import logging
from typing import Any
from homeassistant.components.media_player import (
MediaPlayerEnqueue,
MediaPlayerEntity,
MediaPlayerEntityDescription,
MediaPlayerEntityFeature,
@@ -14,19 +14,14 @@ from homeassistant.components.media_player import (
MediaType,
RepeatMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .coordinator import OasisMiniCoordinator
from . import OasisMiniConfigEntry
from .entity import OasisMiniEntity
from .helpers import add_and_play_track
from .pyoasismini.const import TRACKS
_LOGGER = logging.getLogger(__name__)
class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
"""Oasis Mini media player entity."""
@@ -39,6 +34,7 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
| MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.PLAY_MEDIA
| MediaPlayerEntityFeature.MEDIA_ENQUEUE
| MediaPlayerEntityFeature.CLEAR_PLAYLIST
| MediaPlayerEntityFeature.REPEAT_SET
)
@@ -59,7 +55,7 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
def media_image_url(self) -> str | None:
"""Image url of current playing media."""
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:
return f"https://app.grounded.so/uploads/{track['image']}"
return None
@@ -80,7 +76,7 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
if not self.device.track_id:
return None
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})")
@property
@@ -144,10 +140,14 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
await self.coordinator.async_request_refresh()
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:
"""Play a piece of media."""
if media_id not in TRACKS:
if media_id not in map(str, TRACKS):
media_id = next(
(
id
@@ -157,15 +157,38 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
media_id,
)
try:
media_id = int(media_id)
track = int(media_id)
except ValueError as 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:
"""Clear players playlist."""
await self.device.async_set_playlist([0])
await self.device.async_clear_playlist()
await self.coordinator.async_request_refresh()
@@ -173,8 +196,9 @@ DESCRIPTOR = MediaPlayerEntityDescription(key="oasis_mini", name=None)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
hass: HomeAssistant,
entry: OasisMiniConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Oasis Mini media_players using config entry."""
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities([OasisMiniMediaPlayerEntity(coordinator, entry, DESCRIPTOR)])
async_add_entities([OasisMiniMediaPlayerEntity(entry.runtime_data, DESCRIPTOR)])

View File

@@ -7,12 +7,10 @@ from homeassistant.components.number import (
NumberEntityDescription,
NumberMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .coordinator import OasisMiniCoordinator
from . import OasisMiniConfigEntry
from .entity import OasisMiniEntity
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(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
hass: HomeAssistant,
entry: OasisMiniConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Oasis Mini numbers using config entry."""
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
[
OasisMiniNumberEntity(coordinator, entry, descriptor)
OasisMiniNumberEntity(entry.runtime_data, descriptor)
for descriptor in DESCRIPTORS
]
)

View File

@@ -2,11 +2,13 @@
import asyncio
import logging
from typing import Any, Awaitable, Callable, Final
from typing import Any, Awaitable, Final
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
_LOGGER = logging.getLogger(__name__)
@@ -15,12 +17,12 @@ STATUS_CODE_MAP = {
0: "booting", # maybe?
2: "stopped",
3: "centering",
4: "running",
4: "playing",
5: "paused",
9: "error",
11: "updating",
13: "downloading",
15: "live drawing",
15: "live",
}
AUTOPLAY_MAP = {
@@ -31,25 +33,6 @@ AUTOPLAY_MAP = {
"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]] = {
"0": "Solid",
@@ -90,7 +73,8 @@ class OasisMini:
autoplay: str
brightness: int
color: str
busy: bool
color: str | None = None
download_progress: int
error: int
led_effect: str
@@ -175,12 +159,15 @@ class OasisMini:
async def async_add_track_to_playlist(self, track: int) -> None:
"""Add track to playlist."""
if not track:
return
if 0 in self.playlist:
playlist = [t for t in self.playlist if t] + [track]
await self.async_set_playlist(playlist)
else:
await self._async_command(params={"ADDJOBLIST": track})
self.playlist.append(track)
return await self.async_set_playlist(playlist)
await self._async_command(params={"ADDJOBLIST": track})
self.playlist.append(track)
async def async_change_track(self, index: int) -> None:
"""Change the track."""
@@ -188,6 +175,10 @@ class OasisMini:
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": ""})
@@ -214,14 +205,39 @@ class OasisMini:
async def async_get_status(self) -> str:
"""Get the status from the device."""
status = await self._async_get(params={"GETSTATUS": ""})
_LOGGER.debug("Status: %s", status)
for index, value in enumerate(status.split(";")):
attr, func = ATTRIBUTES[index]
if (old_value := getattr(self, attr, None)) != (value := func(value)):
_LOGGER.debug("%s changed: '%s' -> '%s'", attr, old_value, value)
setattr(self, attr, value)
return status
raw_status = await self._async_get(params={"GETSTATUS": ""})
_LOGGER.debug("Status: %s", raw_status)
values = raw_status.split(";")
playlist = [int(track) for track in values[3].split(",") if track]
status = {
"status_code": int(values[0]), # see status code map
"error": int(values[1]), # noqa: E501; error, 0 = none, and 10 = ?, 18 = can't download?
"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:
"""Move a track in the playlist."""
@@ -235,7 +251,8 @@ class OasisMini:
"""Send play command."""
if self.status_code == 15:
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:
"""Send reboot command."""
@@ -268,7 +285,7 @@ class OasisMini:
if led_effect is None:
led_effect = self.led_effect
if color is None:
color = self.color
color = self.color or "#ffffff"
if led_speed is None:
led_speed = self.led_speed
if brightness is None:
@@ -294,9 +311,13 @@ class OasisMini:
await self._async_command(params={"WRIWAITAFTER": option})
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))})
self.playlist = playlist
if is_playing:
await self.async_play()
async def async_set_repeat_playlist(self, repeat: bool) -> None:
"""Set repeat playlist."""
@@ -327,9 +348,12 @@ class OasisMini:
"""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
return None
async def async_cloud_get_tracks(
self, tracks: list[int] | None = None
@@ -338,6 +362,8 @@ class OasisMini:
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)
@@ -350,17 +376,22 @@ class OasisMini:
async def async_get_current_track_details(self) -> dict | None:
"""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
if self.track_id:
self._track = await self.async_cloud_get_track_info(self.track_id)
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)
self._playlist = {
all_tracks = TRACKS | {
track["id"]: {
"name": track["name"],
"author": ((track.get("author") or {}).get("person") or {}).get(
@@ -370,6 +401,10 @@ class OasisMini:
}
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:
@@ -395,6 +430,13 @@ class OasisMini:
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":

View File

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

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.track {{ stroke: {COLOR_LIGHT_SHADE[1]}; }}
path.track_complete {{ stroke: {COLOR_MEDIUM_TINT[1]}; }}
}}"""
}}""".replace("\n", " ").strip()
group = SubElement(
svg,

View File

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

View File

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

View File

@@ -26,7 +26,7 @@
"options": {
"step": {
"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": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
@@ -36,5 +36,23 @@
"error": {
"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 homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
# from homeassistant.config_entries import ConfigEntry
# from homeassistant.core import HomeAssistant
# from homeassistant.helpers.entity_platform import AddEntitiesCallback
# from .const import DOMAIN
# from .coordinator import OasisMiniCoordinator
# from . import OasisMiniConfigEntry
# from .entity import OasisMiniEntity
# async def async_setup_entry(
# hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
# hass: HomeAssistant,
# entry: OasisMiniConfigEntry,
# async_add_entities: AddEntitiesCallback,
# ) -> None:
# """Set up Oasis Mini switchs using config entry."""
# coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id]
# async_add_entities(
# [
# OasisMiniSwitchEntity(coordinator, entry, descriptor)
# OasisMiniSwitchEntity(entry.runtime_data, descriptor)
# for descriptor in DESCRIPTORS
# ]
# )

View File

@@ -26,7 +26,7 @@
"options": {
"step": {
"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": {
"email": "Email",
"password": "Password"
@@ -36,5 +36,23 @@
"error": {
"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 datetime import timedelta
import logging
from typing import Any
from homeassistant.components.update import (
@@ -11,26 +12,27 @@ from homeassistant.components.update import (
UpdateEntityDescription,
UpdateEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from . import OasisMiniConfigEntry
from .coordinator import OasisMiniCoordinator
from .entity import OasisMiniEntity
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(hours=6)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
hass: HomeAssistant,
entry: OasisMiniConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""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:
async_add_entities(
[OasisMiniUpdateEntity(coordinator, entry, DESCRIPTOR)], True
)
async_add_entities([OasisMiniUpdateEntity(coordinator, DESCRIPTOR)], True)
DESCRIPTOR = UpdateEntityDescription(
@@ -75,6 +77,9 @@ class OasisMiniUpdateEntity(OasisMiniEntity, UpdateEntity):
"""Update the entity."""
await self.device.async_get_software_version()
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_release_summary = software["description"]
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())