1
0
mirror of https://github.com/natekspencer/hacs-oasis_mini.git synced 2025-11-14 08:03:52 -05:00

84 Commits
0.2.0 ... 1.0.3

Author SHA1 Message Date
Nathan Spencer
f67aee166a Merge pull request #54 from natekspencer/update-tracks
Update tracks
2025-02-02 12:15:58 -07:00
natekspencer
4ed6b1701d Update tracks 2025-02-02 19:15:01 +00:00
Nathan Spencer
ade3e7c666 Merge pull request #53 from natekspencer/update-tracks
Update tracks
2025-01-30 12:16:24 -07:00
natekspencer
4c112f2b06 Update tracks 2025-01-30 19:14:50 +00:00
Nathan Spencer
f850158a8e Merge pull request #52 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 / Search and update new tracks (push) Has been cancelled
Update tracks
2025-01-14 12:20:10 -07:00
natekspencer
8bb8cf9447 Update tracks 2025-01-14 19:15:13 +00:00
Nathan Spencer
1c8b2f052c Merge pull request #51 from natekspencer/image-update
Some checks failed
Validate repo / Validate with hassfest (push) Has been cancelled
Validate repo / Validate with HACS (push) Has been cancelled
Don't update image unless playing or image hasn't been cached yet
2025-01-10 15:20:41 -07:00
Nathan Spencer
73f96d8302 Don't update image unless playing or image hasn't been cached yet 2025-01-10 22:17:52 +00:00
Nathan Spencer
9cc1d6d314 Merge pull request #50 from natekspencer/binary-sensors
Switch busy and wifi_connected sensors to binary sensors
2025-01-10 15:17:02 -07:00
Nathan Spencer
4894e3549d Switch busy and wifi_connected sensors to binary sensors 2025-01-10 22:15:53 +00:00
Nathan Spencer
221f314dd6 Merge pull request #49 from natekspencer/translations
Update translations and add icons.json file
2025-01-10 15:14:26 -07:00
Nathan Spencer
595621652a Update translations and add icons.json file 2025-01-10 22:02:29 +00:00
Nathan Spencer
42040895e2 Merge pull request #48 from natekspencer/dev
Adjust media player to allow adding multiple tracks at a time
2025-01-10 14:49:38 -07:00
Nathan Spencer
51c4c8a6a2 Adjust media player to allow adding multiple tracks at a time 2025-01-10 21:48:31 +00:00
Nathan Spencer
ddabccc4a8 Merge pull request #47 from natekspencer/dev
Update dev environment
2025-01-10 12:31:31 -07:00
Nathan Spencer
94860106ea Update dev environment 2025-01-10 19:28:38 +00:00
Nathan Spencer
c4dd4f0499 Merge pull request #44 from natekspencer/svg-content
Some checks failed
Validate repo / Validate with hassfest (push) Has been cancelled
Validate repo / Validate with HACS (push) Has been cancelled
Fix parsing svg content
2024-12-27 17:14:25 -07:00
Nathan Spencer
2a5043298e Fix parsing svg content 2024-12-28 00:12:23 +00:00
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
Nathan Spencer
423e7eba9f Merge pull request #12 from natekspencer/dev
Handle unknown track ids
2024-07-31 00:10:58 -06:00
Nathan Spencer
d70dd0a650 Handle unknown track ids 2024-07-31 00:09:21 -06:00
Nathan Spencer
cee752b6ce Merge pull request #11 from natekspencer/dev
Add additional features
2024-07-30 23:50:08 -06:00
Nathan Spencer
3b90603bef Add additional features 2024-07-30 23:47:14 -06:00
Nathan Spencer
e77804ec0d Merge pull request #10 from natekspencer/dev
Handle IP update from DHCP and add drawing progress sensor
2024-07-25 10:55:44 -06:00
Nathan Spencer
96edafd006 Handle IP update from DHCP and add drawing progress sensor 2024-07-25 10:52:47 -06:00
Nathan Spencer
71180f68f9 Merge pull request #9 from natekspencer/dev
Updates to handle firmware version 0.71 and other improvements
2024-07-18 13:05:28 -06:00
Nathan Spencer
0d539888e5 Updates to handle firmware version 0.71 and other improvements 2024-07-18 13:03:19 -06:00
Nathan Spencer
4186755a92 Merge pull request #8 from natekspencer/dev
Add update entity
2024-07-17 09:47:02 -06:00
Nathan Spencer
7c8ca361ba Add update entity 2024-07-17 09:44:32 -06:00
Nathan Spencer
07446f56da Merge pull request #7 from natekspencer/dev
Handle options update in select entity
2024-07-11 14:30:45 -06:00
Nathan Spencer
bd5b2e876d Handle options update in select entity 2024-07-11 14:28:54 -06:00
Nathan Spencer
36da0249b7 Merge pull request #6 from natekspencer/dev
Code cleanup
2024-07-11 12:37:40 -06:00
Nathan Spencer
bcc8547e3e Code cleanup 2024-07-11 11:55:02 -06:00
Nathan Spencer
e678b20990 Merge pull request #5 from natekspencer/dev
Add ability to reconfigure integration to handle updated host ip
2024-07-11 11:54:12 -06:00
Nathan Spencer
cda435070d Add ability to reconfigure integration to handle updated host ip 2024-07-11 11:51:23 -06:00
Nathan Spencer
9b85d939c4 Merge pull request #4 from natekspencer/dev
Set HACS minimum HA version to 2024.4.0
2024-07-11 11:43:30 -06:00
Nathan Spencer
4eb86c5541 Set minimum HA version to 2024.4.0 2024-07-11 11:41:37 -06:00
Nathan Spencer
e35ae0d4fa Merge pull request #3 from natekspencer/dev
Add button to play a random track
2024-07-11 11:03:39 -06:00
Nathan Spencer
21105e497a Add button to play a random track 2024-07-11 11:00:01 -06:00
33 changed files with 5565 additions and 717 deletions

View File

@@ -1,9 +1,9 @@
// See https://aka.ms/vscode-remote/devcontainer.json for format details. // See https://aka.ms/vscode-remote/devcontainer.json for format details.
{ {
"name": "Home Assistant integration development", "name": "Home Assistant integration development",
"image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye", "image": "mcr.microsoft.com/devcontainers/python:1-3.13-bullseye",
"postCreateCommand": "sudo apt-get update && sudo apt-get install libturbojpeg0", "postCreateCommand": "sudo apt-get update && sudo apt-get install libturbojpeg0 libpcap0.8 -y",
"postAttachCommand": ".devcontainer/setup", "postAttachCommand": "scripts/setup",
"forwardPorts": [8123], "forwardPorts": [8123],
"customizations": { "customizations": {
"vscode": { "vscode": {

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.13
uses: actions/setup-python@v5
with:
python-version: "3.13"
- 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">
@@ -53,6 +56,20 @@ Alternatively:
After this integration is set up, you can configure the integration to connect to the Kinetic Oasis cloud API. This will allow pulling in certain details (such as track name and image) that are otherwise not available. After this integration is set up, you can configure the integration to connect to the Kinetic Oasis cloud API. This will allow pulling in certain details (such as track name and image) that are otherwise not available.
# Actions
The media player entity supports various actions, including managing the playlist queue. You can specify a track by its ID or name. If using a track name, it must match an entry in the [tracks list](custom_components/oasis_mini/pyoasismini/tracks.json). To specify multiple tracks, separate them with commas. An example is below:
```yaml
action: media_player.play_media
target:
entity_id: media_player.oasis_mini
data:
media_content_id: 63, Turtle
media_content_type: track
enqueue: replace
```
--- ---
## Support Me ## Support Me

View File

@@ -4,6 +4,7 @@ automation:
dhcp: dhcp:
frontend: frontend:
history: history:
isal:
logbook: logbook:
media_source: media_source:

View File

@@ -7,15 +7,19 @@ import logging
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
import homeassistant.helpers.device_registry as dr
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
type OasisMiniConfigEntry = ConfigEntry[OasisMiniCoordinator]
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
PLATFORMS = [ PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON, Platform.BUTTON,
Platform.IMAGE, Platform.IMAGE,
Platform.LIGHT, Platform.LIGHT,
@@ -23,13 +27,13 @@ PLATFORMS = [
Platform.NUMBER, Platform.NUMBER,
Platform.SELECT, Platform.SELECT,
Platform.SENSOR, Platform.SENSOR,
Platform.SWITCH, # Platform.SWITCH,
Platform.UPDATE,
] ]
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)
@@ -38,11 +42,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except Exception as ex: except Exception as ex:
_LOGGER.exception(ex) _LOGGER.exception(ex)
if not entry.unique_id:
if not (serial_number := coordinator.device.serial_number):
dev_reg = dr.async_get(hass)
devices = dr.async_entries_for_config_entry(dev_reg, entry.entry_id)
serial_number = next(
(
identifier[1]
for identifier in devices[0].identifiers
if identifier[0] == DOMAIN
),
None,
)
hass.config_entries.async_update_entry(entry, unique_id=serial_number)
if not coordinator.data: if not coordinator.data:
await client.session.close() await client.session.close()
raise ConfigEntryNotReady raise ConfigEntryNotReady
hass.data[DOMAIN][entry.entry_id] = coordinator if entry.unique_id != coordinator.device.serial_number:
await client.session.close()
raise ConfigEntryError("Serial number mismatch")
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -50,15 +72,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)
@@ -66,6 +86,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

@@ -0,0 +1,55 @@
"""Oasis Mini binary sensor entity."""
from __future__ import annotations
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import OasisMiniConfigEntry
from .coordinator import OasisMiniCoordinator
from .entity import OasisMiniEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: OasisMiniConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Oasis Mini sensors using config entry."""
coordinator: OasisMiniCoordinator = entry.runtime_data
async_add_entities(
OasisMiniBinarySensorEntity(coordinator, descriptor)
for descriptor in DESCRIPTORS
)
DESCRIPTORS = {
BinarySensorEntityDescription(
key="busy",
translation_key="busy",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
BinarySensorEntityDescription(
key="wifi_connected",
translation_key="wifi_status",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
}
class OasisMiniBinarySensorEntity(OasisMiniEntity, BinarySensorEntity):
"""Oasis Mini binary sensor entity."""
@property
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
return getattr(self.device, self.entity_description.key)

View File

@@ -2,40 +2,74 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, Coroutine from dataclasses import dataclass
import random
from typing import Awaitable, Callable
from homeassistant.components.button import ( from homeassistant.components.button import (
ButtonDeviceClass, ButtonDeviceClass,
ButtonEntity, ButtonEntity,
ButtonEntityDescription, ButtonEntityDescription,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
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 .entity import OasisMiniEntity from .entity import OasisMiniEntity
from .helpers import add_and_play_track
from .pyoasismini import OasisMini
from .pyoasismini.const import TRACKS from .pyoasismini.const import TRACKS
async def async_setup_entry(
hass: HomeAssistant,
entry: OasisMiniConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Oasis Mini button using config entry."""
async_add_entities(
[
OasisMiniButtonEntity(entry.runtime_data, descriptor)
for descriptor in DESCRIPTORS
]
)
async def play_random_track(device: OasisMini) -> None:
"""Play random track."""
track = random.choice(list(TRACKS))
await add_and_play_track(device, track)
@dataclass(frozen=True, kw_only=True)
class OasisMiniButtonEntityDescription(ButtonEntityDescription):
"""Oasis Mini button entity description."""
press_fn: Callable[[OasisMini], Awaitable[None]]
DESCRIPTORS = (
OasisMiniButtonEntityDescription(
key="reboot",
device_class=ButtonDeviceClass.RESTART,
entity_category=EntityCategory.CONFIG,
press_fn=lambda device: device.async_reboot(),
),
OasisMiniButtonEntityDescription(
key="random_track",
translation_key="random_track",
press_fn=play_random_track,
),
)
class OasisMiniButtonEntity(OasisMiniEntity, ButtonEntity): class OasisMiniButtonEntity(OasisMiniEntity, ButtonEntity):
"""Oasis Mini button entity.""" """Oasis Mini button entity."""
entity_description: OasisMiniButtonEntityDescription
async def async_press(self) -> None: async def async_press(self) -> None:
"""Press the button.""" """Press the button."""
await self.device.async_reboot() await self.entity_description.press_fn(self.device)
await self.coordinator.async_request_refresh()
DESCRIPTOR = ButtonEntityDescription(
key="reboot", device_class=ButtonDeviceClass.RESTART
)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, 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)])

View File

@@ -10,10 +10,10 @@ from aiohttp import ClientConnectorError
from httpx import ConnectError, HTTPStatusError from httpx import ConnectError, HTTPStatusError
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.components import dhcp
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_HOST, CONF_PASSWORD from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_HOST, CONF_PASSWORD
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.schema_config_entry_flow import ( from homeassistant.helpers.schema_config_entry_flow import (
SchemaCommonFlowHandler, SchemaCommonFlowHandler,
SchemaFlowError, SchemaFlowError,
@@ -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
@@ -30,27 +31,23 @@ _LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
OPTIONS_SCHEMA = vol.Schema( OPTIONS_SCHEMA = vol.Schema(
{ {vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str}
vol.Optional(CONF_EMAIL): str,
vol.Optional(CONF_PASSWORD): str,
}
) )
async def cloud_login( async def cloud_login(
handler: SchemaCommonFlowHandler, user_input: dict[str, Any] handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
) -> dict[str, Any]: ) -> dict[str, Any]:
coordinator: OasisMiniCoordinator = handler.parent_handler.hass.data[DOMAIN][ """Cloud login."""
handler.parent_handler.config_entry.entry_id coordinator: OasisMiniCoordinator = handler.parent_handler.config_entry.runtime_data
]
try: try:
await coordinator.device.async_cloud_login( await coordinator.device.async_cloud_login(
email=user_input[CONF_EMAIL], password=user_input[CONF_PASSWORD] email=user_input[CONF_EMAIL], password=user_input[CONF_PASSWORD]
) )
user_input[CONF_ACCESS_TOKEN] = coordinator.device.access_token user_input[CONF_ACCESS_TOKEN] = coordinator.device.access_token
except: except Exception as ex:
raise SchemaFlowError("invalid_auth") raise SchemaFlowError("invalid_auth") from ex
del user_input[CONF_PASSWORD] del user_input[CONF_PASSWORD]
return user_input return user_input
@@ -66,64 +63,87 @@ class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1 VERSION = 1
host: str | None = None
serial_number: str | None = None
@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)
# async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: async def async_step_dhcp(
# """Handle dhcp discovery.""" self, discovery_info: dhcp.DhcpServiceInfo
# self.host = discovery_info.ip ) -> ConfigFlowResult:
# self.name = discovery_info.hostname """Handle DHCP discovery."""
# await self.async_set_unique_id(discovery_info.macaddress) host = {CONF_HOST: discovery_info.ip}
# self._abort_if_unique_id_configured(updates={CONF_HOST: self.host}) await self.validate_client(host)
# return await self.async_step_api_key() self._abort_if_unique_id_configured(updates=host)
# This should never happen since we only listen to DHCP requests
# for configured devices.
return self.async_abort(reason="already_configured")
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> FlowResult: ) -> ConfigFlowResult:
"""Handle the initial step.""" """Handle the initial step."""
return await self._async_step("user", STEP_USER_DATA_SCHEMA, user_input) return await self._async_step(
"user", STEP_USER_DATA_SCHEMA, user_input, user_input
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration."""
entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
assert entry
suggested_values = user_input or entry.data
return await self._async_step(
"reconfigure", STEP_USER_DATA_SCHEMA, user_input, suggested_values
)
async def _async_step( async def _async_step(
self, step_id: str, schema: vol.Schema, user_input: dict[str, Any] | None = None self,
) -> FlowResult: step_id: str,
schema: vol.Schema,
user_input: dict[str, Any] | None = None,
suggested_values: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Handle step setup.""" """Handle step setup."""
if abort := self._abort_if_configured(user_input):
return abort
errors = {} errors = {}
if user_input is not None: if user_input is not None:
if not (errors := await self.validate_client(user_input)): if not (errors := await self.validate_client(user_input)):
data = {CONF_HOST: user_input.get(CONF_HOST, self.host)} if step_id != "reconfigure":
self._abort_if_unique_id_configured(updates=user_input)
if existing_entry := self.hass.config_entries.async_get_entry( if existing_entry := self.hass.config_entries.async_get_entry(
self.context.get("entry_id") self.context.get("entry_id")
): ):
self.hass.config_entries.async_update_entry( self.hass.config_entries.async_update_entry(
existing_entry, data=data existing_entry, data=user_input
) )
await self.hass.config_entries.async_reload(existing_entry.entry_id) await self.hass.config_entries.async_reload(existing_entry.entry_id)
return self.async_abort(reason="reauth_successful") return self.async_abort(reason="reconfigure_successful")
return self.async_create_entry( return self.async_create_entry(
title=f"Oasis Mini {self.serial_number}", title=f"Oasis Mini {self.unique_id}",
data=data, data=user_input,
) )
return self.async_show_form(step_id=step_id, data_schema=schema, errors=errors) return self.async_show_form(
step_id=step_id,
data_schema=self.add_suggested_values_to_schema(schema, suggested_values),
errors=errors,
)
async def validate_client(self, user_input: dict[str, Any]) -> dict[str, str]: async def validate_client(self, user_input: dict[str, Any]) -> dict[str, str]:
"""Validate client setup.""" """Validate client setup."""
errors = {} errors = {}
try: try:
client = create_client({"host": self.host} | user_input) async with asyncio.timeout(10):
self.serial_number = await client.async_get_serial_number() client = create_client(user_input)
if not self.serial_number: await self.async_set_unique_id(await client.async_get_serial_number())
if not self.unique_id:
errors["base"] = "invalid_host" errors["base"] = "invalid_host"
except asyncio.TimeoutError: except asyncio.TimeoutError:
errors["base"] = "timeout_connect" errors["base"] = "timeout_connect"
@@ -139,15 +159,3 @@ class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN):
finally: finally:
await client.session.close() await client.session.close()
return errors return errors
@callback
def _abort_if_configured(
self, user_input: dict[str, Any] | None
) -> FlowResult | None:
"""Abort if configured."""
if self.host or user_input:
data = {CONF_HOST: self.host, **(user_input or {})}
for entry in self._async_current_entries():
if entry.data[CONF_HOST] == data[CONF_HOST]:
return self.async_abort(reason="already_configured")
return None

View File

@@ -8,7 +8,6 @@ import logging
import async_timeout import async_timeout
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN from .const import DOMAIN
@@ -20,28 +19,43 @@ _LOGGER = logging.getLogger(__name__)
class OasisMiniCoordinator(DataUpdateCoordinator[str]): class OasisMiniCoordinator(DataUpdateCoordinator[str]):
"""Oasis Mini data update coordinator.""" """Oasis Mini data update coordinator."""
attempt: int = 0
last_updated: datetime | None = None last_updated: datetime | None = None
def __init__(self, hass: HomeAssistant, device: OasisMini) -> None: def __init__(self, hass: HomeAssistant, device: OasisMini) -> None:
"""Initialize.""" """Initialize."""
super().__init__( super().__init__(
hass, _LOGGER, name=DOMAIN, update_interval=timedelta(seconds=10) hass,
_LOGGER,
name=DOMAIN,
update_interval=timedelta(seconds=10),
always_update=False,
) )
self.device = device self.device = device
async def _async_update_data(self): async def _async_update_data(self):
"""Update the data."""
data: str | None = None
self.attempt += 1
try: try:
async with async_timeout.timeout(10): async with async_timeout.timeout(10):
if not self.device.mac_address:
await self.device.async_get_mac_address()
if not self.device.serial_number: if not self.device.serial_number:
await self.device.async_get_serial_number() await self.device.async_get_serial_number()
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()
except Exception as ex: await self.device.async_get_playlist_details()
raise UpdateFailed("Couldn't read from the Oasis Mini") from ex except Exception as ex: # pylint:disable=broad-except
if data is None: if self.attempt > 2 or not (data or self.data):
raise ConfigEntryAuthFailed raise UpdateFailed(
f"Couldn't read from the Oasis Mini after {self.attempt} attempts"
) from ex
if data != self.data: if data != self.data:
self.last_updated = datetime.now() self.last_updated = datetime.now()
return data return data

View File

@@ -2,9 +2,7 @@
from __future__ import annotations from __future__ import annotations
import logging from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac
from homeassistant.config_entries import ConfigEntry
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
@@ -12,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."""
@@ -21,24 +17,23 @@ 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)
self.entity_description = description self.entity_description = description
serial_number = coordinator.device.serial_number device = coordinator.device
serial_number = device.serial_number
self._attr_unique_id = f"{serial_number}-{description.key}" self._attr_unique_id = f"{serial_number}-{description.key}"
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
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,
sw_version=coordinator.device.software_version, sw_version=device.software_version,
) )
@property @property

View File

@@ -2,13 +2,49 @@
from __future__ import annotations from __future__ import annotations
import logging
from typing import Any from typing import Any
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST
from .pyoasismini import OasisMini from .pyoasismini import TRACKS, OasisMini
_LOGGER = logging.getLogger(__name__)
def create_client(data: dict[str, Any]) -> OasisMini: def create_client(data: dict[str, Any]) -> OasisMini:
"""Create a Oasis Mini local client.""" """Create a Oasis Mini local client."""
return OasisMini(data[CONF_HOST], data.get(CONF_ACCESS_TOKEN)) return OasisMini(data[CONF_HOST], data.get(CONF_ACCESS_TOKEN))
async def add_and_play_track(device: OasisMini, track: int) -> None:
"""Add and play a track."""
if track not in device.playlist:
await device.async_add_track_to_playlist(track)
# 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) - 1)):
await device.async_move_track(index, _next)
await device.async_change_track(_next)
if device.status_code != 4:
await device.async_play()
def get_track_id(track: str) -> int | None:
"""Get a track id.
`track` can be either an id or title
"""
track = track.lower().strip()
if track not in map(str, TRACKS):
track = next(
(id for id, info in TRACKS.items() if info["name"].lower() == track), track
)
try:
return int(track)
except ValueError:
_LOGGER.warning("Invalid track: %s", track)
return None

View File

@@ -0,0 +1,45 @@
{
"entity": {
"binary_sensor": {
"wifi_status": {
"default": "mdi:wifi",
"state": {
"off": "mdi:wifi-off"
}
}
},
"sensor": {
"download_progress": {
"default": "mdi:progress-download"
},
"drawing_progress": {
"default": "mdi:progress-pencil"
},
"error": {
"default": "mdi:alert-circle-outline",
"state": {
"0": "mdi:circle-outline"
}
},
"status": {
"state": {
"booting": "mdi:loading",
"centering": "mdi:record-circle-outline",
"downloading": "mdi:progress-download",
"error": "mdi:alert-circle-outline",
"live": "mdi:pencil-circle-outline",
"paused": "mdi:motion-pause-outline",
"playing": "mdi:motion-play-outline",
"stopped": "mdi:stop-circle-outline",
"updating": "mdi:update"
}
},
"wifi_connected": {
"default": "mdi:wifi",
"state": {
"off": "mdi:wifi-off"
}
}
}
}
}

View File

@@ -2,16 +2,15 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from homeassistant.components.image import Image, ImageEntity, ImageEntityDescription
from homeassistant.core import HomeAssistant, callback
from homeassistant.components.image import ImageEntity, ImageEntityDescription
from homeassistant.config_entries import ConfigEntry
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.utils import draw_svg from .pyoasismini.utils import draw_svg
IMAGE = ImageEntityDescription(key="image", name=None) IMAGE = ImageEntityDescription(key="image", name=None)
@@ -21,35 +20,56 @@ class OasisMiniImageEntity(OasisMiniEntity, ImageEntity):
"""Oasis Mini image entity.""" """Oasis Mini image entity."""
_attr_content_type = "image/svg+xml" _attr_content_type = "image/svg+xml"
_track_id: int | None = None
_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()
@property
def image_last_updated(self) -> datetime | None:
"""The time when the image was last updated."""
return self.coordinator.last_updated
def image(self) -> bytes | None: def image(self) -> bytes | None:
"""Return bytes of image.""" """Return bytes of image."""
return draw_svg( if not self._cached_image:
self.device._current_track_details, self._cached_image = Image(
self.device.progress, self.content_type, draw_svg(self.device.track, self._progress, "1")
"1", )
) 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 (self._progress != self.device.progress and self.device.access_token)
) and (self.device.status == "playing" or self._cached_image is None):
self._attr_image_last_updated = self.coordinator.last_updated
self._track_id = self.device.track_id
self._progress = self.device.progress
self._cached_image = None
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 := (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( 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)])
if coordinator.device.access_token:
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
@@ -106,12 +106,13 @@ class OasisMiniLightEntity(OasisMiniEntity, LightEntity):
await self.coordinator.async_request_refresh() await self.coordinator.async_request_refresh()
DESCRIPTOR = LightEntityDescription(key="led", name="LED") DESCRIPTOR = LightEntityDescription(key="led", translation_key="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,6 +3,7 @@
"name": "Oasis Mini", "name": "Oasis Mini",
"codeowners": ["@natekspencer"], "codeowners": ["@natekspencer"],
"config_flow": true, "config_flow": true,
"dhcp": [{ "registered_devices": true }],
"documentation": "https://github.com/natekspencer/hacs-oasis_mini", "documentation": "https://github.com/natekspencer/hacs-oasis_mini",
"integration_type": "device", "integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",

View File

@@ -3,9 +3,10 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
import math from typing import Any
from homeassistant.components.media_player import ( from homeassistant.components.media_player import (
MediaPlayerEnqueue,
MediaPlayerEntity, MediaPlayerEntity,
MediaPlayerEntityDescription, MediaPlayerEntityDescription,
MediaPlayerEntityFeature, MediaPlayerEntityFeature,
@@ -13,26 +14,30 @@ 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.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import OasisMiniConfigEntry
from .const import DOMAIN from .const import DOMAIN
from .coordinator import OasisMiniCoordinator
from .entity import OasisMiniEntity from .entity import OasisMiniEntity
from .helpers import get_track_id
from .pyoasismini.const import TRACKS from .pyoasismini.const import TRACKS
BRIGHTNESS_SCALE = (1, 200)
class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity): class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
"""Oasis Mini media player entity.""" """Oasis Mini media player entity."""
_attr_media_image_remotely_accessible = True _attr_media_image_remotely_accessible = True
_attr_supported_features = ( _attr_supported_features = (
MediaPlayerEntityFeature.NEXT_TRACK MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.PLAY_MEDIA
| MediaPlayerEntityFeature.MEDIA_ENQUEUE
| MediaPlayerEntityFeature.CLEAR_PLAYLIST
| MediaPlayerEntityFeature.REPEAT_SET | MediaPlayerEntityFeature.REPEAT_SET
) )
@@ -42,19 +47,17 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
return MediaType.IMAGE return MediaType.IMAGE
@property @property
def media_duration(self) -> int: def media_duration(self) -> int | None:
"""Duration of current playing media in seconds.""" """Duration of current playing media in seconds."""
if ( if (track := self.device.track) and "reduced_svg_content" in track:
track := self.device._current_track_details
) and "reduced_svg_content" in track:
return track["reduced_svg_content"].get("1") return track["reduced_svg_content"].get("1")
return math.ceil(self.media_position / 0.99) return None
@property @property
def media_image_url(self) -> str | None: def media_image_url(self) -> str | None:
"""Image url of current playing media.""" """Image url of current playing media."""
if not (track := self.device._current_track_details): if not (track := self.device.track):
track = TRACKS.get(str(self.device.current_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
@@ -70,41 +73,64 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
return self.coordinator.last_updated return self.coordinator.last_updated
@property @property
def media_title(self) -> str: def media_title(self) -> str | None:
"""Title of current playing media.""" """Title of current playing media."""
if not (track := self.device._current_track_details): if not self.device.track_id:
track = TRACKS.get(str(self.device.current_track_id), {}) return None
return track.get("name", f"Unknown Title (#{self.device.current_track_id})") if not (track := self.device.track):
track = TRACKS.get(self.device.track_id, {})
return track.get("name", f"Unknown Title (#{self.device.track_id})")
@property @property
def repeat(self) -> RepeatMode: def repeat(self) -> RepeatMode:
"""Return current repeat mode.""" """Return current repeat mode."""
if self.device.repeat_playlist: return RepeatMode.ALL if self.device.repeat_playlist else RepeatMode.OFF
return RepeatMode.ALL
return RepeatMode.OFF
@property @property
def state(self) -> MediaPlayerState: def state(self) -> MediaPlayerState:
"""State of the player.""" """State of the player."""
status_code = self.device.status_code status_code = self.device.status_code
if self.device.error or status_code in (9, 11):
return MediaPlayerState.OFF
if status_code == 2:
return MediaPlayerState.IDLE
if status_code in (3, 13): if status_code in (3, 13):
return MediaPlayerState.BUFFERING return MediaPlayerState.BUFFERING
if status_code in (2, 5):
return MediaPlayerState.PAUSED
if status_code == 4: if status_code == 4:
return MediaPlayerState.PLAYING return MediaPlayerState.PLAYING
return MediaPlayerState.STANDBY if status_code == 5:
return MediaPlayerState.PAUSED
if status_code == 15:
return MediaPlayerState.ON
return MediaPlayerState.IDLE
def abort_if_busy(self) -> None:
"""Abort if the device is currently busy."""
if self.device.busy:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="device_busy",
translation_placeholders={"name": self._friendly_name_internal()},
)
async def async_media_pause(self) -> None: async def async_media_pause(self) -> None:
"""Send pause command.""" """Send pause command."""
self.abort_if_busy()
await self.device.async_pause() await self.device.async_pause()
await self.coordinator.async_request_refresh() await self.coordinator.async_request_refresh()
async def async_media_play(self) -> None: async def async_media_play(self) -> None:
"""Send play command.""" """Send play command."""
self.abort_if_busy()
await self.device.async_play() await self.device.async_play()
await self.coordinator.async_request_refresh() await self.coordinator.async_request_refresh()
async def async_media_stop(self) -> None:
"""Send stop command."""
self.abort_if_busy()
await self.device.async_stop()
await self.coordinator.async_request_refresh()
async def async_set_repeat(self, repeat: RepeatMode) -> None: async def async_set_repeat(self, repeat: RepeatMode) -> None:
"""Set repeat mode.""" """Set repeat mode."""
await self.device.async_set_repeat_playlist( await self.device.async_set_repeat_playlist(
@@ -113,20 +139,86 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
) )
await self.coordinator.async_request_refresh() await self.coordinator.async_request_refresh()
async def async_media_previous_track(self) -> None:
"""Send previous track command."""
self.abort_if_busy()
if (index := self.device.playlist_index - 1) < 0:
index = len(self.device.playlist) - 1
await self.device.async_change_track(index)
await self.coordinator.async_request_refresh()
async def async_media_next_track(self) -> None: async def async_media_next_track(self) -> None:
"""Send next track command.""" """Send next track command."""
self.abort_if_busy()
if (index := self.device.playlist_index + 1) >= len(self.device.playlist): if (index := self.device.playlist_index + 1) >= len(self.device.playlist):
index = 0 index = 0
await self.device.async_change_track(index) await self.device.async_change_track(index)
await self.coordinator.async_request_refresh() await self.coordinator.async_request_refresh()
async def async_play_media(
self,
media_type: MediaType | str,
media_id: str,
enqueue: MediaPlayerEnqueue | None = None,
**kwargs: Any,
) -> None:
"""Play a piece of media."""
self.abort_if_busy()
if media_type == MediaType.PLAYLIST:
raise ServiceValidationError(
translation_domain=DOMAIN, translation_key="playlists_unsupported"
)
else:
track = list(filter(None, map(get_track_id, media_id.split(","))))
if not track:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_media",
translation_placeholders={"media": 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
new_tracks = 1 if isinstance(track, int) else len(track)
if (index := (len(device.playlist) - new_tracks)) != device.playlist_index:
if index != (
_next := min(
device.playlist_index + 1, len(device.playlist) - new_tracks
)
):
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."""
self.abort_if_busy()
await self.device.async_clear_playlist()
await self.coordinator.async_request_refresh()
DESCRIPTOR = MediaPlayerEntityDescription(key="oasis_mini", name=None) 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

@@ -2,14 +2,17 @@
from __future__ import annotations from __future__ import annotations
from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.components.number import (
from homeassistant.config_entries import ConfigEntry NumberEntity,
NumberEntityDescription,
NumberMode,
)
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
class OasisMiniNumberEntity(OasisMiniEntity, NumberEntity): class OasisMiniNumberEntity(OasisMiniEntity, NumberEntity):
@@ -32,27 +35,30 @@ class OasisMiniNumberEntity(OasisMiniEntity, NumberEntity):
DESCRIPTORS = { DESCRIPTORS = {
NumberEntityDescription( NumberEntityDescription(
key="ball_speed", key="ball_speed",
name="Ball speed", translation_key="ball_speed",
native_max_value=800, mode=NumberMode.SLIDER,
native_min_value=200, native_max_value=BALL_SPEED_MAX,
native_min_value=BALL_SPEED_MIN,
), ),
NumberEntityDescription( NumberEntityDescription(
key="led_speed", key="led_speed",
name="LED speed", translation_key="led_speed",
native_max_value=90, mode=NumberMode.SLIDER,
native_min_value=-90, native_max_value=LED_SPEED_MAX,
native_min_value=LED_SPEED_MIN,
), ),
} }
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

@@ -1,44 +1,38 @@
"""Oasis Mini API client.""" """Oasis Mini API client."""
from __future__ import annotations
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
from .utils import _bit_to_bool from .const import TRACKS
from .utils import _bit_to_bool, decrypt_svg_content
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
STATUS_CODE_MAP = { STATUS_CODE_MAP = {
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",
13: "downloading", 13: "downloading",
15: "live",
} }
ATTRIBUTES: Final[list[tuple[str, Callable[[str], Any]]]] = [ AUTOPLAY_MAP = {
("status_code", int), # see status code map "0": "on",
("error", str), # error, 0 = none, and 10 = ?, 18 = can't download? "1": "off",
("ball_speed", int), # 200 - 800 "2": "5 minutes",
("playlist", lambda value: [int(track) for track in value.split(",")]), # noqa: E501 # comma separated track ids "3": "10 minutes",
("playlist_index", int), # index of above "4": "30 minutes",
("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),
("pause_between_tracks", _bit_to_bool),
]
LED_EFFECTS: Final[dict[str, str]] = { LED_EFFECTS: Final[dict[str, str]] = {
"0": "Solid", "0": "Solid",
@@ -59,25 +53,37 @@ LED_EFFECTS: Final[dict[str, str]] = {
} }
CLOUD_BASE_URL = "https://app.grounded.so" CLOUD_BASE_URL = "https://app.grounded.so"
CLOUD_API_URL = f"{CLOUD_BASE_URL}/api"
BALL_SPEED_MAX: Final = 1000
BALL_SPEED_MIN: Final = 200
LED_SPEED_MAX: Final = 90
LED_SPEED_MIN: Final = -90
class OasisMini: class OasisMini:
"""Oasis Mini API client class.""" """Oasis Mini API client class."""
_access_token: str | None = None _access_token: str | None = None
_current_track_details: dict | None = None _mac_address: str | None = None
_ip_address: str | None = None
_playlist: dict[int, dict[str, str]] = {}
_serial_number: str | None = None _serial_number: str | None = None
_software_version: str | None = None _software_version: str | None = None
_track: dict | None = None
autoplay: str
brightness: int brightness: int
color: str busy: bool
color: str | None = None
download_progress: int
error: int
led_effect: str led_effect: str
led_speed: int led_speed: int
max_brightness: int max_brightness: int
playlist: list[int] playlist: list[int]
playlist_index: int playlist_index: int
progress: int progress: int
repeat_playlist: bool
status_code: int status_code: int
def __init__( def __init__(
@@ -97,10 +103,20 @@ class OasisMini:
return self._access_token return self._access_token
@property @property
def current_track_id(self) -> int: def mac_address(self) -> str | None:
"""Return the current track.""" """Return the mac address."""
i = self.playlist_index return self._mac_address
return self.playlist[0] if i >= len(self.playlist) else self.playlist[i]
@property
def drawing_progress(self) -> float | None:
"""Return the drawing progress percent."""
if not (self.track and (svg_content := self.track.get("svg_content"))):
return None
svg_content = decrypt_svg_content(svg_content)
paths = svg_content.split("L")
total = self.track.get("reduced_svg_content", {}).get("1", len(paths))
percent = (100 * self.progress) / total
return percent
@property @property
def serial_number(self) -> str | None: def serial_number(self) -> str | None:
@@ -122,17 +138,60 @@ class OasisMini:
"""Return the status.""" """Return the status."""
return STATUS_CODE_MAP.get(self.status_code, f"Unknown ({self.status_code})") return STATUS_CODE_MAP.get(self.status_code, f"Unknown ({self.status_code})")
@property
def track(self) -> dict | None:
"""Return the current track info."""
if self._track and self._track.get("id") == self.track_id:
return self._track
return None
@property
def track_id(self) -> int | None:
"""Return the current track id."""
if not self.playlist:
return None
i = self.playlist_index
return self.playlist[0] if i >= len(self.playlist) else self.playlist[i]
@property @property
def url(self) -> str: def url(self) -> str:
"""Return the url.""" """Return the url."""
return f"http://{self._host}/" return f"http://{self._host}/"
async def async_add_track_to_playlist(self, track: int | list[int]) -> None:
"""Add track to playlist."""
if not track:
return
if isinstance(track, int):
track = [track]
if 0 in self.playlist:
playlist = [t for t in self.playlist if t] + track
return await self.async_set_playlist(playlist)
await self._async_command(params={"ADDJOBLIST": track})
self.playlist.extend(track)
async def async_change_track(self, index: int) -> None: async def async_change_track(self, index: int) -> None:
"""Change the track.""" """Change the track."""
if index >= len(self.playlist): if index >= len(self.playlist):
raise ValueError("Invalid selection") 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:
"""Get the ip address."""
self._ip_address = await self._async_get(params={"GETIP": ""})
_LOGGER.debug("IP address: %s", self._ip_address)
return self._ip_address
async def async_get_mac_address(self) -> str | None:
"""Get the mac address."""
self._mac_address = await self._async_get(params={"GETMAC": ""})
_LOGGER.debug("MAC address: %s", self._mac_address)
return self._mac_address
async def async_get_serial_number(self) -> str | None: async def async_get_serial_number(self) -> str | None:
"""Get the serial number.""" """Get the serial number."""
self._serial_number = await self._async_get(params={"GETOASISID": ""}) self._serial_number = await self._async_get(params={"GETOASISID": ""})
@@ -145,16 +204,45 @@ class OasisMini:
_LOGGER.debug("Software version: %s", self._software_version) _LOGGER.debug("Software version: %s", self._software_version)
return self._software_version return self._software_version
async def async_get_status(self) -> None: 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:
"""Move a track in the playlist."""
await self._async_command(params={"MOVEJOB": f"{_from};{_to}"})
async def async_pause(self) -> None: async def async_pause(self) -> None:
"""Send pause command.""" """Send pause command."""
@@ -162,7 +250,10 @@ class OasisMini:
async def async_play(self) -> None: async def async_play(self) -> None:
"""Send play command.""" """Send play command."""
await self._async_command(params={"CMDPLAY": ""}) if self.status_code == 15:
await self.async_stop()
if self.track_id:
await self._async_command(params={"CMDPLAY": ""})
async def async_reboot(self) -> None: async def async_reboot(self) -> None:
"""Send reboot command.""" """Send reboot command."""
@@ -178,8 +269,8 @@ class OasisMini:
async def async_set_ball_speed(self, speed: int) -> None: async def async_set_ball_speed(self, speed: int) -> None:
"""Set the Oasis Mini ball speed.""" """Set the Oasis Mini ball speed."""
if not 200 <= speed <= 800: if not BALL_SPEED_MIN <= speed <= BALL_SPEED_MAX:
raise Exception("Invalid speed specified") raise ValueError("Invalid speed specified")
await self._async_command(params={"WRIOASISSPEED": speed}) await self._async_command(params={"WRIOASISSPEED": speed})
@@ -195,43 +286,53 @@ 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:
brightness = self.brightness brightness = self.brightness
if led_effect not in LED_EFFECTS: if led_effect not in LED_EFFECTS:
raise Exception("Invalid led effect specified") raise ValueError("Invalid led effect specified")
if not -90 <= led_speed <= 90: if not LED_SPEED_MIN <= led_speed <= LED_SPEED_MAX:
raise Exception("Invalid led speed specified") raise ValueError("Invalid led speed specified")
if not 0 <= brightness <= 200: if not 0 <= brightness <= self.max_brightness:
raise Exception("Invalid brightness specified") raise ValueError("Invalid brightness specified")
await self._async_command( await self._async_command(
params={"WRILED": f"{led_effect};0;{color};{led_speed};{brightness}"} params={"WRILED": f"{led_effect};0;{color};{led_speed};{brightness}"}
) )
async def async_set_pause_between_tracks(self, pause: bool) -> None: async def async_set_autoplay(self, option: bool | int | str) -> None:
"""Set the Oasis Mini pause between tracks.""" """Set autoplay."""
await self._async_command(params={"WRIWAITAFTER": 1 if pause else 0}) if isinstance(option, bool):
option = 0 if option else 1
if str(option) not in AUTOPLAY_MAP:
raise ValueError("Invalid pause option specified")
await self._async_command(params={"WRIWAITAFTER": option})
async def async_set_playlist(self, playlist: list[int] | int) -> None:
"""Set the playlist."""
if isinstance(playlist, int):
playlist = [playlist]
if is_playing := (self.status_code == 4):
await self.async_stop()
await self._async_command(params={"WRIJOBLIST": ",".join(map(str, playlist))})
self.playlist = playlist
if is_playing:
await self.async_play()
async def async_set_repeat_playlist(self, repeat: bool) -> None: async def async_set_repeat_playlist(self, repeat: bool) -> None:
"""Set the Oasis Mini repeat playlist.""" """Set repeat playlist."""
await self._async_command(params={"WRIREPEATJOB": 1 if repeat else 0}) await self._async_command(params={"WRIREPEATJOB": 1 if repeat else 0})
async def _async_command(self, **kwargs: Any) -> str | None: async def async_stop(self) -> None:
"""Send a command request.""" """Send stop command."""
result = await self._async_get(**kwargs) await self._async_command(params={"CMDSTOP": ""})
_LOGGER.debug("Result: %s", result)
async def _async_get(self, **kwargs: Any) -> str | None: async def async_upgrade(self, beta: bool = False) -> None:
"""Perform a GET request.""" """Trigger a software upgrade."""
response = await self._session.get(self.url, **kwargs) await self._async_command(params={"CMDUPGRADE": 1 if beta else 0})
if response.status == 200:
text = await response.text()
return text
return None
async def async_cloud_login(self, email: str, password: str) -> None: async def async_cloud_login(self, email: str, password: str) -> None:
"""Login via the cloud.""" """Login via the cloud."""
@@ -244,42 +345,106 @@ class OasisMini:
async def async_cloud_logout(self) -> None: async def async_cloud_logout(self) -> None:
"""Login via the cloud.""" """Login via the cloud."""
if not self.access_token: await self._async_cloud_request("GET", "api/auth/logout")
return
await self._async_request(
"GET",
urljoin(CLOUD_BASE_URL, "api/auth/logout"),
headers={"Authorization": f"Bearer {self.access_token}"},
)
async def async_cloud_get_track_info(self, track_id: int) -> None: async def async_cloud_get_track_info(self, track_id: int) -> dict[str, Any] | None:
"""Get cloud track info.""" """Get cloud track info."""
try:
return await self._async_cloud_request("GET", f"api/track/{track_id}")
except ClientResponseError as err:
if err.status == 404:
return {"id": track_id, "name": f"Unknown Title (#{track_id})"}
except Exception as ex:
_LOGGER.exception(ex)
return None
async def async_cloud_get_tracks(
self, tracks: list[int] | None = None
) -> list[dict[str, Any]]:
"""Get tracks info from the cloud"""
response = await self._async_cloud_request(
"GET", "api/track", params={"ids[]": tracks or []}
)
if not response:
return None
track_details = response.get("data", [])
while next_page_url := response.get("next_page_url"):
response = await self._async_cloud_request("GET", next_page_url)
track_details += response.get("data", [])
return track_details
async def async_cloud_get_latest_software_details(self) -> dict[str, int | str]:
"""Get the latest software details from the cloud."""
return await self._async_cloud_request("GET", "api/software/last-version")
async def async_get_current_track_details(self) -> dict | None:
"""Get current track info, refreshing if needed."""
track_id = self.track_id
if (track := self._track) and track.get("id") == track_id:
return track
if track_id:
self._track = await self.async_cloud_get_track_info(track_id)
if not self._track:
self._track = TRACKS.get(
track_id, {"id": track_id, "name": f"Unknown Title (#{track_id})"}
)
return self._track
async def async_get_playlist_details(self) -> dict[int, dict[str, str]]:
"""Get playlist info."""
if set(self.playlist).difference(self._playlist.keys()):
tracks = await self.async_cloud_get_tracks(self.playlist)
all_tracks = TRACKS | {
track["id"]: {
"name": track["name"],
"author": ((track.get("author") or {}).get("person") or {}).get(
"name", "Oasis Mini"
),
"image": track["image"],
}
for track in tracks
}
for track in self.playlist:
self._playlist[track] = all_tracks.get(
track, {"name": f"Unknown Title (#{track})"}
)
return self._playlist
async def _async_cloud_request(self, method: str, url: str, **kwargs: Any) -> Any:
"""Perform a cloud request."""
if not self.access_token: if not self.access_token:
return return
response = await self._async_request( return await self._async_request(
"GET", method,
urljoin(CLOUD_BASE_URL, f"api/track/{track_id}"), urljoin(CLOUD_BASE_URL, url),
headers={"Authorization": f"Bearer {self.access_token}"}, headers={"Authorization": f"Bearer {self.access_token}"},
**kwargs,
) )
return response
async def _async_command(self, **kwargs: Any) -> str | None:
"""Send a command to the device."""
result = await self._async_get(**kwargs)
_LOGGER.debug("Result: %s", result)
async def _async_get(self, **kwargs: Any) -> str | None:
"""Perform a GET request."""
return await self._async_request("GET", self.url, **kwargs)
async def _async_request(self, method: str, url: str, **kwargs) -> Any: async def _async_request(self, method: str, url: str, **kwargs) -> Any:
"""Login via the cloud.""" """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.headers.get("Content-Type") == "application/json": if response.content_type == "application/json":
return await response.json() return await response.json()
return await response.text() if response.content_type == "text/plain":
return await response.text()
return None
response.raise_for_status() response.raise_for_status()
async def async_get_current_track_details(self) -> dict:
"""Get current track info, refreshing if needed."""
if (track_details := self._current_track_details) and track_details.get(
"id"
) == self.current_track_id:
return track_details
self._current_track_details = await self.async_cloud_get_track_info(
self.current_track_id
)

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[int, 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

@@ -1,11 +1,17 @@
"""Oasis Mini utils.""" """Oasis Mini utils."""
from __future__ import annotations
import base64
import logging import logging
import math import math
from xml.etree.ElementTree import Element, SubElement, tostring from xml.etree.ElementTree import Element, SubElement, tostring
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
APP_KEY = "5joW8W4Usk4xUXu5bIIgGiHloQmzMZUMgz6NWQnNI04="
BACKGROUND_FILL = ("#CCC9C4", "#28292E") BACKGROUND_FILL = ("#CCC9C4", "#28292E")
COLOR_DARK = ("#28292E", "#F4F5F8") COLOR_DARK = ("#28292E", "#F4F5F8")
@@ -25,10 +31,11 @@ def draw_svg(track: dict, progress: int, model_id: str) -> str | None:
if track and (svg_content := track.get("svg_content")): if track and (svg_content := track.get("svg_content")):
try: try:
if progress is not None: if progress is not None:
svg_content = decrypt_svg_content(svg_content)
paths = svg_content.split("L") paths = svg_content.split("L")
total = track.get("reduced_svg_content", {}).get(model_id, len(paths)) total = track.get("reduced_svg_content", {}).get(model_id, len(paths))
percent = (100 * progress) / total percent = min((100 * progress) / total, 100)
progress = math.floor((percent / 100) * len(paths)) progress = math.floor((percent / 100) * (len(paths) - 1))
svg = Element( svg = Element(
"svg", "svg",
@@ -56,7 +63,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,
@@ -137,3 +144,28 @@ def draw_svg(track: dict, progress: int, model_id: str) -> str | None:
except Exception as e: except Exception as e:
_LOGGER.exception(e) _LOGGER.exception(e)
return None return None
def decrypt_svg_content(svg_content: dict[str, str]):
"""Decrypt SVG content using AES CBC mode."""
if decrypted := svg_content.get("decrypted"):
return decrypted
# decode base64-encoded data
key = base64.b64decode(APP_KEY)
iv = base64.b64decode(svg_content["iv"])
ciphertext = base64.b64decode(svg_content["content"])
# create the cipher and decrypt the ciphertext
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
decryptor = cipher.decryptor()
decrypted = decryptor.update(ciphertext) + decryptor.finalize()
# remove PKCS7 padding
pad_len = decrypted[-1]
decrypted = decrypted[:-pad_len].decode("utf-8")
# save decrypted data so we don't have to do this each time
svg_content["decrypted"] = decrypted
return decrypted

View File

@@ -2,61 +2,115 @@
from __future__ import annotations from __future__ import annotations
from typing import Any from dataclasses import dataclass
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.const import TRACKS from .pyoasismini.const import TRACKS
@dataclass(frozen=True, kw_only=True)
class OasisMiniSelectEntityDescription(SelectEntityDescription):
"""Oasis Mini select entity description."""
current_value: Callable[[OasisMini], Any]
select_fn: Callable[[OasisMini, int], Awaitable[None]]
update_handler: Callable[[OasisMiniSelectEntity], None] | None = None
class OasisMiniSelectEntity(OasisMiniEntity, SelectEntity): class OasisMiniSelectEntity(OasisMiniEntity, SelectEntity):
"""Oasis Mini select entity.""" """Oasis Mini select entity."""
entity_description: OasisMiniSelectEntityDescription
_current_value: Any | None = None
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._attr_options = [ self._handle_coordinator_update()
TRACKS.get(str(track), {}).get("name", str(track))
for track in self.device.playlist
]
@property
def current_option(self) -> str:
"""Return the selected entity option to represent the entity state."""
return self.options[self.device.playlist_index]
async def async_select_option(self, option: str) -> None: async def async_select_option(self, option: str) -> None:
"""Change the selected option.""" """Change the selected option."""
await self.device.async_change_track(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."""
self._attr_options = [ new_value = self.entity_description.current_value(self.device)
TRACKS.get(str(track), {}).get("name", str(track)) if self._current_value == new_value:
for track in self.device.playlist return
] self._current_value = new_value
return super()._handle_coordinator_update() if update_handler := self.entity_description.update_handler:
update_handler(self)
else:
self._attr_current_option = getattr(
self.device, self.entity_description.key
)
if self.hass:
return super()._handle_coordinator_update()
DESCRIPTOR = SelectEntityDescription(key="playlist", name="Playlist") def playlist_update_handler(entity: OasisMiniSelectEntity) -> None:
"""Handle playlist updates."""
# pylint: disable=protected-access
device = entity.device
options = [
device._playlist.get(track, {}).get(
"name",
TRACKS.get(track, {"id": track, "name": f"Unknown Title (#{track})"}).get(
"name",
device.track["name"]
if device.track and device.track["id"] == track
else str(track),
),
)
for track in device.playlist
]
entity._attr_options = options
index = min(device.playlist_index, len(options) - 1)
entity._attr_current_option = options[index] if options else None
DESCRIPTORS = (
OasisMiniSelectEntityDescription(
key="autoplay",
translation_key="autoplay",
options=list(AUTOPLAY_MAP.values()),
current_value=lambda device: device.autoplay,
select_fn=lambda device, option: device.async_set_autoplay(option),
),
OasisMiniSelectEntityDescription(
key="playlist",
translation_key="playlist",
current_value=lambda device: (device.playlist.copy(), device.playlist_index),
select_fn=lambda device, option: device.async_change_track(option),
update_handler=playlist_update_handler,
),
)
async def async_setup_entry( 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
]
)

View File

@@ -2,80 +2,75 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorEntity, SensorEntity,
SensorEntityDescription, SensorEntityDescription,
SensorStateClass, SensorStateClass,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, 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 .coordinator import OasisMiniCoordinator
from .entity import OasisMiniEntity from .entity import OasisMiniEntity
from .pyoasismini import OasisMini
@dataclass(frozen=True, kw_only=True) async def async_setup_entry(
class OasisMiniSensorEntityDescription(SensorEntityDescription): hass: HomeAssistant,
"""Oasis Mini sensor entity description.""" entry: OasisMiniConfigEntry,
async_add_entities: AddEntitiesCallback,
lookup_fn: Callable[[OasisMini], Any] | None = None ) -> None:
"""Set up Oasis Mini sensors using config entry."""
coordinator: OasisMiniCoordinator = entry.runtime_data
class OasisMiniSensorEntity(OasisMiniEntity, SensorEntity): entities = [
"""Oasis Mini sensor entity.""" OasisMiniSensorEntity(coordinator, descriptor) for descriptor in DESCRIPTORS
]
entity_description: OasisMiniSensorEntityDescription | SensorEntityDescription if coordinator.device.access_token:
entities.extend(
@property [
def native_value(self) -> str | None: OasisMiniSensorEntity(coordinator, descriptor)
"""Return the value reported by the sensor.""" for descriptor in CLOUD_DESCRIPTORS
if lookup_fn := getattr(self.entity_description, "lookup_fn", None): ]
return lookup_fn(self.device) )
return getattr(self.device, self.entity_description.key) async_add_entities(entities)
DESCRIPTORS = { DESCRIPTORS = {
SensorEntityDescription( SensorEntityDescription(
key="download_progress", key="download_progress",
translation_key="download_progress",
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
name="Download progress", native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.MEASUREMENT,
), ),
} } | {
OTHERS = {
SensorEntityDescription( SensorEntityDescription(
key=key, key=key,
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,
) )
for key in ( for key in ("error", "led_color_id", "status")
"busy",
"error",
"led_color_id",
"status",
"wifi_connected",
)
} }
CLOUD_DESCRIPTORS = (
SensorEntityDescription(
key="drawing_progress",
translation_key="drawing_progress",
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
),
)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback class OasisMiniSensorEntity(OasisMiniEntity, SensorEntity):
) -> None: """Oasis Mini sensor entity."""
"""Set up Oasis Mini sensors using config entry."""
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id] @property
async_add_entities( def native_value(self) -> str | None:
[ """Return the value reported by the sensor."""
OasisMiniSensorEntity(coordinator, entry, descriptor) return getattr(self.device, self.entity_description.key)
for descriptor in DESCRIPTORS | OTHERS
]
)

View File

@@ -6,8 +6,10 @@
"host": "[%key:common::config_flow::data::host%]" "host": "[%key:common::config_flow::data::host%]"
} }
}, },
"reauth_confirm": { "reconfigure": {
"data": {} "data": {
"host": "[%key:common::config_flow::data::host%]"
}
} }
}, },
"error": { "error": {
@@ -17,12 +19,14 @@
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
}, },
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]" "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
} }
}, },
"options": { "options": {
"step": { "step": {
"init": { "init": {
"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%]"
@@ -32,5 +36,80 @@
"error": { "error": {
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
} }
},
"entity": {
"button": {
"random_track": {
"name": "Play random track"
}
},
"binary_sensor": {
"busy": {
"name": "Busy"
},
"wifi_status": {
"name": "Wi-Fi status"
}
},
"light": {
"led": {
"name": "LED"
}
},
"number": {
"ball_speed": {
"name": "Ball speed"
},
"led_speed": {
"name": "LED speed"
}
},
"select": {
"autoplay": {
"name": "Autoplay"
},
"playlist": {
"name": "Playlist"
}
},
"sensor": {
"download_progress": {
"name": "Download progress"
},
"drawing_progress": {
"name": "Drawing progress"
},
"error": {
"name": "Error"
},
"led_color_id": {
"name": "LED color ID"
},
"status": {
"name": "Status",
"state": {
"booting": "Booting",
"stopped": "Stopped",
"centering": "Centering",
"playing": "Playing",
"paused": "Paused",
"error": "Error",
"updating": "Updating",
"downloading": "Downloading",
"live": "Live drawing"
}
}
}
},
"exceptions": {
"device_busy": {
"message": "{name} is currently busy and cannot be modified"
},
"invalid_media": {
"message": "Invalid media: {media}"
},
"playlists_unsupported": {
"message": "Playlists are not currently supported"
}
} }
} }

View File

@@ -1,64 +1,53 @@
"""Oasis Mini switch entity.""" # """Oasis Mini switch entity."""
from __future__ import annotations # from __future__ import annotations
from typing import Any # from typing import Any
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription # from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.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
class OasisMiniSwitchEntity(OasisMiniEntity, SwitchEntity): # async def async_setup_entry(
"""Oasis Mini switch entity.""" # hass: HomeAssistant,
# entry: OasisMiniConfigEntry,
@property # async_add_entities: AddEntitiesCallback,
def is_on(self) -> bool: # ) -> None:
"""Return True if entity is on.""" # """Set up Oasis Mini switchs using config entry."""
return int(getattr(self.device, self.entity_description.key)) # async_add_entities(
# [
async def async_turn_off(self, **kwargs: Any) -> None: # OasisMiniSwitchEntity(entry.runtime_data, descriptor)
"""Turn the entity off.""" # for descriptor in DESCRIPTORS
if self.entity_description.key == "pause_between_tracks": # ]
await self.device.async_set_pause_between_tracks(False) # )
elif self.entity_description.key == "repeat_playlist":
await self.device.async_set_repeat_playlist(False)
await self.coordinator.async_request_refresh()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
if self.entity_description.key == "pause_between_tracks":
await self.device.async_set_pause_between_tracks(True)
elif self.entity_description.key == "repeat_playlist":
await self.device.async_set_repeat_playlist(True)
await self.coordinator.async_request_refresh()
DESCRIPTORS = { # class OasisMiniSwitchEntity(OasisMiniEntity, SwitchEntity):
SwitchEntityDescription( # """Oasis Mini switch entity."""
key="pause_between_tracks",
name="Pause between tracks", # @property
), # def is_on(self) -> bool:
# SwitchEntityDescription( # """Return True if entity is on."""
# key="repeat_playlist", # return int(getattr(self.device, self.entity_description.key))
# name="Repeat playlist",
# ), # async def async_turn_off(self, **kwargs: Any) -> None:
} # """Turn the entity off."""
# await self.device.async_set_repeat_playlist(False)
# await self.coordinator.async_request_refresh()
# async def async_turn_on(self, **kwargs: Any) -> None:
# """Turn the entity on."""
# await self.device.async_set_repeat_playlist(True)
# await self.coordinator.async_request_refresh()
async def async_setup_entry( # DESCRIPTORS = {
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback # SwitchEntityDescription(
) -> None: # key="repeat_playlist",
"""Set up Oasis Mini switchs using config entry.""" # name="Repeat playlist",
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id] # ),
async_add_entities( # }
[
OasisMiniSwitchEntity(coordinator, entry, descriptor)
for descriptor in DESCRIPTORS
]
)

View File

@@ -6,8 +6,10 @@
"host": "Host" "host": "Host"
} }
}, },
"reauth_confirm": { "reconfigure": {
"data": {} "data": {
"host": "Host"
}
} }
}, },
"error": { "error": {
@@ -17,12 +19,14 @@
"unknown": "Unexpected error" "unknown": "Unexpected error"
}, },
"abort": { "abort": {
"already_configured": "Device is already configured" "already_configured": "Device is already configured",
"reconfigure_successful": "Re-configuration was successful"
} }
}, },
"options": { "options": {
"step": { "step": {
"init": { "init": {
"description": "Add your cloud credentials to get additional information about your device",
"data": { "data": {
"email": "Email", "email": "Email",
"password": "Password" "password": "Password"
@@ -32,5 +36,80 @@
"error": { "error": {
"invalid_auth": "Invalid authentication" "invalid_auth": "Invalid authentication"
} }
},
"entity": {
"button": {
"random_track": {
"name": "Play random track"
}
},
"binary_sensor": {
"busy": {
"name": "Busy"
},
"wifi_status": {
"name": "Wi-Fi status"
}
},
"light": {
"led": {
"name": "LED"
}
},
"number": {
"ball_speed": {
"name": "Ball speed"
},
"led_speed": {
"name": "LED speed"
}
},
"select": {
"autoplay": {
"name": "Autoplay"
},
"playlist": {
"name": "Playlist"
}
},
"sensor": {
"download_progress": {
"name": "Download progress"
},
"drawing_progress": {
"name": "Drawing progress"
},
"error": {
"name": "Error"
},
"led_color_id": {
"name": "LED color ID"
},
"status": {
"name": "Status",
"state": {
"booting": "Booting",
"stopped": "Stopped",
"centering": "Centering",
"playing": "Playing",
"paused": "Paused",
"error": "Error",
"updating": "Updating",
"downloading": "Downloading",
"live": "Live drawing"
}
}
}
},
"exceptions": {
"device_busy": {
"message": "{name} is currently busy and cannot be modified"
},
"invalid_media": {
"message": "Invalid media: {media}"
},
"playlists_unsupported": {
"message": "Playlists are not currently supported"
}
} }
} }

View File

@@ -0,0 +1,85 @@
"""Oasis Mini update entity."""
from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any
from homeassistant.components.update import (
UpdateDeviceClass,
UpdateEntity,
UpdateEntityDescription,
UpdateEntityFeature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
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: OasisMiniConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Oasis Mini updates using config entry."""
coordinator: OasisMiniCoordinator = entry.runtime_data
if coordinator.device.access_token:
async_add_entities([OasisMiniUpdateEntity(coordinator, DESCRIPTOR)], True)
DESCRIPTOR = UpdateEntityDescription(
key="software", device_class=UpdateDeviceClass.FIRMWARE
)
class OasisMiniUpdateEntity(OasisMiniEntity, UpdateEntity):
"""Oasis Mini update entity."""
_attr_supported_features = (
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
)
@property
def in_progress(self) -> bool | int:
"""Update installation progress."""
if self.device.status_code == 11:
return self.device.download_progress
return False
@property
def installed_version(self) -> str:
"""Version installed and in use."""
return self.device.software_version
@property
def should_poll(self) -> bool:
"""Set polling to True."""
return True
async def async_install(
self, version: str | None, backup: bool, **kwargs: Any
) -> None:
"""Install an update."""
version = await self.device.async_get_software_version()
if version == self.latest_version:
return
await self.device.async_upgrade()
async def async_update(self) -> None:
"""Update the entity."""
await self.device.async_get_software_version()
software = await self.device.async_cloud_get_latest_software_details()
if not software:
_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']}"

View File

@@ -1,6 +1,6 @@
{ {
"name": "Oasis Mini", "name": "Oasis Mini",
"homeassistant": "2024.7.0", "homeassistant": "2024.5.0",
"render_readme": true, "render_readme": true,
"zip_release": true, "zip_release": true,
"filename": "oasis_mini.zip" "filename": "oasis_mini.zip"

View File

@@ -4,3 +4,10 @@ known-first-party = ["homeassistant", "tests"]
forced-separate = ["tests"] forced-separate = ["tests"]
combine-as-imports = true combine-as-imports = true
split-on-trailing-comma = false split-on-trailing-comma = false
[tool.pylint."MESSAGES CONTROL"]
# abstract-method - with intro of async there are always methods missing
disable = [
"abstract-method",
"unexpected-keyword-arg",
]

View File

@@ -1,11 +1,11 @@
# Home Assistant # Home Assistant
homeassistant>=2024.4 homeassistant>=2025.1
home-assistant-frontend
numpy numpy
PyTurboJPEG PyTurboJPEG
# Integration # Integration
aiohttp aiohttp # should already be installed with Home Assistant
cryptography # should already be installed with Home Assistant
# Development # Development
colorlog colorlog

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())