mirror of
https://github.com/natekspencer/hacs-oasis_mini.git
synced 2025-11-13 15:43:52 -05:00
Compare commits
95 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50f7b270f2 | ||
|
|
802ce0f9a8 | ||
|
|
2f25218df5 | ||
|
|
de36b6ea67 | ||
|
|
4e370d441c | ||
|
|
cf8e744fa4 | ||
|
|
f04438cac8 | ||
|
|
8fbf7664b1 | ||
|
|
5d7176ebaa | ||
|
|
005a621816 | ||
|
|
2feba20b76 | ||
|
|
e2f5727669 | ||
|
|
8650fd597a | ||
|
|
7bef2cbe3b | ||
|
|
5ea472821b | ||
|
|
ab09bde752 | ||
|
|
f49b8ce1d2 | ||
|
|
cbbe8bc10d | ||
|
|
c2c62bb875 | ||
|
|
108b1850b7 | ||
|
|
ffc74a9dcb | ||
|
|
f67aee166a | ||
|
|
4ed6b1701d | ||
|
|
ade3e7c666 | ||
|
|
4c112f2b06 | ||
|
|
f850158a8e | ||
|
|
8bb8cf9447 | ||
|
|
1c8b2f052c | ||
|
|
73f96d8302 | ||
|
|
9cc1d6d314 | ||
|
|
4894e3549d | ||
|
|
221f314dd6 | ||
|
|
595621652a | ||
|
|
42040895e2 | ||
|
|
51c4c8a6a2 | ||
|
|
ddabccc4a8 | ||
|
|
94860106ea | ||
|
|
c4dd4f0499 | ||
|
|
2a5043298e | ||
|
|
8ee4076e8b | ||
|
|
09f4026480 | ||
|
|
20c320ecd6 | ||
|
|
36fff5ec16 | ||
|
|
d9cfb922c4 | ||
|
|
40a9c89cfc | ||
|
|
74ae6b9155 | ||
|
|
bfb058b0aa | ||
|
|
82ee3fe63b | ||
|
|
7b11c37ca8 | ||
|
|
389ab22215 | ||
|
|
9e2a423d4e | ||
|
|
04e98ee103 | ||
|
|
4945b1e6b7 | ||
|
|
88537ee3c7 | ||
|
|
d971cc55c6 | ||
|
|
739ee874d3 | ||
|
|
78de49e12c | ||
|
|
57280d46fc | ||
|
|
51c4cee3f6 | ||
|
|
782a794a32 | ||
|
|
2cd196f0f0 | ||
|
|
02a073943b | ||
|
|
c7a8732ad5 | ||
|
|
7b11d79de1 | ||
|
|
de64e61666 | ||
|
|
59134b0473 | ||
|
|
893ac4e327 | ||
|
|
37a18090b3 | ||
|
|
570e08c9a2 | ||
|
|
b1f211d843 | ||
|
|
99bf3b2ef0 | ||
|
|
3f4f7720c0 | ||
|
|
6e13c22d43 | ||
|
|
f5bf50a801 | ||
|
|
33e62528ba | ||
|
|
3014f0f11c | ||
|
|
a44c035828 | ||
|
|
31276048dc | ||
|
|
742fc26a4f | ||
|
|
3acd45da9d | ||
|
|
a736c72c8e | ||
|
|
c87bb241ef | ||
|
|
6ee81db9d4 | ||
|
|
6d6b7929d5 | ||
|
|
cc80c295f6 | ||
|
|
423e7eba9f | ||
|
|
d70dd0a650 | ||
|
|
cee752b6ce | ||
|
|
3b90603bef | ||
|
|
e77804ec0d | ||
|
|
96edafd006 | ||
|
|
71180f68f9 | ||
|
|
0d539888e5 | ||
|
|
4186755a92 | ||
|
|
7c8ca361ba |
@@ -1,8 +1,8 @@
|
||||
// See https://aka.ms/vscode-remote/devcontainer.json for format details.
|
||||
{
|
||||
"name": "Home Assistant integration development",
|
||||
"image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye",
|
||||
"postCreateCommand": "sudo apt-get update && sudo apt-get install libturbojpeg0",
|
||||
"image": "mcr.microsoft.com/devcontainers/python:1-3.13-bookworm",
|
||||
"postCreateCommand": "scripts/setup",
|
||||
"postAttachCommand": "scripts/setup",
|
||||
"forwardPorts": [8123],
|
||||
"customizations": {
|
||||
@@ -26,7 +26,10 @@
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": "always"
|
||||
},
|
||||
"files.trimTrailingWhitespace": true
|
||||
"files.trimTrailingWhitespace": true,
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "charliermarsh.ruff"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
11
.github/dependabot.yml
vendored
Normal file
11
.github/dependabot.yml
vendored
Normal 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
33
.github/workflows/update-tracks.yml
vendored
Normal 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
|
||||
10
.pre-commit-config.yaml
Normal file
10
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.9.10
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
# Run the formatter.
|
||||
- id: ruff-format
|
||||
21
README.md
21
README.md
@@ -1,6 +1,9 @@
|
||||

|
||||
[](https://github.com/natekspencer/hacs-oasis_mini/releases)
|
||||
[](https://ko-fi.com/natekspencer)
|
||||
[](https://github.com/hacs/integration)
|
||||
[](https://github.com/hacs/integration)
|
||||
|
||||

|
||||

|
||||
|
||||
<picture>
|
||||
<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.
|
||||
|
||||
# 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
|
||||
|
||||
@@ -4,6 +4,7 @@ automation:
|
||||
dhcp:
|
||||
frontend:
|
||||
history:
|
||||
isal:
|
||||
logbook:
|
||||
media_source:
|
||||
|
||||
|
||||
@@ -7,15 +7,19 @@ import logging
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
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 .coordinator import OasisMiniCoordinator
|
||||
from .helpers import create_client
|
||||
|
||||
type OasisMiniConfigEntry = ConfigEntry[OasisMiniCoordinator]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.IMAGE,
|
||||
Platform.LIGHT,
|
||||
@@ -23,13 +27,13 @@ PLATFORMS = [
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
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."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
client = create_client(entry.data | entry.options)
|
||||
coordinator = OasisMiniCoordinator(hass, client)
|
||||
|
||||
@@ -38,11 +42,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
except Exception as ex:
|
||||
_LOGGER.exception(ex)
|
||||
|
||||
if not entry.unique_id:
|
||||
if not (serial_number := coordinator.device.serial_number):
|
||||
dev_reg = dr.async_get(hass)
|
||||
devices = dr.async_entries_for_config_entry(dev_reg, entry.entry_id)
|
||||
serial_number = next(
|
||||
(
|
||||
identifier[1]
|
||||
for identifier in devices[0].identifiers
|
||||
if identifier[0] == DOMAIN
|
||||
),
|
||||
None,
|
||||
)
|
||||
hass.config_entries.async_update_entry(entry, unique_id=serial_number)
|
||||
|
||||
if not coordinator.data:
|
||||
await client.session.close()
|
||||
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)
|
||||
|
||||
@@ -50,15 +72,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: OasisMiniConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
await hass.data[DOMAIN][entry.entry_id].device.session.close()
|
||||
del hass.data[DOMAIN][entry.entry_id]
|
||||
return unload_ok
|
||||
await entry.runtime_data.device.session.close()
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
async def async_remove_entry(hass: HomeAssistant, entry: OasisMiniConfigEntry) -> None:
|
||||
"""Handle removal of an entry."""
|
||||
if entry.options:
|
||||
client = create_client(entry.data | entry.options)
|
||||
@@ -66,6 +86,6 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
await client.session.close()
|
||||
|
||||
|
||||
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
async def update_listener(hass: HomeAssistant, entry: OasisMiniConfigEntry) -> None:
|
||||
"""Handle options update."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
55
custom_components/oasis_mini/binary_sensor.py
Normal file
55
custom_components/oasis_mini/binary_sensor.py
Normal 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)
|
||||
@@ -11,25 +11,26 @@ from homeassistant.components.button import (
|
||||
ButtonEntity,
|
||||
ButtonEntityDescription,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import OasisMiniCoordinator
|
||||
from . import OasisMiniConfigEntry
|
||||
from .entity import OasisMiniEntity
|
||||
from .helpers import add_and_play_track
|
||||
from .pyoasismini import OasisMini
|
||||
from .pyoasismini.const import TRACKS
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
hass: HomeAssistant,
|
||||
entry: OasisMiniConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Oasis Mini button using config entry."""
|
||||
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_entities(
|
||||
[
|
||||
OasisMiniButtonEntity(coordinator, entry, descriptor)
|
||||
OasisMiniButtonEntity(entry.runtime_data, descriptor)
|
||||
for descriptor in DESCRIPTORS
|
||||
]
|
||||
)
|
||||
@@ -37,15 +38,8 @@ async def async_setup_entry(
|
||||
|
||||
async def play_random_track(device: OasisMini) -> None:
|
||||
"""Play random track."""
|
||||
track = int(random.choice(list(TRACKS)))
|
||||
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 (idx := device.playlist.index(track)) != (next_idx := device.playlist_index + 1):
|
||||
await device.async_move_track(idx, next_idx)
|
||||
await device.async_change_track(next_idx)
|
||||
await device.async_play()
|
||||
track = random.choice(list(TRACKS))
|
||||
await add_and_play_track(device, track)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
@@ -59,11 +53,12 @@ DESCRIPTORS = (
|
||||
OasisMiniButtonEntityDescription(
|
||||
key="reboot",
|
||||
device_class=ButtonDeviceClass.RESTART,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
press_fn=lambda device: device.async_reboot(),
|
||||
),
|
||||
OasisMiniButtonEntityDescription(
|
||||
key="random_track",
|
||||
name="Play random track",
|
||||
translation_key="random_track",
|
||||
press_fn=play_random_track,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -10,7 +10,8 @@ from aiohttp import ClientConnectorError
|
||||
from httpx import ConnectError, HTTPStatusError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.components import dhcp
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_HOST, CONF_PASSWORD
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.schema_config_entry_flow import (
|
||||
@@ -20,6 +21,7 @@ from homeassistant.helpers.schema_config_entry_flow import (
|
||||
SchemaOptionsFlowHandler,
|
||||
)
|
||||
|
||||
from . import OasisMiniConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .coordinator import OasisMiniCoordinator
|
||||
from .helpers import create_client
|
||||
@@ -37,9 +39,7 @@ async def cloud_login(
|
||||
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Cloud login."""
|
||||
coordinator: OasisMiniCoordinator = handler.parent_handler.hass.data[DOMAIN][
|
||||
handler.parent_handler.config_entry.entry_id
|
||||
]
|
||||
coordinator: OasisMiniCoordinator = handler.parent_handler.config_entry.runtime_data
|
||||
|
||||
try:
|
||||
await coordinator.device.async_cloud_login(
|
||||
@@ -63,28 +63,32 @@ class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
VERSION = 1
|
||||
|
||||
host: str | None = None
|
||||
serial_number: str | None = None
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry: ConfigEntry) -> SchemaOptionsFlowHandler:
|
||||
def async_get_options_flow(
|
||||
config_entry: OasisMiniConfigEntry,
|
||||
) -> SchemaOptionsFlowHandler:
|
||||
"""Get the options flow for this handler."""
|
||||
return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW)
|
||||
|
||||
# async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> ConfigFlowResult:
|
||||
# """Handle dhcp discovery."""
|
||||
# self.host = discovery_info.ip
|
||||
# self.name = discovery_info.hostname
|
||||
# await self.async_set_unique_id(discovery_info.macaddress)
|
||||
# self._abort_if_unique_id_configured(updates={CONF_HOST: self.host})
|
||||
# return await self.async_step_api_key()
|
||||
async def async_step_dhcp(
|
||||
self, discovery_info: dhcp.DhcpServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle DHCP discovery."""
|
||||
host = {CONF_HOST: discovery_info.ip}
|
||||
await self.validate_client(host)
|
||||
self._abort_if_unique_id_configured(updates=host)
|
||||
# This should never happen since we only listen to DHCP requests
|
||||
# for configured devices.
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""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
|
||||
@@ -106,26 +110,24 @@ class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
suggested_values: dict[str, Any] | None = None,
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle step setup."""
|
||||
if abort := self._abort_if_configured(user_input):
|
||||
return abort
|
||||
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
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(
|
||||
self.context.get("entry_id")
|
||||
):
|
||||
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)
|
||||
return self.async_abort(reason="reconfigure_successful")
|
||||
|
||||
return self.async_create_entry(
|
||||
title=f"Oasis Mini {self.serial_number}",
|
||||
data=data,
|
||||
title=f"Oasis Mini {self.unique_id}",
|
||||
data=user_input,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
@@ -139,9 +141,9 @@ class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors = {}
|
||||
try:
|
||||
async with asyncio.timeout(10):
|
||||
client = create_client({"host": self.host} | user_input)
|
||||
self.serial_number = await client.async_get_serial_number()
|
||||
if not self.serial_number:
|
||||
client = create_client(user_input)
|
||||
await self.async_set_unique_id(await client.async_get_serial_number())
|
||||
if not self.unique_id:
|
||||
errors["base"] = "invalid_host"
|
||||
except asyncio.TimeoutError:
|
||||
errors["base"] = "timeout_connect"
|
||||
@@ -157,15 +159,3 @@ class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
finally:
|
||||
await client.session.close()
|
||||
return errors
|
||||
|
||||
@callback
|
||||
def _abort_if_configured(
|
||||
self, user_input: dict[str, Any] | None
|
||||
) -> ConfigFlowResult | 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
|
||||
|
||||
@@ -8,7 +8,6 @@ import logging
|
||||
import async_timeout
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -20,28 +19,43 @@ _LOGGER = logging.getLogger(__name__)
|
||||
class OasisMiniCoordinator(DataUpdateCoordinator[str]):
|
||||
"""Oasis Mini data update coordinator."""
|
||||
|
||||
attempt: int = 0
|
||||
last_updated: datetime | None = None
|
||||
|
||||
def __init__(self, hass: HomeAssistant, device: OasisMini) -> None:
|
||||
"""Initialize."""
|
||||
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
|
||||
|
||||
async def _async_update_data(self):
|
||||
"""Update the data."""
|
||||
data: str | None = None
|
||||
self.attempt += 1
|
||||
|
||||
try:
|
||||
async with async_timeout.timeout(10):
|
||||
if not self.device.mac_address:
|
||||
await self.device.async_get_mac_address()
|
||||
if not self.device.serial_number:
|
||||
await self.device.async_get_serial_number()
|
||||
if not self.device.software_version:
|
||||
await self.device.async_get_software_version()
|
||||
data = await self.device.async_get_status()
|
||||
self.attempt = 0
|
||||
await self.device.async_get_current_track_details()
|
||||
except Exception as ex:
|
||||
raise UpdateFailed("Couldn't read from the Oasis Mini") from ex
|
||||
if data is None:
|
||||
raise ConfigEntryAuthFailed
|
||||
await self.device.async_get_playlist_details()
|
||||
except Exception as ex: # pylint:disable=broad-except
|
||||
if self.attempt > 2 or not (data or self.data):
|
||||
raise UpdateFailed(
|
||||
f"Couldn't read from the Oasis Mini after {self.attempt} attempts"
|
||||
) from ex
|
||||
|
||||
if data != self.data:
|
||||
self.last_updated = datetime.now()
|
||||
return data
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac
|
||||
from homeassistant.helpers.entity import DeviceInfo, EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
@@ -12,8 +10,6 @@ from .const import DOMAIN
|
||||
from .coordinator import OasisMiniCoordinator
|
||||
from .pyoasismini import OasisMini
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OasisMiniEntity(CoordinatorEntity[OasisMiniCoordinator]):
|
||||
"""Base class for Oasis Mini entities."""
|
||||
@@ -21,24 +17,23 @@ class OasisMiniEntity(CoordinatorEntity[OasisMiniCoordinator]):
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: OasisMiniCoordinator,
|
||||
entry: ConfigEntry,
|
||||
description: EntityDescription,
|
||||
self, coordinator: OasisMiniCoordinator, description: EntityDescription
|
||||
) -> None:
|
||||
"""Construct an Oasis Mini entity."""
|
||||
super().__init__(coordinator)
|
||||
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_device_info = DeviceInfo(
|
||||
connections={(CONNECTION_NETWORK_MAC, format_mac(device.mac_address))},
|
||||
identifiers={(DOMAIN, serial_number)},
|
||||
name=entry.title,
|
||||
name=f"Oasis Mini {serial_number}",
|
||||
manufacturer="Kinetic Oasis",
|
||||
model="Oasis Mini",
|
||||
serial_number=serial_number,
|
||||
sw_version=coordinator.device.software_version,
|
||||
sw_version=device.software_version,
|
||||
)
|
||||
|
||||
@property
|
||||
|
||||
@@ -2,13 +2,49 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
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:
|
||||
"""Create a Oasis Mini local client."""
|
||||
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
|
||||
|
||||
45
custom_components/oasis_mini/icons.json
Normal file
45
custom_components/oasis_mini/icons.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,15 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from homeassistant.components.image import ImageEntity, ImageEntityDescription
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.components.image import Image, ImageEntity, ImageEntityDescription
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import UNDEFINED
|
||||
|
||||
from .const import DOMAIN
|
||||
from . import OasisMiniConfigEntry
|
||||
from .coordinator import OasisMiniCoordinator
|
||||
from .entity import OasisMiniEntity
|
||||
from .pyoasismini.const import TRACKS
|
||||
from .pyoasismini.utils import draw_svg
|
||||
|
||||
IMAGE = ImageEntityDescription(key="image", name=None)
|
||||
@@ -21,35 +20,56 @@ class OasisMiniImageEntity(OasisMiniEntity, ImageEntity):
|
||||
"""Oasis Mini image entity."""
|
||||
|
||||
_attr_content_type = "image/svg+xml"
|
||||
_track_id: int | None = None
|
||||
_progress: int = 0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: OasisMiniCoordinator,
|
||||
entry_id: str,
|
||||
description: ImageEntityDescription,
|
||||
self, coordinator: OasisMiniCoordinator, description: ImageEntityDescription
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator, entry_id, description)
|
||||
super().__init__(coordinator, description)
|
||||
ImageEntity.__init__(self, coordinator.hass)
|
||||
|
||||
@property
|
||||
def image_last_updated(self) -> datetime | None:
|
||||
"""The time when the image was last updated."""
|
||||
return self.coordinator.last_updated
|
||||
self._handle_coordinator_update()
|
||||
|
||||
def image(self) -> bytes | None:
|
||||
"""Return bytes of image."""
|
||||
return draw_svg(
|
||||
self.device._current_track_details,
|
||||
self.device.progress,
|
||||
"1",
|
||||
)
|
||||
if not self._cached_image:
|
||||
self._cached_image = Image(
|
||||
self.content_type, draw_svg(self.device.track, self._progress, "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(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
hass: HomeAssistant,
|
||||
entry: OasisMiniConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Oasis Mini camera using config entry."""
|
||||
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
if coordinator.device.access_token:
|
||||
async_add_entities([OasisMiniImageEntity(coordinator, entry, IMAGE)])
|
||||
async_add_entities([OasisMiniImageEntity(entry.runtime_data, IMAGE)])
|
||||
|
||||
@@ -14,7 +14,6 @@ from homeassistant.components.light import (
|
||||
LightEntityDescription,
|
||||
LightEntityFeature,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.util.color import (
|
||||
@@ -24,8 +23,7 @@ from homeassistant.util.color import (
|
||||
value_to_brightness,
|
||||
)
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import OasisMiniCoordinator
|
||||
from . import OasisMiniConfigEntry
|
||||
from .entity import OasisMiniEntity
|
||||
from .pyoasismini import LED_EFFECTS
|
||||
|
||||
@@ -70,8 +68,10 @@ class OasisMiniLightEntity(OasisMiniEntity, LightEntity):
|
||||
return self.device.brightness > 0
|
||||
|
||||
@property
|
||||
def rgb_color(self) -> tuple[int, int, int]:
|
||||
def rgb_color(self) -> tuple[int, int, int] | None:
|
||||
"""Return the rgb color value [int, int, int]."""
|
||||
if not self.device.color:
|
||||
return None
|
||||
return rgb_hex_to_rgb_list(self.device.color.replace("#", ""))
|
||||
|
||||
@property
|
||||
@@ -106,12 +106,13 @@ class OasisMiniLightEntity(OasisMiniEntity, LightEntity):
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
|
||||
DESCRIPTOR = LightEntityDescription(key="led", name="LED")
|
||||
DESCRIPTOR = LightEntityDescription(key="led", translation_key="led")
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
hass: HomeAssistant,
|
||||
entry: OasisMiniConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Oasis Mini lights using config entry."""
|
||||
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_entities([OasisMiniLightEntity(coordinator, entry, DESCRIPTOR)])
|
||||
async_add_entities([OasisMiniLightEntity(entry.runtime_data, DESCRIPTOR)])
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"name": "Oasis Mini",
|
||||
"codeowners": ["@natekspencer"],
|
||||
"config_flow": true,
|
||||
"dhcp": [{ "registered_devices": true }],
|
||||
"documentation": "https://github.com/natekspencer/hacs-oasis_mini",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
MediaPlayerEnqueue,
|
||||
MediaPlayerEntity,
|
||||
MediaPlayerEntityDescription,
|
||||
MediaPlayerEntityFeature,
|
||||
@@ -13,26 +14,30 @@ from homeassistant.components.media_player import (
|
||||
MediaType,
|
||||
RepeatMode,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import OasisMiniConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .coordinator import OasisMiniCoordinator
|
||||
from .entity import OasisMiniEntity
|
||||
from .helpers import get_track_id
|
||||
from .pyoasismini.const import TRACKS
|
||||
|
||||
BRIGHTNESS_SCALE = (1, 200)
|
||||
|
||||
|
||||
class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
|
||||
"""Oasis Mini media player entity."""
|
||||
|
||||
_attr_media_image_remotely_accessible = True
|
||||
_attr_supported_features = (
|
||||
MediaPlayerEntityFeature.NEXT_TRACK
|
||||
| MediaPlayerEntityFeature.PAUSE
|
||||
MediaPlayerEntityFeature.PAUSE
|
||||
| MediaPlayerEntityFeature.PLAY
|
||||
| MediaPlayerEntityFeature.STOP
|
||||
| MediaPlayerEntityFeature.PREVIOUS_TRACK
|
||||
| MediaPlayerEntityFeature.NEXT_TRACK
|
||||
| MediaPlayerEntityFeature.PLAY_MEDIA
|
||||
| MediaPlayerEntityFeature.MEDIA_ENQUEUE
|
||||
| MediaPlayerEntityFeature.CLEAR_PLAYLIST
|
||||
| MediaPlayerEntityFeature.REPEAT_SET
|
||||
)
|
||||
|
||||
@@ -42,19 +47,17 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
|
||||
return MediaType.IMAGE
|
||||
|
||||
@property
|
||||
def media_duration(self) -> int:
|
||||
def media_duration(self) -> int | None:
|
||||
"""Duration of current playing media in seconds."""
|
||||
if (
|
||||
track := self.device._current_track_details
|
||||
) and "reduced_svg_content" in track:
|
||||
if (track := self.device.track) and "reduced_svg_content" in track:
|
||||
return track["reduced_svg_content"].get("1")
|
||||
return math.ceil(self.media_position / 0.99)
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_image_url(self) -> str | None:
|
||||
"""Image url of current playing media."""
|
||||
if not (track := self.device._current_track_details):
|
||||
track = TRACKS.get(str(self.device.current_track_id))
|
||||
if not (track := self.device.track):
|
||||
track = TRACKS.get(self.device.track_id)
|
||||
if track and "image" in track:
|
||||
return f"https://app.grounded.so/uploads/{track['image']}"
|
||||
return None
|
||||
@@ -70,41 +73,64 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
|
||||
return self.coordinator.last_updated
|
||||
|
||||
@property
|
||||
def media_title(self) -> str:
|
||||
def media_title(self) -> str | None:
|
||||
"""Title of current playing media."""
|
||||
if not (track := self.device._current_track_details):
|
||||
track = TRACKS.get(str(self.device.current_track_id), {})
|
||||
return track.get("name", f"Unknown Title (#{self.device.current_track_id})")
|
||||
if not self.device.track_id:
|
||||
return None
|
||||
if not (track := self.device.track):
|
||||
track = TRACKS.get(self.device.track_id, {})
|
||||
return track.get("name", f"Unknown Title (#{self.device.track_id})")
|
||||
|
||||
@property
|
||||
def repeat(self) -> RepeatMode:
|
||||
"""Return current repeat mode."""
|
||||
if self.device.repeat_playlist:
|
||||
return RepeatMode.ALL
|
||||
return RepeatMode.OFF
|
||||
return RepeatMode.ALL if self.device.repeat_playlist else RepeatMode.OFF
|
||||
|
||||
@property
|
||||
def state(self) -> MediaPlayerState:
|
||||
"""State of the player."""
|
||||
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):
|
||||
return MediaPlayerState.BUFFERING
|
||||
if status_code in (2, 5):
|
||||
return MediaPlayerState.PAUSED
|
||||
if status_code == 4:
|
||||
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:
|
||||
"""Send pause command."""
|
||||
self.abort_if_busy()
|
||||
await self.device.async_pause()
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_media_play(self) -> None:
|
||||
"""Send play command."""
|
||||
self.abort_if_busy()
|
||||
await self.device.async_play()
|
||||
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:
|
||||
"""Set repeat mode."""
|
||||
await self.device.async_set_repeat_playlist(
|
||||
@@ -113,20 +139,86 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
|
||||
)
|
||||
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:
|
||||
"""Send next track command."""
|
||||
self.abort_if_busy()
|
||||
if (index := self.device.playlist_index + 1) >= len(self.device.playlist):
|
||||
index = 0
|
||||
await self.device.async_change_track(index)
|
||||
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)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
hass: HomeAssistant,
|
||||
entry: OasisMiniConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Oasis Mini media_players using config entry."""
|
||||
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_entities([OasisMiniMediaPlayerEntity(coordinator, entry, DESCRIPTOR)])
|
||||
async_add_entities([OasisMiniMediaPlayerEntity(entry.runtime_data, DESCRIPTOR)])
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.number import NumberEntity, NumberEntityDescription
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.components.number import (
|
||||
NumberEntity,
|
||||
NumberEntityDescription,
|
||||
NumberMode,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import OasisMiniCoordinator
|
||||
from . import OasisMiniConfigEntry
|
||||
from .entity import OasisMiniEntity
|
||||
from .pyoasismini import BALL_SPEED_MAX, BALL_SPEED_MIN, LED_SPEED_MAX, LED_SPEED_MIN
|
||||
|
||||
|
||||
class OasisMiniNumberEntity(OasisMiniEntity, NumberEntity):
|
||||
@@ -32,27 +35,30 @@ class OasisMiniNumberEntity(OasisMiniEntity, NumberEntity):
|
||||
DESCRIPTORS = {
|
||||
NumberEntityDescription(
|
||||
key="ball_speed",
|
||||
name="Ball speed",
|
||||
native_max_value=800,
|
||||
native_min_value=200,
|
||||
translation_key="ball_speed",
|
||||
mode=NumberMode.SLIDER,
|
||||
native_max_value=BALL_SPEED_MAX,
|
||||
native_min_value=BALL_SPEED_MIN,
|
||||
),
|
||||
NumberEntityDescription(
|
||||
key="led_speed",
|
||||
name="LED speed",
|
||||
native_max_value=90,
|
||||
native_min_value=-90,
|
||||
translation_key="led_speed",
|
||||
mode=NumberMode.SLIDER,
|
||||
native_max_value=LED_SPEED_MAX,
|
||||
native_min_value=LED_SPEED_MIN,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
hass: HomeAssistant,
|
||||
entry: OasisMiniConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Oasis Mini numbers using config entry."""
|
||||
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_entities(
|
||||
[
|
||||
OasisMiniNumberEntity(coordinator, entry, descriptor)
|
||||
OasisMiniNumberEntity(entry.runtime_data, descriptor)
|
||||
for descriptor in DESCRIPTORS
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1,44 +1,39 @@
|
||||
"""Oasis Mini API client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Awaitable, Callable, Final
|
||||
from typing import Any, Awaitable, Final
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from aiohttp import ClientResponseError, ClientSession
|
||||
|
||||
from .utils import _bit_to_bool
|
||||
from .const import TRACKS
|
||||
from .utils import _bit_to_bool, _parse_int, decrypt_svg_content
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STATUS_CODE_MAP = {
|
||||
0: "booting", # maybe?
|
||||
2: "stopped",
|
||||
3: "centering",
|
||||
4: "running",
|
||||
4: "playing",
|
||||
5: "paused",
|
||||
9: "error",
|
||||
11: "updating",
|
||||
13: "downloading",
|
||||
15: "live",
|
||||
}
|
||||
|
||||
ATTRIBUTES: Final[list[tuple[str, Callable[[str], Any]]]] = [
|
||||
("status_code", int), # see status code map
|
||||
("error", str), # error, 0 = none, and 10 = ?, 18 = can't download?
|
||||
("ball_speed", int), # 200 - 800
|
||||
("playlist", lambda value: [int(track) for track in value.split(",")]), # noqa: E501 # comma separated track ids
|
||||
("playlist_index", int), # index of above
|
||||
("progress", int), # 0 - max svg path
|
||||
("led_effect", str), # led effect (code lookup)
|
||||
("led_color_id", str), # led color id?
|
||||
("led_speed", int), # -90 - 90
|
||||
("brightness", int), # noqa: E501 # 0 - 200 in app, but seems to be 0 (off) to 304 (max), then repeats
|
||||
("color", str), # hex color code
|
||||
("busy", _bit_to_bool), # noqa: E501 # device is busy (downloading track, centering, software update)?
|
||||
("download_progress", int), # 0 - 100%
|
||||
("max_brightness", int),
|
||||
("wifi_connected", _bit_to_bool),
|
||||
("repeat_playlist", _bit_to_bool),
|
||||
("pause_between_tracks", _bit_to_bool),
|
||||
]
|
||||
AUTOPLAY_MAP = {
|
||||
"0": "on",
|
||||
"1": "off",
|
||||
"2": "5 minutes",
|
||||
"3": "10 minutes",
|
||||
"4": "30 minutes",
|
||||
"5": "24 hours",
|
||||
}
|
||||
|
||||
LED_EFFECTS: Final[dict[str, str]] = {
|
||||
"0": "Solid",
|
||||
@@ -59,25 +54,37 @@ LED_EFFECTS: Final[dict[str, str]] = {
|
||||
}
|
||||
|
||||
CLOUD_BASE_URL = "https://app.grounded.so"
|
||||
CLOUD_API_URL = f"{CLOUD_BASE_URL}/api"
|
||||
|
||||
BALL_SPEED_MAX: Final = 400
|
||||
BALL_SPEED_MIN: Final = 100
|
||||
LED_SPEED_MAX: Final = 90
|
||||
LED_SPEED_MIN: Final = -90
|
||||
|
||||
|
||||
class OasisMini:
|
||||
"""Oasis Mini API client class."""
|
||||
|
||||
_access_token: str | None = None
|
||||
_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
|
||||
_software_version: str | None = None
|
||||
_track: dict | None = None
|
||||
|
||||
autoplay: str
|
||||
brightness: int
|
||||
color: str
|
||||
busy: bool
|
||||
color: str | None = None
|
||||
download_progress: int
|
||||
error: int
|
||||
led_effect: str
|
||||
led_speed: int
|
||||
max_brightness: int
|
||||
playlist: list[int]
|
||||
playlist_index: int
|
||||
progress: int
|
||||
repeat_playlist: bool
|
||||
status_code: int
|
||||
|
||||
def __init__(
|
||||
@@ -97,10 +104,20 @@ class OasisMini:
|
||||
return self._access_token
|
||||
|
||||
@property
|
||||
def current_track_id(self) -> int:
|
||||
"""Return the current track."""
|
||||
i = self.playlist_index
|
||||
return self.playlist[0] if i >= len(self.playlist) else self.playlist[i]
|
||||
def mac_address(self) -> str | None:
|
||||
"""Return the mac address."""
|
||||
return self._mac_address
|
||||
|
||||
@property
|
||||
def drawing_progress(self) -> float | None:
|
||||
"""Return the drawing progress percent."""
|
||||
if not (self.track and (svg_content := self.track.get("svg_content"))):
|
||||
return None
|
||||
svg_content = decrypt_svg_content(svg_content)
|
||||
paths = svg_content.split("L")
|
||||
total = self.track.get("reduced_svg_content", {}).get("1", len(paths))
|
||||
percent = (100 * self.progress) / total
|
||||
return percent
|
||||
|
||||
@property
|
||||
def serial_number(self) -> str | None:
|
||||
@@ -122,22 +139,60 @@ class OasisMini:
|
||||
"""Return the status."""
|
||||
return STATUS_CODE_MAP.get(self.status_code, f"Unknown ({self.status_code})")
|
||||
|
||||
@property
|
||||
def track(self) -> dict | None:
|
||||
"""Return the current track info."""
|
||||
if self._track and self._track.get("id") == self.track_id:
|
||||
return self._track
|
||||
return None
|
||||
|
||||
@property
|
||||
def track_id(self) -> int | None:
|
||||
"""Return the current track id."""
|
||||
if not self.playlist:
|
||||
return None
|
||||
i = self.playlist_index
|
||||
return self.playlist[0] if i >= len(self.playlist) else self.playlist[i]
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
"""Return the url."""
|
||||
return f"http://{self._host}/"
|
||||
|
||||
async def async_add_track_to_playlist(self, track: int) -> None:
|
||||
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.append(track)
|
||||
self.playlist.extend(track)
|
||||
|
||||
async def async_change_track(self, index: int) -> None:
|
||||
"""Change the track."""
|
||||
if index >= len(self.playlist):
|
||||
raise ValueError("Invalid selection")
|
||||
raise ValueError("Invalid index specified")
|
||||
await self._async_command(params={"CMDCHANGETRACK": index})
|
||||
|
||||
async def async_clear_playlist(self) -> None:
|
||||
"""Clear the playlist."""
|
||||
await self.async_set_playlist([])
|
||||
|
||||
async def async_get_ip_address(self) -> str | None:
|
||||
"""Get the ip address."""
|
||||
self._ip_address = await self._async_get(params={"GETIP": ""})
|
||||
_LOGGER.debug("IP address: %s", self._ip_address)
|
||||
return self._ip_address
|
||||
|
||||
async def async_get_mac_address(self) -> str | None:
|
||||
"""Get the mac address."""
|
||||
self._mac_address = await self._async_get(params={"GETMAC": ""})
|
||||
_LOGGER.debug("MAC address: %s", self._mac_address)
|
||||
return self._mac_address
|
||||
|
||||
async def async_get_serial_number(self) -> str | None:
|
||||
"""Get the serial number."""
|
||||
self._serial_number = await self._async_get(params={"GETOASISID": ""})
|
||||
@@ -150,16 +205,45 @@ class OasisMini:
|
||||
_LOGGER.debug("Software version: %s", 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."""
|
||||
status = await self._async_get(params={"GETSTATUS": ""})
|
||||
_LOGGER.debug("Status: %s", status)
|
||||
for index, value in enumerate(status.split(";")):
|
||||
attr, func = ATTRIBUTES[index]
|
||||
if (old_value := getattr(self, attr, None)) != (value := func(value)):
|
||||
_LOGGER.debug("%s changed: '%s' -> '%s'", attr, old_value, value)
|
||||
setattr(self, attr, value)
|
||||
return status
|
||||
raw_status = await self._async_get(params={"GETSTATUS": ""})
|
||||
_LOGGER.debug("Status: %s", raw_status)
|
||||
values = raw_status.split(";")
|
||||
playlist = [_parse_int(track) for track in values[3].split(",") if track]
|
||||
shift = len(values) - 18 if len(values) > 17 else 0
|
||||
status = {
|
||||
"status_code": _parse_int(values[0]), # see status code map
|
||||
"error": _parse_int(values[1]), # noqa: E501; error, 0 = none, and 10 = ?, 18 = can't download?
|
||||
"ball_speed": _parse_int(values[2]), # 200 - 1000
|
||||
"playlist": playlist,
|
||||
"playlist_index": min(_parse_int(values[4]), len(playlist)), # noqa: E501; index of above
|
||||
"progress": _parse_int(values[5]), # 0 - max svg path
|
||||
"led_effect": values[6], # led effect (code lookup)
|
||||
"led_color_id": values[7], # led color id?
|
||||
"led_speed": _parse_int(values[8]), # -90 - 90
|
||||
"brightness": _parse_int(values[9]), # noqa: E501; 0 - 200 in app, but seems to be 0 (off) to 304 (max), then repeats
|
||||
"color": values[10] if "#" in values[10] else None, # hex color code
|
||||
"busy": _bit_to_bool(values[11 + shift]), # noqa: E501; device is busy (downloading track, centering, software update)?
|
||||
"download_progress": _parse_int(values[12 + shift]),
|
||||
"max_brightness": _parse_int(values[13 + shift]),
|
||||
"wifi_connected": _bit_to_bool(values[14 + shift]),
|
||||
"repeat_playlist": _bit_to_bool(values[15 + shift]),
|
||||
"autoplay": AUTOPLAY_MAP.get(value := values[16 + shift], value),
|
||||
"autoclean": _bit_to_bool(values[17 + shift])
|
||||
if len(values) > 17
|
||||
else False,
|
||||
}
|
||||
for key, value in status.items():
|
||||
if (old_value := getattr(self, key, None)) != value:
|
||||
_LOGGER.debug(
|
||||
"%s changed: '%s' -> '%s'",
|
||||
key.replace("_", " ").capitalize(),
|
||||
old_value,
|
||||
value,
|
||||
)
|
||||
setattr(self, key, value)
|
||||
return raw_status
|
||||
|
||||
async def async_move_track(self, _from: int, _to: int) -> None:
|
||||
"""Move a track in the playlist."""
|
||||
@@ -171,7 +255,10 @@ class OasisMini:
|
||||
|
||||
async def async_play(self) -> None:
|
||||
"""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:
|
||||
"""Send reboot command."""
|
||||
@@ -187,8 +274,8 @@ class OasisMini:
|
||||
|
||||
async def async_set_ball_speed(self, speed: int) -> None:
|
||||
"""Set the Oasis Mini ball speed."""
|
||||
if not 200 <= speed <= 800:
|
||||
raise Exception("Invalid speed specified")
|
||||
if not BALL_SPEED_MIN <= speed <= BALL_SPEED_MAX:
|
||||
raise ValueError("Invalid speed specified")
|
||||
|
||||
await self._async_command(params={"WRIOASISSPEED": speed})
|
||||
|
||||
@@ -204,43 +291,53 @@ class OasisMini:
|
||||
if led_effect is None:
|
||||
led_effect = self.led_effect
|
||||
if color is None:
|
||||
color = self.color
|
||||
color = self.color or "#ffffff"
|
||||
if led_speed is None:
|
||||
led_speed = self.led_speed
|
||||
if brightness is None:
|
||||
brightness = self.brightness
|
||||
|
||||
if led_effect not in LED_EFFECTS:
|
||||
raise Exception("Invalid led effect specified")
|
||||
if not -90 <= led_speed <= 90:
|
||||
raise Exception("Invalid led speed specified")
|
||||
if not 0 <= brightness <= 200:
|
||||
raise Exception("Invalid brightness specified")
|
||||
raise ValueError("Invalid led effect specified")
|
||||
if not LED_SPEED_MIN <= led_speed <= LED_SPEED_MAX:
|
||||
raise ValueError("Invalid led speed specified")
|
||||
if not 0 <= brightness <= self.max_brightness:
|
||||
raise ValueError("Invalid brightness specified")
|
||||
|
||||
await self._async_command(
|
||||
params={"WRILED": f"{led_effect};0;{color};{led_speed};{brightness}"}
|
||||
)
|
||||
|
||||
async def async_set_pause_between_tracks(self, pause: bool) -> None:
|
||||
"""Set the Oasis Mini pause between tracks."""
|
||||
await self._async_command(params={"WRIWAITAFTER": 1 if pause else 0})
|
||||
async def async_set_autoplay(self, option: bool | int | str) -> None:
|
||||
"""Set autoplay."""
|
||||
if isinstance(option, bool):
|
||||
option = 0 if option else 1
|
||||
if str(option) not in AUTOPLAY_MAP:
|
||||
raise ValueError("Invalid pause option specified")
|
||||
await self._async_command(params={"WRIWAITAFTER": option})
|
||||
|
||||
async def async_set_playlist(self, playlist: list[int] | int) -> None:
|
||||
"""Set the playlist."""
|
||||
if isinstance(playlist, int):
|
||||
playlist = [playlist]
|
||||
if is_playing := (self.status_code == 4):
|
||||
await self.async_stop()
|
||||
await self._async_command(params={"WRIJOBLIST": ",".join(map(str, playlist))})
|
||||
self.playlist = playlist
|
||||
if is_playing:
|
||||
await self.async_play()
|
||||
|
||||
async def async_set_repeat_playlist(self, repeat: bool) -> None:
|
||||
"""Set the Oasis Mini repeat playlist."""
|
||||
"""Set repeat playlist."""
|
||||
await self._async_command(params={"WRIREPEATJOB": 1 if repeat else 0})
|
||||
|
||||
async def _async_command(self, **kwargs: Any) -> str | None:
|
||||
"""Send a command request."""
|
||||
result = await self._async_get(**kwargs)
|
||||
_LOGGER.debug("Result: %s", result)
|
||||
async def async_stop(self) -> None:
|
||||
"""Send stop command."""
|
||||
await self._async_command(params={"CMDSTOP": ""})
|
||||
|
||||
async def _async_get(self, **kwargs: Any) -> str | None:
|
||||
"""Perform a GET request."""
|
||||
response = await self._session.get(self.url, **kwargs)
|
||||
if response.status == 200 and response.content_type == "text/plain":
|
||||
text = await response.text()
|
||||
return text
|
||||
return None
|
||||
async def async_upgrade(self, beta: bool = False) -> None:
|
||||
"""Trigger a software upgrade."""
|
||||
await self._async_command(params={"CMDUPGRADE": 1 if beta else 0})
|
||||
|
||||
async def async_cloud_login(self, email: str, password: str) -> None:
|
||||
"""Login via the cloud."""
|
||||
@@ -253,59 +350,106 @@ class OasisMini:
|
||||
|
||||
async def async_cloud_logout(self) -> None:
|
||||
"""Login via the cloud."""
|
||||
if not self.access_token:
|
||||
return
|
||||
await self._async_request(
|
||||
"GET",
|
||||
urljoin(CLOUD_BASE_URL, "api/auth/logout"),
|
||||
headers={"Authorization": f"Bearer {self.access_token}"},
|
||||
)
|
||||
await self._async_cloud_request("GET", "api/auth/logout")
|
||||
|
||||
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."""
|
||||
try:
|
||||
return await self._async_cloud_request("GET", f"api/track/{track_id}")
|
||||
except ClientResponseError as err:
|
||||
if err.status == 404:
|
||||
return {"id": track_id, "name": f"Unknown Title (#{track_id})"}
|
||||
except Exception as ex:
|
||||
_LOGGER.exception(ex)
|
||||
return None
|
||||
|
||||
async def async_cloud_get_tracks(
|
||||
self, tracks: list[int] | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get tracks info from the cloud"""
|
||||
response = await self._async_cloud_request(
|
||||
"GET", "api/track", params={"ids[]": tracks or []}
|
||||
)
|
||||
if not response:
|
||||
return None
|
||||
track_details = response.get("data", [])
|
||||
while next_page_url := response.get("next_page_url"):
|
||||
response = await self._async_cloud_request("GET", next_page_url)
|
||||
track_details += response.get("data", [])
|
||||
return track_details
|
||||
|
||||
async def async_cloud_get_latest_software_details(self) -> dict[str, int | str]:
|
||||
"""Get the latest software details from the cloud."""
|
||||
return await self._async_cloud_request("GET", "api/software/last-version")
|
||||
|
||||
async def async_get_current_track_details(self) -> dict | None:
|
||||
"""Get current track info, refreshing if needed."""
|
||||
track_id = self.track_id
|
||||
if (track := self._track) and track.get("id") == track_id:
|
||||
return track
|
||||
if track_id:
|
||||
self._track = await self.async_cloud_get_track_info(track_id)
|
||||
if not self._track:
|
||||
self._track = TRACKS.get(
|
||||
track_id, {"id": track_id, "name": f"Unknown Title (#{track_id})"}
|
||||
)
|
||||
return self._track
|
||||
|
||||
async def async_get_playlist_details(self) -> dict[int, dict[str, str]]:
|
||||
"""Get playlist info."""
|
||||
if set(self.playlist).difference(self._playlist.keys()):
|
||||
tracks = await self.async_cloud_get_tracks(self.playlist)
|
||||
all_tracks = TRACKS | {
|
||||
track["id"]: {
|
||||
"name": track["name"],
|
||||
"author": ((track.get("author") or {}).get("person") or {}).get(
|
||||
"name", "Oasis Mini"
|
||||
),
|
||||
"image": track["image"],
|
||||
}
|
||||
for track in tracks
|
||||
}
|
||||
for track in self.playlist:
|
||||
self._playlist[track] = all_tracks.get(
|
||||
track, {"name": f"Unknown Title (#{track})"}
|
||||
)
|
||||
return self._playlist
|
||||
|
||||
async def _async_cloud_request(self, method: str, url: str, **kwargs: Any) -> Any:
|
||||
"""Perform a cloud request."""
|
||||
if not self.access_token:
|
||||
return
|
||||
|
||||
response = await self._async_request(
|
||||
"GET",
|
||||
urljoin(CLOUD_BASE_URL, f"api/track/{track_id}"),
|
||||
return await self._async_request(
|
||||
method,
|
||||
urljoin(CLOUD_BASE_URL, url),
|
||||
headers={"Authorization": f"Bearer {self.access_token}"},
|
||||
**kwargs,
|
||||
)
|
||||
return response
|
||||
|
||||
async def async_cloud_get_tracks(self, tracks: list[int]) -> None:
|
||||
"""Get cloud tracks."""
|
||||
if not self.access_token:
|
||||
return
|
||||
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)
|
||||
|
||||
response = await self._async_request(
|
||||
"GET",
|
||||
urljoin(CLOUD_BASE_URL, "api/track"),
|
||||
headers={"Authorization": f"Bearer {self.access_token}"},
|
||||
params={"ids[]": tracks},
|
||||
)
|
||||
return response
|
||||
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:
|
||||
"""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)
|
||||
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.text()
|
||||
if response.content_type == "text/plain":
|
||||
return await response.text()
|
||||
return None
|
||||
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
|
||||
)
|
||||
|
||||
async def async_get_playlist_details(self) -> dict:
|
||||
"""Get playlist info."""
|
||||
return await self.async_cloud_get_tracks(self.playlist)
|
||||
|
||||
@@ -4,8 +4,13 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Final
|
||||
from typing import Any, Final
|
||||
|
||||
__TRACKS_FILE = os.path.join(os.path.dirname(__file__), "tracks.json")
|
||||
with open(__TRACKS_FILE, "r", encoding="utf8") as file:
|
||||
TRACKS: Final[dict[str, dict[str, str]]] = json.load(file)
|
||||
try:
|
||||
with open(__TRACKS_FILE, "r", encoding="utf8") as file:
|
||||
TRACKS: Final[dict[int, dict[str, Any]]] = {
|
||||
int(k): v for k, v in json.load(file).items()
|
||||
}
|
||||
except Exception: # ignore: broad-except
|
||||
TRACKS = {}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,17 @@
|
||||
"""Oasis Mini utils."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import math
|
||||
from xml.etree.ElementTree import Element, SubElement, tostring
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
APP_KEY = "5joW8W4Usk4xUXu5bIIgGiHloQmzMZUMgz6NWQnNI04="
|
||||
|
||||
BACKGROUND_FILL = ("#CCC9C4", "#28292E")
|
||||
COLOR_DARK = ("#28292E", "#F4F5F8")
|
||||
@@ -20,15 +26,24 @@ def _bit_to_bool(val: str) -> bool:
|
||||
return val == "1"
|
||||
|
||||
|
||||
def _parse_int(val: str) -> int:
|
||||
"""Convert an int string to int."""
|
||||
try:
|
||||
return int(val)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def draw_svg(track: dict, progress: int, model_id: str) -> str | None:
|
||||
"""Draw SVG."""
|
||||
if track and (svg_content := track.get("svg_content")):
|
||||
try:
|
||||
if progress is not None:
|
||||
svg_content = decrypt_svg_content(svg_content)
|
||||
paths = svg_content.split("L")
|
||||
total = track.get("reduced_svg_content", {}).get(model_id, len(paths))
|
||||
percent = (100 * progress) / total
|
||||
progress = math.floor((percent / 100) * len(paths))
|
||||
percent = min((100 * progress) / total, 100)
|
||||
progress = math.floor((percent / 100) * (len(paths) - 1))
|
||||
|
||||
svg = Element(
|
||||
"svg",
|
||||
@@ -56,7 +71,7 @@ def draw_svg(track: dict, progress: int, model_id: str) -> str | None:
|
||||
path.progress_arc_complete {{ stroke: {COLOR_DARK[1]}; }}
|
||||
path.track {{ stroke: {COLOR_LIGHT_SHADE[1]}; }}
|
||||
path.track_complete {{ stroke: {COLOR_MEDIUM_TINT[1]}; }}
|
||||
}}"""
|
||||
}}""".replace("\n", " ").strip()
|
||||
|
||||
group = SubElement(
|
||||
svg,
|
||||
@@ -137,3 +152,28 @@ def draw_svg(track: dict, progress: int, model_id: str) -> str | None:
|
||||
except Exception as e:
|
||||
_LOGGER.exception(e)
|
||||
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
|
||||
|
||||
@@ -2,56 +2,115 @@
|
||||
|
||||
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.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from . import OasisMiniConfigEntry
|
||||
from .coordinator import OasisMiniCoordinator
|
||||
from .entity import OasisMiniEntity
|
||||
from .pyoasismini import AUTOPLAY_MAP, OasisMini
|
||||
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):
|
||||
"""Oasis Mini select entity."""
|
||||
|
||||
entity_description: OasisMiniSelectEntityDescription
|
||||
_current_value: Any | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: OasisMiniCoordinator,
|
||||
entry: ConfigEntry[Any],
|
||||
description: EntityDescription,
|
||||
) -> None:
|
||||
"""Construct an Oasis Mini select entity."""
|
||||
super().__init__(coordinator, entry, description)
|
||||
super().__init__(coordinator, description)
|
||||
self._handle_coordinator_update()
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""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()
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
options = [
|
||||
TRACKS.get(str(track), {}).get("name", str(track))
|
||||
for track in self.device.playlist
|
||||
]
|
||||
self._attr_options = options
|
||||
self._attr_current_option = options[self.device.playlist_index]
|
||||
new_value = self.entity_description.current_value(self.device)
|
||||
if self._current_value == new_value:
|
||||
return
|
||||
self._current_value = new_value
|
||||
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(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
hass: HomeAssistant,
|
||||
entry: OasisMiniConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Oasis Mini select using config entry."""
|
||||
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_entities([OasisMiniSelectEntity(coordinator, entry, DESCRIPTOR)])
|
||||
async_add_entities(
|
||||
[
|
||||
OasisMiniSelectEntity(entry.runtime_data, descriptor)
|
||||
for descriptor in DESCRIPTORS
|
||||
]
|
||||
)
|
||||
|
||||
@@ -7,53 +7,65 @@ from homeassistant.components.sensor import (
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from . import OasisMiniConfigEntry
|
||||
from .coordinator import OasisMiniCoordinator
|
||||
from .entity import OasisMiniEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
hass: HomeAssistant,
|
||||
entry: OasisMiniConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Oasis Mini sensors using config entry."""
|
||||
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_entities(
|
||||
[
|
||||
OasisMiniSensorEntity(coordinator, entry, descriptor)
|
||||
for descriptor in DESCRIPTORS
|
||||
]
|
||||
)
|
||||
coordinator: OasisMiniCoordinator = entry.runtime_data
|
||||
entities = [
|
||||
OasisMiniSensorEntity(coordinator, descriptor) for descriptor in DESCRIPTORS
|
||||
]
|
||||
if coordinator.device.access_token:
|
||||
entities.extend(
|
||||
[
|
||||
OasisMiniSensorEntity(coordinator, descriptor)
|
||||
for descriptor in CLOUD_DESCRIPTORS
|
||||
]
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
DESCRIPTORS = {
|
||||
SensorEntityDescription(
|
||||
key="download_progress",
|
||||
translation_key="download_progress",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
name="Download progress",
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
} | {
|
||||
SensorEntityDescription(
|
||||
key=key,
|
||||
name=key.replace("_", " ").capitalize(),
|
||||
translation_key=key,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
)
|
||||
for key in (
|
||||
"busy",
|
||||
"error",
|
||||
"led_color_id",
|
||||
"status",
|
||||
"wifi_connected",
|
||||
)
|
||||
for key in ("error", "led_color_id", "status")
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class OasisMiniSensorEntity(OasisMiniEntity, SensorEntity):
|
||||
"""Oasis Mini sensor entity."""
|
||||
|
||||
@@ -10,9 +10,6 @@
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"data": {}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -23,13 +20,13 @@
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "Add your cloud credentials to get additional information about your device",
|
||||
"data": {
|
||||
"email": "[%key:common::config_flow::data::email%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
@@ -39,5 +36,80 @@
|
||||
"error": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
# from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
# from homeassistant.core import HomeAssistant
|
||||
# from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import OasisMiniCoordinator
|
||||
from .entity import OasisMiniEntity
|
||||
# from . import OasisMiniConfigEntry
|
||||
# from .entity import OasisMiniEntity
|
||||
|
||||
|
||||
class OasisMiniSwitchEntity(OasisMiniEntity, SwitchEntity):
|
||||
"""Oasis Mini switch entity."""
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return True if entity is on."""
|
||||
return int(getattr(self.device, self.entity_description.key))
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the entity off."""
|
||||
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()
|
||||
# async def async_setup_entry(
|
||||
# hass: HomeAssistant,
|
||||
# entry: OasisMiniConfigEntry,
|
||||
# async_add_entities: AddEntitiesCallback,
|
||||
# ) -> None:
|
||||
# """Set up Oasis Mini switchs using config entry."""
|
||||
# async_add_entities(
|
||||
# [
|
||||
# OasisMiniSwitchEntity(entry.runtime_data, descriptor)
|
||||
# for descriptor in DESCRIPTORS
|
||||
# ]
|
||||
# )
|
||||
|
||||
|
||||
DESCRIPTORS = {
|
||||
SwitchEntityDescription(
|
||||
key="pause_between_tracks",
|
||||
name="Pause between tracks",
|
||||
),
|
||||
# SwitchEntityDescription(
|
||||
# key="repeat_playlist",
|
||||
# name="Repeat playlist",
|
||||
# ),
|
||||
}
|
||||
# class OasisMiniSwitchEntity(OasisMiniEntity, SwitchEntity):
|
||||
# """Oasis Mini switch entity."""
|
||||
|
||||
# @property
|
||||
# def is_on(self) -> bool:
|
||||
# """Return True if entity is on."""
|
||||
# return int(getattr(self.device, self.entity_description.key))
|
||||
|
||||
# async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
# """Turn the entity off."""
|
||||
# await self.device.async_set_repeat_playlist(False)
|
||||
# await self.coordinator.async_request_refresh()
|
||||
|
||||
# async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
# """Turn the entity on."""
|
||||
# await self.device.async_set_repeat_playlist(True)
|
||||
# await self.coordinator.async_request_refresh()
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
"""Set up Oasis Mini switchs using config entry."""
|
||||
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_entities(
|
||||
[
|
||||
OasisMiniSwitchEntity(coordinator, entry, descriptor)
|
||||
for descriptor in DESCRIPTORS
|
||||
]
|
||||
)
|
||||
# DESCRIPTORS = {
|
||||
# SwitchEntityDescription(
|
||||
# key="repeat_playlist",
|
||||
# name="Repeat playlist",
|
||||
# ),
|
||||
# }
|
||||
|
||||
@@ -10,9 +10,6 @@
|
||||
"data": {
|
||||
"host": "Host"
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"data": {}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -23,13 +20,13 @@
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Device is already configured",
|
||||
"reauth_successful": "Re-authentication was successful",
|
||||
"reconfigure_successful": "Re-configuration was successful"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "Add your cloud credentials to get additional information about your device",
|
||||
"data": {
|
||||
"email": "Email",
|
||||
"password": "Password"
|
||||
@@ -39,5 +36,80 @@
|
||||
"error": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
85
custom_components/oasis_mini/update.py
Normal file
85
custom_components/oasis_mini/update.py
Normal 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']}"
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Oasis Mini",
|
||||
"homeassistant": "2024.4.0",
|
||||
"homeassistant": "2024.5.0",
|
||||
"render_readme": true,
|
||||
"zip_release": true,
|
||||
"filename": "oasis_mini.zip"
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
# Home Assistant
|
||||
homeassistant>=2024.4
|
||||
home-assistant-frontend
|
||||
homeassistant>=2025.1
|
||||
numpy
|
||||
PyTurboJPEG
|
||||
|
||||
# Integration
|
||||
aiohttp
|
||||
aiohttp # should already be installed with Home Assistant
|
||||
cryptography # should already be installed with Home Assistant
|
||||
|
||||
# Development
|
||||
colorlog
|
||||
pip>=21.0
|
||||
pre-commit
|
||||
ruff
|
||||
@@ -1,9 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
sudo apt-get update && sudo apt-get install libturbojpeg0 libpcap0.8 -y
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
python3 -m pip install --requirement requirements.txt --upgrade
|
||||
|
||||
pre-commit install
|
||||
|
||||
mkdir -p config
|
||||
69
update_tracks.py
Normal file
69
update_tracks.py
Normal 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())
|
||||
Reference in New Issue
Block a user