mirror of
https://github.com/natekspencer/hacs-oasis_mini.git
synced 2025-12-07 02:54:12 -05:00
Compare commits
154 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecad472bbd | ||
|
|
886d7598f3 | ||
|
|
171a608314 | ||
|
|
5f01397b56 | ||
|
|
b56d7fe805 | ||
|
|
1eecef9299 | ||
|
|
bd7e3831a7 | ||
|
|
11f7a38b04 | ||
|
|
152879f8e0 | ||
|
|
4a07fa3ebb | ||
|
|
2687f1e597 | ||
|
|
a4c6fd57dd | ||
|
|
0cab687cef | ||
|
|
581f41c517 | ||
|
|
7705d61a4f | ||
|
|
3a8e274d26 | ||
|
|
6c6ce70932 | ||
|
|
8a72aba294 | ||
|
|
9949241c84 | ||
|
|
b07fc68b21 | ||
|
|
91d03f11a8 | ||
|
|
4d2c7a0199 | ||
|
|
7c650949d8 | ||
|
|
2d37fb691f | ||
|
|
21fd8a63ba | ||
|
|
552339665f | ||
|
|
85449a5363 | ||
|
|
d2bc89bdd7 | ||
|
|
06008e8f4c | ||
|
|
9fdfd8129f | ||
|
|
f9237927d9 | ||
|
|
dcd8db52f5 | ||
|
|
86cf060af0 | ||
|
|
d7a803abc7 | ||
|
|
a1bb4c78fb | ||
|
|
b5b3e691e2 | ||
|
|
52b741fb71 | ||
|
|
dc9f21b332 | ||
|
|
002898de97 | ||
|
|
1296b309d4 | ||
|
|
9cb8b6d398 | ||
|
|
a6022df49d | ||
|
|
839ba6ff35 | ||
|
|
39b333be8e | ||
|
|
2afb8acf0e | ||
|
|
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 | ||
|
|
07446f56da | ||
|
|
bd5b2e876d | ||
|
|
36da0249b7 | ||
|
|
bcc8547e3e | ||
|
|
e678b20990 | ||
|
|
cda435070d | ||
|
|
9b85d939c4 | ||
|
|
4eb86c5541 | ||
|
|
e35ae0d4fa | ||
|
|
21105e497a | ||
|
|
c14e882dc8 | ||
|
|
10fcfb8a9f | ||
|
|
33faf66109 | ||
|
|
e5c979fab4 |
@@ -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-bookworm",
|
||||||
"postCreateCommand": "sudo apt-get update && sudo apt-get install libturbojpeg0",
|
"postCreateCommand": "scripts/setup",
|
||||||
"postAttachCommand": ".devcontainer/setup",
|
"postAttachCommand": "scripts/setup",
|
||||||
"forwardPorts": [8123],
|
"forwardPorts": [8123],
|
||||||
"customizations": {
|
"customizations": {
|
||||||
"vscode": {
|
"vscode": {
|
||||||
@@ -26,7 +26,10 @@
|
|||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.organizeImports": "always"
|
"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"
|
||||||
41
.github/workflows/update-tracks.yml
vendored
Normal file
41
.github/workflows/update-tracks.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
name: Update tracks
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 19 * * 1"
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
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
|
||||||
22
.github/workflows/validate.yaml
vendored
Normal file
22
.github/workflows/validate.yaml
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
name: Validate repo
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 0 * * *"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
hassfest:
|
||||||
|
name: Validate with hassfest
|
||||||
|
runs-on: "ubuntu-latest"
|
||||||
|
steps:
|
||||||
|
- uses: "actions/checkout@v4"
|
||||||
|
- uses: "home-assistant/actions/hassfest@master"
|
||||||
|
hacs:
|
||||||
|
name: Validate with HACS
|
||||||
|
runs-on: "ubuntu-latest"
|
||||||
|
steps:
|
||||||
|
- uses: "hacs/action@main"
|
||||||
|
with:
|
||||||
|
category: "integration"
|
||||||
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://ko-fi.com/natekspencer)
|
||||||
[](https://github.com/hacs/integration)
|
[](https://github.com/hacs/integration)
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
<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
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ automation:
|
|||||||
dhcp:
|
dhcp:
|
||||||
frontend:
|
frontend:
|
||||||
history:
|
history:
|
||||||
|
isal:
|
||||||
logbook:
|
logbook:
|
||||||
media_source:
|
media_source:
|
||||||
|
|
||||||
|
|||||||
@@ -1,64 +1,148 @@
|
|||||||
"""Support for Oasis Mini."""
|
"""Support for Oasis devices."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import CONF_EMAIL, Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||||
|
import homeassistant.helpers.entity_registry as er
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .coordinator import OasisDeviceCoordinator
|
||||||
from .coordinator import OasisMiniCoordinator
|
|
||||||
from .helpers import create_client
|
from .helpers import create_client
|
||||||
|
from .pyoasiscontrol import OasisMqttClient, UnauthenticatedError
|
||||||
|
|
||||||
|
type OasisDeviceConfigEntry = ConfigEntry[OasisDeviceCoordinator]
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
PLATFORMS = [
|
PLATFORMS = [
|
||||||
|
Platform.BINARY_SENSOR,
|
||||||
|
Platform.BUTTON,
|
||||||
Platform.IMAGE,
|
Platform.IMAGE,
|
||||||
Platform.LIGHT,
|
Platform.LIGHT,
|
||||||
Platform.MEDIA_PLAYER,
|
Platform.MEDIA_PLAYER,
|
||||||
Platform.NUMBER,
|
Platform.NUMBER,
|
||||||
|
Platform.SELECT,
|
||||||
Platform.SENSOR,
|
Platform.SENSOR,
|
||||||
Platform.SWITCH,
|
Platform.UPDATE,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: OasisDeviceConfigEntry) -> bool:
|
||||||
"""Set up Oasis Mini from a config entry."""
|
"""Set up Oasis devices from a config entry."""
|
||||||
hass.data.setdefault(DOMAIN, {})
|
cloud_client = create_client(hass, entry.data)
|
||||||
client = create_client(entry.data | entry.options)
|
try:
|
||||||
coordinator = OasisMiniCoordinator(hass, client)
|
user = await cloud_client.async_get_user()
|
||||||
|
except UnauthenticatedError as err:
|
||||||
|
raise ConfigEntryAuthFailed(err) from err
|
||||||
|
|
||||||
|
mqtt_client = OasisMqttClient()
|
||||||
|
mqtt_client.start()
|
||||||
|
|
||||||
|
coordinator = OasisDeviceCoordinator(hass, cloud_client, mqtt_client)
|
||||||
|
|
||||||
|
try:
|
||||||
await coordinator.async_config_entry_first_refresh()
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
except Exception as ex:
|
||||||
|
_LOGGER.exception(ex)
|
||||||
|
|
||||||
|
if entry.unique_id != (user_id := str(user["id"])):
|
||||||
|
hass.config_entries.async_update_entry(entry, unique_id=user_id)
|
||||||
|
|
||||||
if not coordinator.data:
|
if not coordinator.data:
|
||||||
raise ConfigEntryNotReady
|
_LOGGER.warning("No devices associated with account")
|
||||||
|
|
||||||
hass.data[DOMAIN][entry.entry_id] = coordinator
|
entry.runtime_data = coordinator
|
||||||
|
|
||||||
|
def _on_oasis_update() -> None:
|
||||||
|
coordinator.async_update_listeners()
|
||||||
|
|
||||||
|
for device in coordinator.data:
|
||||||
|
device.add_update_listener(_on_oasis_update)
|
||||||
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
entry.async_on_unload(entry.add_update_listener(update_listener))
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_unload_entry(
|
||||||
|
hass: HomeAssistant, entry: OasisDeviceConfigEntry
|
||||||
|
) -> bool:
|
||||||
"""Unload a config entry."""
|
"""Unload a config entry."""
|
||||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
mqtt_client = entry.runtime_data.mqtt_client
|
||||||
await hass.data[DOMAIN][entry.entry_id].device.session.close()
|
await mqtt_client.async_close()
|
||||||
del hass.data[DOMAIN][entry.entry_id]
|
|
||||||
return unload_ok
|
cloud_client = entry.runtime_data.cloud_client
|
||||||
|
await cloud_client.async_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: OasisDeviceConfigEntry
|
||||||
|
) -> None:
|
||||||
"""Handle removal of an entry."""
|
"""Handle removal of an entry."""
|
||||||
if entry.options:
|
cloud_client = create_client(hass, entry.data)
|
||||||
client = create_client(entry.data | entry.options)
|
try:
|
||||||
await client.async_cloud_logout()
|
await cloud_client.async_logout()
|
||||||
await client.session.close()
|
except Exception as ex:
|
||||||
|
_LOGGER.exception(ex)
|
||||||
|
await cloud_client.async_close()
|
||||||
|
|
||||||
|
|
||||||
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
async def async_migrate_entry(hass: HomeAssistant, entry: OasisDeviceConfigEntry):
|
||||||
"""Handle options update."""
|
"""Migrate old entry."""
|
||||||
await hass.config_entries.async_reload(entry.entry_id)
|
_LOGGER.debug(
|
||||||
|
"Migrating configuration from version %s.%s", entry.version, entry.minor_version
|
||||||
|
)
|
||||||
|
|
||||||
|
if entry.version > 1:
|
||||||
|
# This means the user has downgraded from a future version
|
||||||
|
return False
|
||||||
|
|
||||||
|
if entry.version == 1:
|
||||||
|
new_data = {**entry.data}
|
||||||
|
new_options = {**entry.options}
|
||||||
|
|
||||||
|
if entry.minor_version < 2:
|
||||||
|
# Need to update previous playlist select entity to queue
|
||||||
|
@callback
|
||||||
|
def migrate_unique_id(
|
||||||
|
entity_entry: er.RegistryEntry,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Migrate the playlist unique ID to queue."""
|
||||||
|
if entity_entry.domain == "select" and entity_entry.unique_id.endswith(
|
||||||
|
"-playlist"
|
||||||
|
):
|
||||||
|
unique_id = entity_entry.unique_id.replace("-playlist", "-queue")
|
||||||
|
return {"new_unique_id": unique_id}
|
||||||
|
return None
|
||||||
|
|
||||||
|
await er.async_migrate_entries(hass, entry.entry_id, migrate_unique_id)
|
||||||
|
|
||||||
|
if entry.minor_version < 3:
|
||||||
|
# Auth is now required, host is dropped
|
||||||
|
new_data = {**entry.options}
|
||||||
|
new_options = {}
|
||||||
|
|
||||||
|
hass.config_entries.async_update_entry(
|
||||||
|
entry,
|
||||||
|
data=new_data,
|
||||||
|
options=new_options,
|
||||||
|
minor_version=3,
|
||||||
|
title=new_data.get(CONF_EMAIL, "Oasis Control"),
|
||||||
|
unique_id=None,
|
||||||
|
version=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Migration to configuration version %s.%s successful",
|
||||||
|
entry.version,
|
||||||
|
entry.minor_version,
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|||||||
56
custom_components/oasis_mini/binary_sensor.py
Normal file
56
custom_components/oasis_mini/binary_sensor.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""Oasis device 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 OasisDeviceConfigEntry
|
||||||
|
from .coordinator import OasisDeviceCoordinator
|
||||||
|
from .entity import OasisDeviceEntity
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: OasisDeviceConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up Oasis device sensors using config entry."""
|
||||||
|
coordinator: OasisDeviceCoordinator = entry.runtime_data
|
||||||
|
async_add_entities(
|
||||||
|
OasisDeviceBinarySensorEntity(coordinator, device, descriptor)
|
||||||
|
for device in coordinator.data
|
||||||
|
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 OasisDeviceBinarySensorEntity(OasisDeviceEntity, BinarySensorEntity):
|
||||||
|
"""Oasis device 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)
|
||||||
80
custom_components/oasis_mini/button.py
Normal file
80
custom_components/oasis_mini/button.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""Oasis device button entity."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import random
|
||||||
|
from typing import Awaitable, Callable
|
||||||
|
|
||||||
|
from homeassistant.components.button import (
|
||||||
|
ButtonDeviceClass,
|
||||||
|
ButtonEntity,
|
||||||
|
ButtonEntityDescription,
|
||||||
|
)
|
||||||
|
from homeassistant.const import EntityCategory
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
|
from . import OasisDeviceConfigEntry
|
||||||
|
from .coordinator import OasisDeviceCoordinator
|
||||||
|
from .entity import OasisDeviceEntity
|
||||||
|
from .helpers import add_and_play_track
|
||||||
|
from .pyoasiscontrol import OasisDevice
|
||||||
|
from .pyoasiscontrol.const import TRACKS
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: OasisDeviceConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up Oasis device button using config entry."""
|
||||||
|
coordinator: OasisDeviceCoordinator = entry.runtime_data
|
||||||
|
async_add_entities(
|
||||||
|
OasisDeviceButtonEntity(coordinator, device, descriptor)
|
||||||
|
for device in coordinator.data
|
||||||
|
for descriptor in DESCRIPTORS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def play_random_track(device: OasisDevice) -> None:
|
||||||
|
"""Play random track."""
|
||||||
|
track = random.choice(list(TRACKS))
|
||||||
|
await add_and_play_track(device, track)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, kw_only=True)
|
||||||
|
class OasisDeviceButtonEntityDescription(ButtonEntityDescription):
|
||||||
|
"""Oasis device button entity description."""
|
||||||
|
|
||||||
|
press_fn: Callable[[OasisDevice], Awaitable[None]]
|
||||||
|
|
||||||
|
|
||||||
|
DESCRIPTORS = (
|
||||||
|
OasisDeviceButtonEntityDescription(
|
||||||
|
key="reboot",
|
||||||
|
device_class=ButtonDeviceClass.RESTART,
|
||||||
|
entity_category=EntityCategory.CONFIG,
|
||||||
|
press_fn=lambda device: device.async_reboot(),
|
||||||
|
),
|
||||||
|
OasisDeviceButtonEntityDescription(
|
||||||
|
key="random_track",
|
||||||
|
translation_key="random_track",
|
||||||
|
press_fn=play_random_track,
|
||||||
|
),
|
||||||
|
OasisDeviceButtonEntityDescription(
|
||||||
|
key="sleep",
|
||||||
|
translation_key="sleep",
|
||||||
|
press_fn=lambda device: device.async_sleep(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OasisDeviceButtonEntity(OasisDeviceEntity, ButtonEntity):
|
||||||
|
"""Oasis device button entity."""
|
||||||
|
|
||||||
|
entity_description: OasisDeviceButtonEntityDescription
|
||||||
|
|
||||||
|
async def async_press(self) -> None:
|
||||||
|
"""Press the button."""
|
||||||
|
await self.entity_description.press_fn(self.device)
|
||||||
@@ -1,153 +1,137 @@
|
|||||||
"""Config flow for Oasis Mini integration."""
|
"""Config flow for Oasis device integration."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any, Mapping
|
||||||
|
|
||||||
from aiohttp import ClientConnectorError
|
from aiohttp import ClientConnectorError
|
||||||
from httpx import ConnectError, HTTPStatusError
|
from httpx import ConnectError, HTTPStatusError
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry, ConfigFlow
|
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_HOST, CONF_PASSWORD
|
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD
|
||||||
from homeassistant.core import callback
|
|
||||||
from homeassistant.data_entry_flow import FlowResult
|
|
||||||
from homeassistant.helpers.schema_config_entry_flow import (
|
|
||||||
SchemaCommonFlowHandler,
|
|
||||||
SchemaFlowError,
|
|
||||||
SchemaFlowFormStep,
|
|
||||||
SchemaOptionsFlowHandler,
|
|
||||||
)
|
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .coordinator import OasisMiniCoordinator
|
|
||||||
from .helpers import create_client
|
from .helpers import create_client
|
||||||
|
from .pyoasiscontrol import UnauthenticatedError
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||||
STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
|
{vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str}
|
||||||
OPTIONS_SCHEMA = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Optional(CONF_EMAIL): str,
|
|
||||||
vol.Optional(CONF_PASSWORD): str,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def cloud_login(
|
class OasisDeviceConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
|
"""Handle a config flow for Oasis devices."""
|
||||||
) -> dict[str, Any]:
|
|
||||||
coordinator: OasisMiniCoordinator = handler.parent_handler.hass.data[DOMAIN][
|
|
||||||
handler.parent_handler.config_entry.entry_id
|
|
||||||
]
|
|
||||||
|
|
||||||
try:
|
|
||||||
await coordinator.device.async_cloud_login(
|
|
||||||
email=user_input[CONF_EMAIL], password=user_input[CONF_PASSWORD]
|
|
||||||
)
|
|
||||||
user_input[CONF_ACCESS_TOKEN] = coordinator.device.access_token
|
|
||||||
except:
|
|
||||||
raise SchemaFlowError("invalid_auth")
|
|
||||||
|
|
||||||
del user_input[CONF_PASSWORD]
|
|
||||||
return user_input
|
|
||||||
|
|
||||||
|
|
||||||
OPTIONS_FLOW = {
|
|
||||||
"init": SchemaFlowFormStep(OPTIONS_SCHEMA, validate_user_input=cloud_login)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN):
|
|
||||||
"""Handle a config flow for Oasis Mini."""
|
|
||||||
|
|
||||||
VERSION = 1
|
VERSION = 1
|
||||||
|
MINOR_VERSION = 3
|
||||||
|
|
||||||
host: str | None = None
|
async def async_step_reauth(
|
||||||
serial_number: str | None = None
|
self, entry_data: Mapping[str, Any]
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Perform reauth upon an API authentication error."""
|
||||||
|
return await self.async_step_reauth_confirm()
|
||||||
|
|
||||||
@staticmethod
|
async def async_step_reauth_confirm(
|
||||||
@callback
|
self, user_input: dict[str, Any] | None = None
|
||||||
def async_get_options_flow(config_entry: ConfigEntry) -> SchemaOptionsFlowHandler:
|
) -> ConfigFlowResult:
|
||||||
"""Get the options flow for this handler."""
|
"""Dialog that informs the user that reauth is required."""
|
||||||
return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW)
|
entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
|
||||||
|
assert entry
|
||||||
|
|
||||||
# async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult:
|
suggested_values = user_input or entry.data
|
||||||
# """Handle dhcp discovery."""
|
return await self._async_step(
|
||||||
# self.host = discovery_info.ip
|
"reauth_confirm", STEP_USER_DATA_SCHEMA, user_input, suggested_values
|
||||||
# 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_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)}
|
entry_id = self.context.get("entry_id")
|
||||||
if existing_entry := self.hass.config_entries.async_get_entry(
|
existing_entry = self.hass.config_entries.async_get_entry(entry_id)
|
||||||
self.context.get("entry_id")
|
if existing_entry and existing_entry.unique_id:
|
||||||
):
|
self._abort_if_unique_id_mismatch(reason="wrong_account")
|
||||||
self.hass.config_entries.async_update_entry(
|
if existing_entry:
|
||||||
existing_entry, data=data
|
return self.async_update_reload_and_abort(
|
||||||
|
existing_entry,
|
||||||
|
unique_id=self.unique_id,
|
||||||
|
title=user_input[CONF_EMAIL],
|
||||||
|
data=user_input,
|
||||||
|
reload_even_if_entry_is_unchanged=False,
|
||||||
)
|
)
|
||||||
await self.hass.config_entries.async_reload(existing_entry.entry_id)
|
|
||||||
return self.async_abort(reason="reauth_successful")
|
|
||||||
|
|
||||||
|
self._abort_if_unique_id_configured(updates=user_input)
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
title=f"Oasis Mini {self.serial_number}",
|
title=user_input[CONF_EMAIL], data=user_input
|
||||||
data=data,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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(self.hass, user_input)
|
||||||
if not self.serial_number:
|
await client.async_login(
|
||||||
errors["base"] = "invalid_host"
|
email=user_input[CONF_EMAIL], password=user_input[CONF_PASSWORD]
|
||||||
|
)
|
||||||
|
user_input[CONF_ACCESS_TOKEN] = client.access_token
|
||||||
|
user = await client.async_get_user()
|
||||||
|
await self.async_set_unique_id(str(user["id"]))
|
||||||
|
del user_input[CONF_PASSWORD]
|
||||||
|
if not self.unique_id:
|
||||||
|
errors["base"] = "invalid_auth"
|
||||||
|
except UnauthenticatedError:
|
||||||
|
errors["base"] = "invalid_auth"
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
errors["base"] = "timeout_connect"
|
errors["base"] = "timeout_connect"
|
||||||
except ConnectError:
|
except ConnectError:
|
||||||
errors["base"] = "invalid_host"
|
errors["base"] = "invalid_auth"
|
||||||
except ClientConnectorError:
|
except ClientConnectorError:
|
||||||
errors["base"] = "invalid_host"
|
errors["base"] = "invalid_auth"
|
||||||
except HTTPStatusError as err:
|
except HTTPStatusError as err:
|
||||||
errors["base"] = str(err)
|
errors["base"] = str(err)
|
||||||
except Exception as ex: # pylint: disable=broad-except
|
except Exception as ex: # pylint: disable=broad-except
|
||||||
_LOGGER.error(ex)
|
_LOGGER.error(ex)
|
||||||
errors["base"] = "unknown"
|
errors["base"] = "unknown"
|
||||||
finally:
|
finally:
|
||||||
await client.session.close()
|
await client.async_close()
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
@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
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Constants for the Oasis Mini integration."""
|
"""Constants for the Oasis devices integration."""
|
||||||
|
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Oasis Mini coordinator."""
|
"""Oasis devices coordinator."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -8,40 +8,74 @@ 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
|
||||||
from .pyoasismini import OasisMini
|
from .pyoasiscontrol import OasisCloudClient, OasisDevice, OasisMqttClient
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class OasisMiniCoordinator(DataUpdateCoordinator[str]):
|
class OasisDeviceCoordinator(DataUpdateCoordinator[list[OasisDevice]]):
|
||||||
"""Oasis Mini data update coordinator."""
|
"""Oasis device data update coordinator."""
|
||||||
|
|
||||||
|
attempt: int = 0
|
||||||
last_updated: datetime | None = None
|
last_updated: datetime | None = None
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, device: OasisMini) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
cloud_client: OasisCloudClient,
|
||||||
|
mqtt_client: OasisMqttClient,
|
||||||
|
) -> None:
|
||||||
"""Initialize."""
|
"""Initialize."""
|
||||||
super().__init__(
|
super().__init__(
|
||||||
hass, _LOGGER, name=DOMAIN, update_interval=timedelta(seconds=10)
|
hass,
|
||||||
|
_LOGGER,
|
||||||
|
name=DOMAIN,
|
||||||
|
update_interval=timedelta(minutes=10),
|
||||||
|
always_update=False,
|
||||||
)
|
)
|
||||||
self.device = device
|
self.cloud_client = cloud_client
|
||||||
|
self.mqtt_client = mqtt_client
|
||||||
|
|
||||||
|
async def _async_update_data(self) -> list[OasisDevice]:
|
||||||
|
"""Update the data."""
|
||||||
|
devices: list[OasisDevice] = []
|
||||||
|
self.attempt += 1
|
||||||
|
|
||||||
async def _async_update_data(self):
|
|
||||||
try:
|
try:
|
||||||
async with async_timeout.timeout(10):
|
async with async_timeout.timeout(10):
|
||||||
if not self.device.serial_number:
|
if not self.data:
|
||||||
await self.device.async_get_serial_number()
|
raw_devices = await self.cloud_client.async_get_devices()
|
||||||
if not self.device.software_version:
|
devices = [
|
||||||
await self.device.async_get_software_version()
|
OasisDevice(
|
||||||
data = await self.device.async_get_status()
|
model=raw_device.get("model", {}).get("name"),
|
||||||
await self.device.async_get_current_track_details()
|
serial_number=raw_device.get("serial_number"),
|
||||||
except Exception as ex:
|
)
|
||||||
raise UpdateFailed("Couldn't read oasis_mini") from ex
|
for raw_device in raw_devices
|
||||||
if data is None:
|
]
|
||||||
raise ConfigEntryAuthFailed
|
else:
|
||||||
if data != self.data:
|
devices = self.data
|
||||||
|
for device in devices:
|
||||||
|
self.mqtt_client.register_device(device)
|
||||||
|
await self.mqtt_client.wait_until_ready(device, request_status=True)
|
||||||
|
if not device.mac_address:
|
||||||
|
await device.async_get_mac_address()
|
||||||
|
# if not device.software_version:
|
||||||
|
# await device.async_get_software_version()
|
||||||
|
# data = await self.device.async_get_status()
|
||||||
|
# devices = self.cloud_client.mac_address
|
||||||
|
self.attempt = 0
|
||||||
|
# await self.device.async_get_current_track_details()
|
||||||
|
# await self.device.async_get_playlist_details()
|
||||||
|
# await self.device.async_cloud_get_playlists()
|
||||||
|
except Exception as ex: # pylint:disable=broad-except
|
||||||
|
if self.attempt > 2 or not (devices or self.data):
|
||||||
|
raise UpdateFailed(
|
||||||
|
f"Couldn't read from the Oasis device after {self.attempt} attempts"
|
||||||
|
) from ex
|
||||||
|
|
||||||
|
if devices != self.data:
|
||||||
self.last_updated = datetime.now()
|
self.last_updated = datetime.now()
|
||||||
return data
|
return devices
|
||||||
|
|||||||
@@ -1,47 +1,41 @@
|
|||||||
"""Oasis Mini entity."""
|
"""Oasis device entity."""
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .coordinator import OasisMiniCoordinator
|
from .coordinator import OasisDeviceCoordinator
|
||||||
from .pyoasismini import OasisMini
|
from .pyoasiscontrol import OasisDevice
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class OasisMiniEntity(CoordinatorEntity[OasisMiniCoordinator]):
|
class OasisDeviceEntity(CoordinatorEntity[OasisDeviceCoordinator]):
|
||||||
"""Base class for Oasis Mini entities."""
|
"""Base class for Oasis device entities."""
|
||||||
|
|
||||||
_attr_has_entity_name = True
|
_attr_has_entity_name = True
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
coordinator: OasisMiniCoordinator,
|
coordinator: OasisDeviceCoordinator,
|
||||||
entry: ConfigEntry,
|
device: OasisDevice,
|
||||||
description: EntityDescription,
|
description: EntityDescription,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Construct a Oasis Mini entity."""
|
"""Construct an Oasis device entity."""
|
||||||
super().__init__(coordinator)
|
super().__init__(coordinator)
|
||||||
|
self.device = device
|
||||||
self.entity_description = description
|
self.entity_description = description
|
||||||
serial_number = coordinator.device.serial_number
|
|
||||||
|
serial_number = device.serial_number
|
||||||
self._attr_unique_id = f"{serial_number}-{description.key}"
|
self._attr_unique_id = f"{serial_number}-{description.key}"
|
||||||
|
|
||||||
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"{device.model} {serial_number}",
|
||||||
manufacturer="Kinetic Oasis",
|
manufacturer=device.manufacturer,
|
||||||
model="Oasis Mini",
|
model=device.model,
|
||||||
serial_number=serial_number,
|
serial_number=serial_number,
|
||||||
sw_version=coordinator.device.software_version,
|
sw_version=device.software_version,
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
|
||||||
def device(self) -> OasisMini:
|
|
||||||
"""Return the device."""
|
|
||||||
return self.coordinator.device
|
|
||||||
|
|||||||
@@ -1,14 +1,54 @@
|
|||||||
"""Helpers for the Oasis Mini integration."""
|
"""Helpers for the Oasis devices integration."""
|
||||||
|
|
||||||
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
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
|
||||||
from .pyoasismini import OasisMini
|
from .pyoasiscontrol import OasisCloudClient, OasisDevice
|
||||||
|
from .pyoasiscontrol.const import TRACKS
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def create_client(data: dict[str, Any]) -> OasisMini:
|
def create_client(hass: HomeAssistant, data: dict[str, Any]) -> OasisCloudClient:
|
||||||
"""Create a Oasis Mini local client."""
|
"""Create a Oasis cloud client."""
|
||||||
return OasisMini(data[CONF_HOST], data.get(CONF_ACCESS_TOKEN))
|
session = async_get_clientsession(hass)
|
||||||
|
return OasisCloudClient(session=session, access_token=data.get(CONF_ACCESS_TOKEN))
|
||||||
|
|
||||||
|
|
||||||
|
async def add_and_play_track(device: OasisDevice, 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
|
||||||
|
|||||||
47
custom_components/oasis_mini/icons.json
Normal file
47
custom_components/oasis_mini/icons.json
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"busy": "mdi:progress-clock",
|
||||||
|
"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",
|
||||||
|
"sleeping": "mdi:power-sleep",
|
||||||
|
"stopped": "mdi:stop-circle-outline",
|
||||||
|
"updating": "mdi:update"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"wifi_connected": {
|
||||||
|
"default": "mdi:wifi",
|
||||||
|
"state": {
|
||||||
|
"off": "mdi:wifi-off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,55 +1,84 @@
|
|||||||
"""Oasis Mini image entity."""
|
"""Oasis device image entity."""
|
||||||
|
|
||||||
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 . import OasisDeviceConfigEntry
|
||||||
|
from .coordinator import OasisDeviceCoordinator
|
||||||
|
from .entity import OasisDeviceEntity
|
||||||
|
from .pyoasiscontrol import OasisDevice
|
||||||
|
from .pyoasiscontrol.const import TRACKS
|
||||||
|
from .pyoasiscontrol.utils import draw_svg
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: OasisDeviceConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up Oasis device image using config entry."""
|
||||||
|
coordinator: OasisDeviceCoordinator = entry.runtime_data
|
||||||
|
async_add_entities(
|
||||||
|
OasisDeviceImageEntity(coordinator, device, IMAGE)
|
||||||
|
for device in coordinator.data
|
||||||
|
)
|
||||||
|
|
||||||
from .const import DOMAIN
|
|
||||||
from .coordinator import OasisMiniCoordinator
|
|
||||||
from .entity import OasisMiniEntity
|
|
||||||
from .pyoasismini.utils import draw_svg
|
|
||||||
|
|
||||||
IMAGE = ImageEntityDescription(key="image", name=None)
|
IMAGE = ImageEntityDescription(key="image", name=None)
|
||||||
|
|
||||||
|
|
||||||
class OasisMiniImageEntity(OasisMiniEntity, ImageEntity):
|
class OasisDeviceImageEntity(OasisDeviceEntity, ImageEntity):
|
||||||
"""Oasis Mini image entity."""
|
"""Oasis device image entity."""
|
||||||
|
|
||||||
_attr_content_type = "image/svg+xml"
|
_attr_content_type = "image/svg+xml"
|
||||||
|
_track_id: int | None = None
|
||||||
|
_progress: int = 0
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
coordinator: OasisMiniCoordinator,
|
coordinator: OasisDeviceCoordinator,
|
||||||
entry_id: str,
|
device: OasisDevice,
|
||||||
description: ImageEntityDescription,
|
description: ImageEntityDescription,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the entity."""
|
"""Initialize the entity."""
|
||||||
super().__init__(coordinator, entry_id, description)
|
super().__init__(coordinator, device, 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.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:
|
||||||
async def async_setup_entry(
|
super()._handle_coordinator_update()
|
||||||
hass: HomeAssistant, entry: ConfigEntry, 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)])
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Oasis Mini light entity."""
|
"""Oasis device light entity."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -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,35 +23,50 @@ from homeassistant.util.color import (
|
|||||||
value_to_brightness,
|
value_to_brightness,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .const import DOMAIN
|
from . import OasisDeviceConfigEntry
|
||||||
from .coordinator import OasisMiniCoordinator
|
from .coordinator import OasisDeviceCoordinator
|
||||||
from .entity import OasisMiniEntity
|
from .entity import OasisDeviceEntity
|
||||||
from .pyoasismini import LED_EFFECTS
|
from .pyoasiscontrol.const import LED_EFFECTS
|
||||||
|
|
||||||
|
|
||||||
class OasisMiniLightEntity(OasisMiniEntity, LightEntity):
|
async def async_setup_entry(
|
||||||
"""Oasis Mini light entity."""
|
hass: HomeAssistant,
|
||||||
|
entry: OasisDeviceConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up Oasis device lights using config entry."""
|
||||||
|
coordinator: OasisDeviceCoordinator = entry.runtime_data
|
||||||
|
async_add_entities(
|
||||||
|
OasisDeviceLightEntity(coordinator, device, DESCRIPTOR)
|
||||||
|
for device in coordinator.data
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
DESCRIPTOR = LightEntityDescription(key="led", translation_key="led")
|
||||||
|
|
||||||
|
|
||||||
|
class OasisDeviceLightEntity(OasisDeviceEntity, LightEntity):
|
||||||
|
"""Oasis device light entity."""
|
||||||
|
|
||||||
_attr_supported_features = LightEntityFeature.EFFECT
|
_attr_supported_features = LightEntityFeature.EFFECT
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def brightness(self) -> int:
|
def brightness(self) -> int:
|
||||||
"""Return the brightness of this light between 0..255."""
|
"""Return the brightness of this light between 0..255."""
|
||||||
scale = (1, self.device.max_brightness)
|
scale = (1, self.device.brightness_max)
|
||||||
return value_to_brightness(scale, self.device.brightness)
|
return value_to_brightness(scale, self.device.brightness)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def color_mode(self) -> ColorMode:
|
def color_mode(self) -> ColorMode:
|
||||||
"""Return the color mode of the light."""
|
"""Return the color mode of the light."""
|
||||||
# if self.effect in (
|
if self.effect in (
|
||||||
# "Rainbow",
|
"Rainbow",
|
||||||
# "Glitter",
|
"Glitter",
|
||||||
# "Confetti",
|
"Confetti",
|
||||||
# "BPM",
|
"BPM",
|
||||||
# "Juggle",
|
"Juggle",
|
||||||
# "Theater",
|
):
|
||||||
# ):
|
return ColorMode.BRIGHTNESS
|
||||||
# return ColorMode.BRIGHTNESS
|
|
||||||
return ColorMode.RGB
|
return ColorMode.RGB
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -71,8 +85,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
|
||||||
@@ -83,15 +99,14 @@ class OasisMiniLightEntity(OasisMiniEntity, LightEntity):
|
|||||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||||
"""Turn the entity off."""
|
"""Turn the entity off."""
|
||||||
await self.device.async_set_led(brightness=0)
|
await self.device.async_set_led(brightness=0)
|
||||||
await self.coordinator.async_request_refresh()
|
|
||||||
|
|
||||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||||
"""Turn the entity on."""
|
"""Turn the entity on."""
|
||||||
if brightness := kwargs.get(ATTR_BRIGHTNESS):
|
if brightness := kwargs.get(ATTR_BRIGHTNESS):
|
||||||
scale = (1, self.device.max_brightness)
|
scale = (1, self.device.brightness_max)
|
||||||
brightness = math.ceil(brightness_to_value(scale, brightness))
|
brightness = math.ceil(brightness_to_value(scale, brightness))
|
||||||
else:
|
else:
|
||||||
brightness = self.device.brightness or 100
|
brightness = self.device.brightness or self.device.brightness_on
|
||||||
|
|
||||||
if color := kwargs.get(ATTR_RGB_COLOR):
|
if color := kwargs.get(ATTR_RGB_COLOR):
|
||||||
color = f"#{color_rgb_to_hex(*color)}"
|
color = f"#{color_rgb_to_hex(*color)}"
|
||||||
@@ -104,15 +119,3 @@ class OasisMiniLightEntity(OasisMiniEntity, LightEntity):
|
|||||||
await self.device.async_set_led(
|
await self.device.async_set_led(
|
||||||
brightness=brightness, color=color, led_effect=led_effect
|
brightness=brightness, color=color, led_effect=led_effect
|
||||||
)
|
)
|
||||||
await self.coordinator.async_request_refresh()
|
|
||||||
|
|
||||||
|
|
||||||
DESCRIPTOR = LightEntityDescription(key="led", name="LED")
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
|
||||||
hass: HomeAssistant, entry: ConfigEntry, 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)])
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
{
|
{
|
||||||
"domain": "oasis_mini",
|
"domain": "oasis_mini",
|
||||||
"name": "Oasis Mini",
|
"name": "Oasis Control",
|
||||||
"codeowners": ["@natekspencer"],
|
"codeowners": ["@natekspencer"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
|
"dhcp": [{ "registered_devices": true }],
|
||||||
"documentation": "https://github.com/natekspencer/hacs-oasis_mini",
|
"documentation": "https://github.com/natekspencer/hacs-oasis_mini",
|
||||||
"integration_type": "device",
|
"integration_type": "hub",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"issue_tracker": "https://github.com/natekspencer/hacs-oasis_mini/issues",
|
"issue_tracker": "https://github.com/natekspencer/hacs-oasis_mini/issues",
|
||||||
"loggers": ["custom_components.oasis_mini"],
|
"loggers": ["custom_components.oasis_mini"],
|
||||||
|
"requirements": ["aiomqtt"],
|
||||||
"version": "0.0.0"
|
"version": "0.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
"""Oasis Mini media player entity."""
|
"""Oasis device media player entity."""
|
||||||
|
|
||||||
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,25 +14,47 @@ 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 OasisDeviceConfigEntry
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .coordinator import OasisMiniCoordinator
|
from .coordinator import OasisDeviceCoordinator
|
||||||
from .entity import OasisMiniEntity
|
from .entity import OasisDeviceEntity
|
||||||
|
from .helpers import get_track_id
|
||||||
BRIGHTNESS_SCALE = (1, 200)
|
from .pyoasiscontrol.const import TRACKS
|
||||||
|
|
||||||
|
|
||||||
class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
|
async def async_setup_entry(
|
||||||
"""Oasis Mini media player entity."""
|
hass: HomeAssistant,
|
||||||
|
entry: OasisDeviceConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up Oasis device media_players using config entry."""
|
||||||
|
coordinator: OasisDeviceCoordinator = entry.runtime_data
|
||||||
|
async_add_entities(
|
||||||
|
OasisDeviceMediaPlayerEntity(coordinator, device, DESCRIPTOR)
|
||||||
|
for device in coordinator.data
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
DESCRIPTOR = MediaPlayerEntityDescription(key="oasis_mini", name=None)
|
||||||
|
|
||||||
|
|
||||||
|
class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity):
|
||||||
|
"""Oasis device media player entity."""
|
||||||
|
|
||||||
_attr_media_image_remotely_accessible = True
|
_attr_media_image_remotely_accessible = True
|
||||||
_attr_supported_features = (
|
_attr_supported_features = (
|
||||||
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
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -41,21 +64,19 @@ 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_new" in track:
|
||||||
track_details := self.device._current_track_details
|
return track["reduced_svg_content_new"]
|
||||||
) and "reduced_svg_content" in track_details:
|
return None
|
||||||
return track_details["reduced_svg_content"].get("1")
|
|
||||||
return math.ceil(self.media_position / 0.99)
|
|
||||||
|
|
||||||
@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 (
|
if not (track := self.device.track):
|
||||||
track_details := self.device._current_track_details
|
track = TRACKS.get(self.device.track_id)
|
||||||
) and "image" in track_details:
|
if track and "image" in track:
|
||||||
return f"https://app.grounded.so/uploads/{track_details['image']}"
|
return f"https://app.grounded.so/uploads/{track['image']}"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -69,40 +90,60 @@ 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 track_details := self.device._current_track_details:
|
if not self.device.track_id:
|
||||||
return track_details.get("name", self.device.current_track_id)
|
return None
|
||||||
return 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()
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
async def async_media_stop(self) -> None:
|
||||||
|
"""Send stop command."""
|
||||||
|
self.abort_if_busy()
|
||||||
|
await self.device.async_stop()
|
||||||
|
|
||||||
async def async_set_repeat(self, repeat: RepeatMode) -> None:
|
async def async_set_repeat(self, repeat: RepeatMode) -> None:
|
||||||
"""Set repeat mode."""
|
"""Set repeat mode."""
|
||||||
@@ -110,21 +151,70 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
|
|||||||
repeat != RepeatMode.OFF
|
repeat != RepeatMode.OFF
|
||||||
and not (repeat == RepeatMode.ONE and self.repeat == RepeatMode.ALL)
|
and not (repeat == RepeatMode.ONE and self.repeat == RepeatMode.ALL)
|
||||||
)
|
)
|
||||||
await self.coordinator.async_request_refresh()
|
|
||||||
|
async def async_media_previous_track(self) -> None:
|
||||||
|
"""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)
|
||||||
|
|
||||||
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
|
||||||
return await self.device.async_change_track(index)
|
await self.device.async_change_track(index)
|
||||||
|
|
||||||
|
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},
|
||||||
|
)
|
||||||
|
|
||||||
DESCRIPTOR = MediaPlayerEntityDescription(key="oasis_mini", name=None)
|
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)
|
||||||
|
|
||||||
async def async_setup_entry(
|
if (
|
||||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
enqueue in (MediaPlayerEnqueue.PLAY, MediaPlayerEnqueue.REPLACE)
|
||||||
) -> None:
|
and device.status_code != 4
|
||||||
"""Set up Oasis Mini media_players using config entry."""
|
):
|
||||||
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id]
|
await device.async_play()
|
||||||
async_add_entities([OasisMiniMediaPlayerEntity(coordinator, entry, DESCRIPTOR)])
|
|
||||||
|
async def async_clear_playlist(self) -> None:
|
||||||
|
"""Clear players playlist."""
|
||||||
|
self.abort_if_busy()
|
||||||
|
await self.device.async_clear_playlist()
|
||||||
|
|||||||
@@ -1,19 +1,60 @@
|
|||||||
"""Oasis Mini number entity."""
|
"""Oasis device number entity."""
|
||||||
|
|
||||||
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 OasisDeviceConfigEntry
|
||||||
from .coordinator import OasisMiniCoordinator
|
from .coordinator import OasisDeviceCoordinator
|
||||||
from .entity import OasisMiniEntity
|
from .entity import OasisDeviceEntity
|
||||||
|
from .pyoasiscontrol.device import (
|
||||||
|
BALL_SPEED_MAX,
|
||||||
|
BALL_SPEED_MIN,
|
||||||
|
LED_SPEED_MAX,
|
||||||
|
LED_SPEED_MIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class OasisMiniNumberEntity(OasisMiniEntity, NumberEntity):
|
async def async_setup_entry(
|
||||||
"""Oasis Mini number entity."""
|
hass: HomeAssistant,
|
||||||
|
entry: OasisDeviceConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up Oasis device numbers using config entry."""
|
||||||
|
coordinator: OasisDeviceCoordinator = entry.runtime_data
|
||||||
|
async_add_entities(
|
||||||
|
OasisDeviceNumberEntity(coordinator, device, descriptor)
|
||||||
|
for device in coordinator.data
|
||||||
|
for descriptor in DESCRIPTORS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
DESCRIPTORS = {
|
||||||
|
NumberEntityDescription(
|
||||||
|
key="ball_speed",
|
||||||
|
translation_key="ball_speed",
|
||||||
|
mode=NumberMode.SLIDER,
|
||||||
|
native_max_value=BALL_SPEED_MAX,
|
||||||
|
native_min_value=BALL_SPEED_MIN,
|
||||||
|
),
|
||||||
|
NumberEntityDescription(
|
||||||
|
key="led_speed",
|
||||||
|
translation_key="led_speed",
|
||||||
|
mode=NumberMode.SLIDER,
|
||||||
|
native_max_value=LED_SPEED_MAX,
|
||||||
|
native_min_value=LED_SPEED_MIN,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class OasisDeviceNumberEntity(OasisDeviceEntity, NumberEntity):
|
||||||
|
"""Oasis device number entity."""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_value(self) -> str | None:
|
def native_value(self) -> str | None:
|
||||||
@@ -22,37 +63,8 @@ class OasisMiniNumberEntity(OasisMiniEntity, NumberEntity):
|
|||||||
|
|
||||||
async def async_set_native_value(self, value: float) -> None:
|
async def async_set_native_value(self, value: float) -> None:
|
||||||
"""Set new value."""
|
"""Set new value."""
|
||||||
|
value = int(value)
|
||||||
if self.entity_description.key == "ball_speed":
|
if self.entity_description.key == "ball_speed":
|
||||||
await self.device.async_set_ball_speed(value)
|
await self.device.async_set_ball_speed(value)
|
||||||
elif self.entity_description.key == "led_speed":
|
elif self.entity_description.key == "led_speed":
|
||||||
await self.device.async_set_led(led_speed=value)
|
await self.device.async_set_led(led_speed=value)
|
||||||
await self.coordinator.async_request_refresh()
|
|
||||||
|
|
||||||
|
|
||||||
DESCRIPTORS = {
|
|
||||||
NumberEntityDescription(
|
|
||||||
key="ball_speed",
|
|
||||||
name="Ball speed",
|
|
||||||
native_max_value=800,
|
|
||||||
native_min_value=200,
|
|
||||||
),
|
|
||||||
NumberEntityDescription(
|
|
||||||
key="led_speed",
|
|
||||||
name="LED speed",
|
|
||||||
native_max_value=90,
|
|
||||||
native_min_value=-90,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
|
||||||
hass: HomeAssistant, entry: ConfigEntry, 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)
|
|
||||||
for descriptor in DESCRIPTORS
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|||||||
7
custom_components/oasis_mini/pyoasiscontrol/__init__.py
Normal file
7
custom_components/oasis_mini/pyoasiscontrol/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"""Oasis control."""
|
||||||
|
|
||||||
|
from .clients import OasisCloudClient, OasisMqttClient
|
||||||
|
from .device import OasisDevice
|
||||||
|
from .exceptions import UnauthenticatedError
|
||||||
|
|
||||||
|
__all__ = ["OasisDevice", "OasisCloudClient", "OasisMqttClient", "UnauthenticatedError"]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""Oasis control clients."""
|
||||||
|
|
||||||
|
from .cloud_client import OasisCloudClient
|
||||||
|
from .http_client import OasisHttpClient
|
||||||
|
from .mqtt_client import OasisMqttClient
|
||||||
|
|
||||||
|
__all__ = ["OasisCloudClient", "OasisHttpClient", "OasisMqttClient"]
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
"""Oasis cloud client."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
from aiohttp import ClientResponseError, ClientSession
|
||||||
|
|
||||||
|
from ..exceptions import UnauthenticatedError
|
||||||
|
from ..utils import now
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
BASE_URL = "https://app.grounded.so"
|
||||||
|
PLAYLISTS_REFRESH_LIMITER = timedelta(minutes=5)
|
||||||
|
|
||||||
|
|
||||||
|
class OasisCloudClient:
|
||||||
|
"""Cloud client for Oasis.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
- Manage aiohttp session (optionally owned)
|
||||||
|
- Manage access token
|
||||||
|
- Provide async_* helpers for:
|
||||||
|
* login/logout
|
||||||
|
* user info
|
||||||
|
* devices
|
||||||
|
* tracks/playlists
|
||||||
|
* latest software metadata
|
||||||
|
"""
|
||||||
|
|
||||||
|
_session: ClientSession | None
|
||||||
|
_owns_session: bool
|
||||||
|
_access_token: str | None
|
||||||
|
|
||||||
|
# these are "cache" fields for tracks/playlists
|
||||||
|
_playlists_next_refresh: float
|
||||||
|
playlists: list[dict[str, Any]]
|
||||||
|
_playlist_details: dict[int, dict[str, str]]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session: ClientSession | None = None,
|
||||||
|
access_token: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._session = session
|
||||||
|
self._owns_session = session is None
|
||||||
|
self._access_token = access_token
|
||||||
|
|
||||||
|
# simple in-memory caches
|
||||||
|
self._playlists_next_refresh = 0.0
|
||||||
|
self.playlists = []
|
||||||
|
self._playlist_details = {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def session(self) -> ClientSession:
|
||||||
|
"""Return (or lazily create) the aiohttp ClientSession."""
|
||||||
|
if self._session is None or self._session.closed:
|
||||||
|
self._session = ClientSession()
|
||||||
|
self._owns_session = True
|
||||||
|
return self._session
|
||||||
|
|
||||||
|
async def async_close(self) -> None:
|
||||||
|
"""Close owned session (call from HA unload / cleanup)."""
|
||||||
|
if self._session and not self._session.closed and self._owns_session:
|
||||||
|
await self._session.close()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def access_token(self) -> str | None:
|
||||||
|
return self._access_token
|
||||||
|
|
||||||
|
@access_token.setter
|
||||||
|
def access_token(self, value: str | None) -> None:
|
||||||
|
self._access_token = value
|
||||||
|
|
||||||
|
async def async_login(self, email: str, password: str) -> None:
|
||||||
|
"""Login via the cloud and store the access token."""
|
||||||
|
response = await self._async_request(
|
||||||
|
"POST",
|
||||||
|
urljoin(BASE_URL, "api/auth/login"),
|
||||||
|
json={"email": email, "password": password},
|
||||||
|
)
|
||||||
|
token = response.get("access_token") if isinstance(response, dict) else None
|
||||||
|
self.access_token = token
|
||||||
|
_LOGGER.debug("Cloud login succeeded, token set: %s", bool(token))
|
||||||
|
|
||||||
|
async def async_logout(self) -> None:
|
||||||
|
"""Logout from the cloud."""
|
||||||
|
await self._async_auth_request("GET", "api/auth/logout")
|
||||||
|
self.access_token = None
|
||||||
|
|
||||||
|
async def async_get_user(self) -> dict:
|
||||||
|
"""Get current user info."""
|
||||||
|
return await self._async_auth_request("GET", "api/auth/user")
|
||||||
|
|
||||||
|
async def async_get_devices(self) -> list[dict[str, Any]]:
|
||||||
|
"""Get user devices (raw JSON from API)."""
|
||||||
|
return await self._async_auth_request("GET", "api/user/devices")
|
||||||
|
|
||||||
|
async def async_get_playlists(
|
||||||
|
self, personal_only: bool = False
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Get playlists from the cloud (cached by PLAYLISTS_REFRESH_LIMITER)."""
|
||||||
|
if self._playlists_next_refresh <= now():
|
||||||
|
params = {"my_playlists": str(personal_only).lower()}
|
||||||
|
playlists = await self._async_auth_request(
|
||||||
|
"GET", "api/playlist", params=params
|
||||||
|
)
|
||||||
|
if playlists:
|
||||||
|
self.playlists = playlists
|
||||||
|
self._playlists_next_refresh = now() + PLAYLISTS_REFRESH_LIMITER
|
||||||
|
return self.playlists
|
||||||
|
|
||||||
|
async def async_get_track_info(self, track_id: int) -> dict[str, Any] | None:
|
||||||
|
"""Get single track info from the cloud."""
|
||||||
|
try:
|
||||||
|
return await self._async_auth_request("GET", f"api/track/{track_id}")
|
||||||
|
except ClientResponseError as err:
|
||||||
|
if err.status == 404:
|
||||||
|
return {"id": track_id, "name": f"Unknown Title (#{track_id})"}
|
||||||
|
except Exception as ex: # noqa: BLE001
|
||||||
|
_LOGGER.exception("Error fetching track %s: %s", track_id, ex)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def async_get_tracks(
|
||||||
|
self, tracks: list[int] | None = None
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Get multiple tracks info from the cloud (handles pagination)."""
|
||||||
|
response = await self._async_auth_request(
|
||||||
|
"GET",
|
||||||
|
"api/track",
|
||||||
|
params={"ids[]": tracks or []},
|
||||||
|
)
|
||||||
|
if not response:
|
||||||
|
return []
|
||||||
|
track_details = response.get("data", [])
|
||||||
|
while next_page_url := response.get("next_page_url"):
|
||||||
|
response = await self._async_auth_request("GET", next_page_url)
|
||||||
|
track_details += response.get("data", [])
|
||||||
|
return track_details
|
||||||
|
|
||||||
|
async def async_get_latest_software_details(self) -> dict[str, int | str]:
|
||||||
|
"""Get latest software metadata from cloud."""
|
||||||
|
return await self._async_auth_request("GET", "api/software/last-version")
|
||||||
|
|
||||||
|
async def _async_auth_request(self, method: str, url: str, **kwargs: Any) -> Any:
|
||||||
|
"""Perform an authenticated cloud request."""
|
||||||
|
if not self.access_token:
|
||||||
|
raise UnauthenticatedError("Unauthenticated")
|
||||||
|
|
||||||
|
headers = kwargs.pop("headers", {}) or {}
|
||||||
|
headers["Authorization"] = f"Bearer {self.access_token}"
|
||||||
|
|
||||||
|
return await self._async_request(
|
||||||
|
method,
|
||||||
|
url if url.startswith("http") else urljoin(BASE_URL, url),
|
||||||
|
headers=headers,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _async_request(self, method: str, url: str, **kwargs: Any) -> Any:
|
||||||
|
"""Low-level HTTP helper for both cloud and (if desired) device HTTP."""
|
||||||
|
session = self.session
|
||||||
|
_LOGGER.debug(
|
||||||
|
"%s %s",
|
||||||
|
method,
|
||||||
|
session._build_url(url).update_query( # pylint: disable=protected-access
|
||||||
|
kwargs.get("params"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
response = await session.request(method, url, **kwargs)
|
||||||
|
|
||||||
|
if response.status == 200:
|
||||||
|
if response.content_type == "application/json":
|
||||||
|
return await response.json()
|
||||||
|
if response.content_type == "text/plain":
|
||||||
|
return await response.text()
|
||||||
|
if response.content_type == "text/html" and BASE_URL in url:
|
||||||
|
text = await response.text()
|
||||||
|
if "login-page" in text:
|
||||||
|
raise UnauthenticatedError("Unauthenticated")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if response.status == 401:
|
||||||
|
raise UnauthenticatedError("Unauthenticated")
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
"""Oasis HTTP client (per-device)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aiohttp import ClientSession
|
||||||
|
|
||||||
|
from ..device import OasisDevice
|
||||||
|
from .transport import OasisClientProtocol
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OasisHttpClient(OasisClientProtocol):
|
||||||
|
"""HTTP-based Oasis transport.
|
||||||
|
|
||||||
|
This client is typically used per-device (per host/IP).
|
||||||
|
It implements the OasisClientProtocol so OasisDevice can delegate
|
||||||
|
all commands through it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, host: str, session: ClientSession | None = None) -> None:
|
||||||
|
self._host = host
|
||||||
|
self._session: ClientSession | None = session
|
||||||
|
self._owns_session: bool = session is None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def session(self) -> ClientSession:
|
||||||
|
if self._session is None or self._session.closed:
|
||||||
|
self._session = ClientSession()
|
||||||
|
self._owns_session = True
|
||||||
|
return self._session
|
||||||
|
|
||||||
|
async def async_close(self) -> None:
|
||||||
|
"""Close owned session."""
|
||||||
|
if self._session and not self._session.closed and self._owns_session:
|
||||||
|
await self._session.close()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url(self) -> str:
|
||||||
|
# These devices are plain HTTP, no TLS
|
||||||
|
return f"http://{self._host}/"
|
||||||
|
|
||||||
|
async def _async_request(self, method: str, url: str, **kwargs: Any) -> Any:
|
||||||
|
"""Low-level HTTP helper."""
|
||||||
|
session = self.session
|
||||||
|
_LOGGER.debug(
|
||||||
|
"%s %s",
|
||||||
|
method,
|
||||||
|
session._build_url(url).update_query( # pylint: disable=protected-access
|
||||||
|
kwargs.get("params"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
resp = await session.request(method, url, **kwargs)
|
||||||
|
|
||||||
|
if resp.status == 200:
|
||||||
|
if resp.content_type == "text/plain":
|
||||||
|
return await resp.text()
|
||||||
|
if resp.content_type == "application/json":
|
||||||
|
return await resp.json()
|
||||||
|
return None
|
||||||
|
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
async def _async_get(self, **kwargs: Any) -> str | None:
|
||||||
|
return await self._async_request("GET", self.url, **kwargs)
|
||||||
|
|
||||||
|
async def _async_command(self, **kwargs: Any) -> str | None:
|
||||||
|
result = await self._async_get(**kwargs)
|
||||||
|
_LOGGER.debug("Result: %s", result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def async_get_mac_address(self, device: OasisDevice) -> str | None:
|
||||||
|
"""Fetch MAC address via HTTP GETMAC."""
|
||||||
|
try:
|
||||||
|
mac = await self._async_get(params={"GETMAC": ""})
|
||||||
|
if isinstance(mac, str):
|
||||||
|
return mac.strip()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
_LOGGER.exception(
|
||||||
|
"Failed to get MAC address via HTTP for %s", device.serial_number
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def async_send_ball_speed_command(
|
||||||
|
self,
|
||||||
|
device: OasisDevice,
|
||||||
|
speed: int,
|
||||||
|
) -> None:
|
||||||
|
await self._async_command(params={"WRIOASISSPEED": speed})
|
||||||
|
|
||||||
|
async def async_send_led_command(
|
||||||
|
self,
|
||||||
|
device: OasisDevice,
|
||||||
|
led_effect: str,
|
||||||
|
color: str,
|
||||||
|
led_speed: int,
|
||||||
|
brightness: int,
|
||||||
|
) -> None:
|
||||||
|
payload = f"{led_effect};0;{color};{led_speed};{brightness}"
|
||||||
|
await self._async_command(params={"WRILED": payload})
|
||||||
|
|
||||||
|
async def async_send_sleep_command(self, device: OasisDevice) -> None:
|
||||||
|
await self._async_command(params={"CMDSLEEP": ""})
|
||||||
|
|
||||||
|
async def async_send_move_job_command(
|
||||||
|
self,
|
||||||
|
device: OasisDevice,
|
||||||
|
from_index: int,
|
||||||
|
to_index: int,
|
||||||
|
) -> None:
|
||||||
|
await self._async_command(params={"MOVEJOB": f"{from_index};{to_index}"})
|
||||||
|
|
||||||
|
async def async_send_change_track_command(
|
||||||
|
self,
|
||||||
|
device: OasisDevice,
|
||||||
|
index: int,
|
||||||
|
) -> None:
|
||||||
|
await self._async_command(params={"CMDCHANGETRACK": index})
|
||||||
|
|
||||||
|
async def async_send_add_joblist_command(
|
||||||
|
self,
|
||||||
|
device: OasisDevice,
|
||||||
|
tracks: list[int],
|
||||||
|
) -> None:
|
||||||
|
# The old code passed the list directly; if the device expects CSV:
|
||||||
|
await self._async_command(params={"ADDJOBLIST": ",".join(map(str, tracks))})
|
||||||
|
|
||||||
|
async def async_send_set_playlist_command(
|
||||||
|
self,
|
||||||
|
device: OasisDevice,
|
||||||
|
playlist: list[int],
|
||||||
|
) -> None:
|
||||||
|
await self._async_command(params={"WRIJOBLIST": ",".join(map(str, playlist))})
|
||||||
|
# optional: optimistic state update
|
||||||
|
device.update_from_status_dict({"playlist": playlist})
|
||||||
|
|
||||||
|
async def async_send_set_repeat_playlist_command(
|
||||||
|
self,
|
||||||
|
device: OasisDevice,
|
||||||
|
repeat: bool,
|
||||||
|
) -> None:
|
||||||
|
await self._async_command(params={"WRIREPEATJOB": 1 if repeat else 0})
|
||||||
|
|
||||||
|
async def async_send_set_autoplay_command(
|
||||||
|
self,
|
||||||
|
device: OasisDevice,
|
||||||
|
option: str,
|
||||||
|
) -> None:
|
||||||
|
await self._async_command(params={"WRIWAITAFTER": option})
|
||||||
|
|
||||||
|
async def async_send_upgrade_command(
|
||||||
|
self,
|
||||||
|
device: OasisDevice,
|
||||||
|
beta: bool,
|
||||||
|
) -> None:
|
||||||
|
await self._async_command(params={"CMDUPGRADE": 1 if beta else 0})
|
||||||
|
|
||||||
|
async def async_send_play_command(self, device: OasisDevice) -> None:
|
||||||
|
await self._async_command(params={"CMDPLAY": ""})
|
||||||
|
|
||||||
|
async def async_send_pause_command(self, device: OasisDevice) -> None:
|
||||||
|
await self._async_command(params={"CMDPAUSE": ""})
|
||||||
|
|
||||||
|
async def async_send_stop_command(self, device: OasisDevice) -> None:
|
||||||
|
await self._async_command(params={"CMDSTOP": ""})
|
||||||
|
|
||||||
|
async def async_send_reboot_command(self, device: OasisDevice) -> None:
|
||||||
|
await self._async_command(params={"CMDBOOT": ""})
|
||||||
|
|
||||||
|
async def async_get_status(self, device: OasisDevice) -> None:
|
||||||
|
"""Fetch status via GETSTATUS and update the device."""
|
||||||
|
raw_status = await self._async_get(params={"GETSTATUS": ""})
|
||||||
|
if raw_status is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
_LOGGER.debug("Status for %s: %s", device.serial_number, raw_status)
|
||||||
|
device.update_from_status_string(raw_status)
|
||||||
@@ -0,0 +1,607 @@
|
|||||||
|
"""Oasis MQTT client (multi-device)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
import logging
|
||||||
|
import ssl
|
||||||
|
from typing import Any, Final
|
||||||
|
|
||||||
|
import aiomqtt
|
||||||
|
|
||||||
|
from ..device import OasisDevice
|
||||||
|
from ..utils import _bit_to_bool, _parse_int
|
||||||
|
from .transport import OasisClientProtocol
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# mqtt connection parameters
|
||||||
|
HOST: Final = "mqtt.grounded.so"
|
||||||
|
PORT: Final = 8084
|
||||||
|
PATH: Final = "mqtt"
|
||||||
|
USERNAME: Final = "YXBw"
|
||||||
|
PASSWORD: Final = "RWdETFlKMDczfi4t"
|
||||||
|
RECONNECT_INTERVAL: Final = 4
|
||||||
|
|
||||||
|
# Command queue behaviour
|
||||||
|
MAX_PENDING_COMMANDS: Final = 10
|
||||||
|
|
||||||
|
|
||||||
|
class OasisMqttClient(OasisClientProtocol):
|
||||||
|
"""MQTT-based Oasis transport using WSS.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
- Maintain a single MQTT connection to:
|
||||||
|
wss://mqtt.grounded.so:8084/mqtt
|
||||||
|
- Subscribe only to <serial>/STATUS/# for devices it knows about.
|
||||||
|
- Publish commands to <serial>/COMMAND/CMD
|
||||||
|
- Map MQTT payloads to OasisDevice.update_from_status_dict()
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
# MQTT connection state
|
||||||
|
self._client: aiomqtt.Client | None = None
|
||||||
|
self._loop_task: asyncio.Task | None = None
|
||||||
|
self._connected_at: datetime | None = None
|
||||||
|
|
||||||
|
self._connected_event: asyncio.Event = asyncio.Event()
|
||||||
|
self._stop_event: asyncio.Event = asyncio.Event()
|
||||||
|
|
||||||
|
# Known devices by serial
|
||||||
|
self._devices: dict[str, OasisDevice] = {}
|
||||||
|
|
||||||
|
# Per-device events
|
||||||
|
self._first_status_events: dict[str, asyncio.Event] = {}
|
||||||
|
self._mac_events: dict[str, asyncio.Event] = {}
|
||||||
|
|
||||||
|
# Subscription bookkeeping
|
||||||
|
self._subscribed_serials: set[str] = set()
|
||||||
|
self._subscription_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
# Pending command queue: (serial, payload)
|
||||||
|
self._command_queue: asyncio.Queue[tuple[str, str]] = asyncio.Queue(
|
||||||
|
maxsize=MAX_PENDING_COMMANDS
|
||||||
|
)
|
||||||
|
|
||||||
|
def register_device(self, device: OasisDevice) -> None:
|
||||||
|
"""Register a device so MQTT messages can be routed to it."""
|
||||||
|
if not device.serial_number:
|
||||||
|
raise ValueError("Device must have serial_number set before registration")
|
||||||
|
|
||||||
|
serial = device.serial_number
|
||||||
|
self._devices[serial] = device
|
||||||
|
|
||||||
|
# Ensure we have per-device events
|
||||||
|
self._first_status_events.setdefault(serial, asyncio.Event())
|
||||||
|
self._mac_events.setdefault(serial, asyncio.Event())
|
||||||
|
|
||||||
|
# Attach ourselves as the client if the device doesn't already have one
|
||||||
|
if not device.client:
|
||||||
|
device.attach_client(self)
|
||||||
|
|
||||||
|
# If we're already connected, subscribe to this device's topics
|
||||||
|
if self._client is not None:
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
loop.create_task(self._subscribe_serial(serial))
|
||||||
|
except RuntimeError:
|
||||||
|
# No running loop (unlikely in HA), so just log
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Could not schedule subscription for %s (no running loop)", serial
|
||||||
|
)
|
||||||
|
|
||||||
|
def unregister_device(self, device: OasisDevice) -> None:
|
||||||
|
serial = device.serial_number
|
||||||
|
if not serial:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._devices.pop(serial, None)
|
||||||
|
self._first_status_events.pop(serial, None)
|
||||||
|
self._mac_events.pop(serial, None)
|
||||||
|
|
||||||
|
# If connected and we were subscribed, unsubscribe
|
||||||
|
if self._client is not None and serial in self._subscribed_serials:
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
loop.create_task(self._unsubscribe_serial(serial))
|
||||||
|
except RuntimeError:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Could not schedule unsubscription for %s (no running loop)",
|
||||||
|
serial,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _subscribe_serial(self, serial: str) -> None:
|
||||||
|
"""Subscribe to STATUS topics for a single device."""
|
||||||
|
if not self._client:
|
||||||
|
return
|
||||||
|
|
||||||
|
async with self._subscription_lock:
|
||||||
|
if not self._client or serial in self._subscribed_serials:
|
||||||
|
return
|
||||||
|
|
||||||
|
topic = f"{serial}/STATUS/#"
|
||||||
|
await self._client.subscribe([(topic, 1)])
|
||||||
|
self._subscribed_serials.add(serial)
|
||||||
|
_LOGGER.info("Subscribed to %s", topic)
|
||||||
|
|
||||||
|
async def _unsubscribe_serial(self, serial: str) -> None:
|
||||||
|
"""Unsubscribe from STATUS topics for a single device."""
|
||||||
|
if not self._client:
|
||||||
|
return
|
||||||
|
|
||||||
|
async with self._subscription_lock:
|
||||||
|
if not self._client or serial not in self._subscribed_serials:
|
||||||
|
return
|
||||||
|
|
||||||
|
topic = f"{serial}/STATUS/#"
|
||||||
|
await self._client.unsubscribe(topic)
|
||||||
|
self._subscribed_serials.discard(serial)
|
||||||
|
_LOGGER.info("Unsubscribed from %s", topic)
|
||||||
|
|
||||||
|
async def _resubscribe_all(self) -> None:
|
||||||
|
"""Resubscribe to all known devices after (re)connect."""
|
||||||
|
self._subscribed_serials.clear()
|
||||||
|
for serial in list(self._devices):
|
||||||
|
await self._subscribe_serial(serial)
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""Start MQTT connection loop."""
|
||||||
|
if self._loop_task is None or self._loop_task.done():
|
||||||
|
self._stop_event.clear()
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
self._loop_task = loop.create_task(self._mqtt_loop())
|
||||||
|
|
||||||
|
async def async_close(self) -> None:
|
||||||
|
"""Close connection loop and MQTT client."""
|
||||||
|
await self.stop()
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""Stop MQTT connection loop."""
|
||||||
|
self._stop_event.set()
|
||||||
|
|
||||||
|
if self._loop_task:
|
||||||
|
self._loop_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._loop_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if self._client:
|
||||||
|
try:
|
||||||
|
await self._client.disconnect()
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.exception("Error disconnecting MQTT client")
|
||||||
|
finally:
|
||||||
|
self._client = None
|
||||||
|
|
||||||
|
# Drop pending commands on stop
|
||||||
|
while not self._command_queue.empty():
|
||||||
|
try:
|
||||||
|
self._command_queue.get_nowait()
|
||||||
|
self._command_queue.task_done()
|
||||||
|
except asyncio.QueueEmpty:
|
||||||
|
break
|
||||||
|
|
||||||
|
async def wait_until_ready(
|
||||||
|
self, device: OasisDevice, timeout: float = 10.0, request_status: bool = True
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Wait until:
|
||||||
|
1. MQTT client is connected
|
||||||
|
2. Device sends at least one STATUS message
|
||||||
|
|
||||||
|
If request_status=True, a request status command is sent *after* connection.
|
||||||
|
"""
|
||||||
|
serial = device.serial_number
|
||||||
|
if not serial:
|
||||||
|
raise RuntimeError("Device has no serial_number set")
|
||||||
|
|
||||||
|
first_status_event = self._first_status_events.setdefault(
|
||||||
|
serial, asyncio.Event()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait for MQTT connection
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self._connected_event.wait(), timeout=timeout)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Timeout (%.1fs) waiting for MQTT connection (device %s)",
|
||||||
|
timeout,
|
||||||
|
serial,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Optionally request a status refresh
|
||||||
|
if request_status:
|
||||||
|
try:
|
||||||
|
first_status_event.clear()
|
||||||
|
await self.async_get_status(device)
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Could not request status for %s (not fully connected yet?)",
|
||||||
|
serial,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait for first status
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(first_status_event.wait(), timeout=timeout)
|
||||||
|
return True
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Timeout (%.1fs) waiting for first STATUS message from %s",
|
||||||
|
timeout,
|
||||||
|
serial,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def async_get_mac_address(self, device: OasisDevice) -> str | None:
|
||||||
|
"""For MQTT, GETSTATUS causes MAC_ADDRESS to be published."""
|
||||||
|
# If already known on the device, return it
|
||||||
|
if device.mac_address:
|
||||||
|
return device.mac_address
|
||||||
|
|
||||||
|
serial = device.serial_number
|
||||||
|
if not serial:
|
||||||
|
raise RuntimeError("Device has no serial_number set")
|
||||||
|
|
||||||
|
mac_event = self._mac_events.setdefault(serial, asyncio.Event())
|
||||||
|
mac_event.clear()
|
||||||
|
|
||||||
|
# Ask device to refresh status (including MAC_ADDRESS)
|
||||||
|
await self.async_get_status(device)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(mac_event.wait(), timeout=3.0)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
_LOGGER.debug("Timed out waiting for MAC_ADDRESS for %s", serial)
|
||||||
|
|
||||||
|
return device.mac_address
|
||||||
|
|
||||||
|
async def async_send_ball_speed_command(
|
||||||
|
self,
|
||||||
|
device: OasisDevice,
|
||||||
|
speed: int,
|
||||||
|
) -> None:
|
||||||
|
payload = f"WRIOASISSPEED={speed}"
|
||||||
|
await self._publish_command(device, payload)
|
||||||
|
|
||||||
|
async def async_send_led_command(
|
||||||
|
self,
|
||||||
|
device: OasisDevice,
|
||||||
|
led_effect: str,
|
||||||
|
color: str,
|
||||||
|
led_speed: int,
|
||||||
|
brightness: int,
|
||||||
|
) -> None:
|
||||||
|
payload = f"WRILED={led_effect};0;{color};{led_speed};{brightness}"
|
||||||
|
await self._publish_command(device, payload, bool(brightness))
|
||||||
|
|
||||||
|
async def async_send_sleep_command(self, device: OasisDevice) -> None:
|
||||||
|
await self._publish_command(device, "CMDSLEEP")
|
||||||
|
|
||||||
|
async def async_send_move_job_command(
|
||||||
|
self,
|
||||||
|
device: OasisDevice,
|
||||||
|
from_index: int,
|
||||||
|
to_index: int,
|
||||||
|
) -> None:
|
||||||
|
payload = f"MOVEJOB={from_index};{to_index}"
|
||||||
|
await self._publish_command(device, payload)
|
||||||
|
|
||||||
|
async def async_send_change_track_command(
|
||||||
|
self,
|
||||||
|
device: OasisDevice,
|
||||||
|
index: int,
|
||||||
|
) -> None:
|
||||||
|
payload = f"CMDCHANGETRACK={index}"
|
||||||
|
await self._publish_command(device, payload)
|
||||||
|
|
||||||
|
async def async_send_add_joblist_command(
|
||||||
|
self,
|
||||||
|
device: OasisDevice,
|
||||||
|
tracks: list[int],
|
||||||
|
) -> None:
|
||||||
|
track_str = ",".join(map(str, tracks))
|
||||||
|
payload = f"ADDJOBLIST={track_str}"
|
||||||
|
await self._publish_command(device, payload)
|
||||||
|
|
||||||
|
async def async_send_set_playlist_command(
|
||||||
|
self,
|
||||||
|
device: OasisDevice,
|
||||||
|
playlist: list[int],
|
||||||
|
) -> None:
|
||||||
|
track_str = ",".join(map(str, playlist))
|
||||||
|
payload = f"WRIJOBLIST={track_str}"
|
||||||
|
await self._publish_command(device, payload)
|
||||||
|
|
||||||
|
# local state optimistic update
|
||||||
|
device.update_from_status_dict({"playlist": playlist})
|
||||||
|
|
||||||
|
async def async_send_set_repeat_playlist_command(
|
||||||
|
self,
|
||||||
|
device: OasisDevice,
|
||||||
|
repeat: bool,
|
||||||
|
) -> None:
|
||||||
|
payload = f"WRIREPEATJOB={1 if repeat else 0}"
|
||||||
|
await self._publish_command(device, payload)
|
||||||
|
|
||||||
|
async def async_send_set_autoplay_command(
|
||||||
|
self,
|
||||||
|
device: OasisDevice,
|
||||||
|
option: str,
|
||||||
|
) -> None:
|
||||||
|
payload = f"WRIWAITAFTER={option}"
|
||||||
|
await self._publish_command(device, payload)
|
||||||
|
|
||||||
|
async def async_send_upgrade_command(
|
||||||
|
self,
|
||||||
|
device: OasisDevice,
|
||||||
|
beta: bool,
|
||||||
|
) -> None:
|
||||||
|
payload = f"CMDUPGRADE={1 if beta else 0}"
|
||||||
|
await self._publish_command(device, payload)
|
||||||
|
|
||||||
|
async def async_send_play_command(self, device: OasisDevice) -> None:
|
||||||
|
await self._publish_command(device, "CMDPLAY", True)
|
||||||
|
|
||||||
|
async def async_send_pause_command(self, device: OasisDevice) -> None:
|
||||||
|
await self._publish_command(device, "CMDPAUSE")
|
||||||
|
|
||||||
|
async def async_send_stop_command(self, device: OasisDevice) -> None:
|
||||||
|
await self._publish_command(device, "CMDSTOP")
|
||||||
|
|
||||||
|
async def async_send_reboot_command(self, device: OasisDevice) -> None:
|
||||||
|
await self._publish_command(device, "CMDBOOT")
|
||||||
|
|
||||||
|
async def async_get_all(self, device: OasisDevice) -> None:
|
||||||
|
"""Request FULLSTATUS + SCHEDULE (compact snapshot)."""
|
||||||
|
await self._publish_command(device, "GETALL")
|
||||||
|
|
||||||
|
async def async_get_status(self, device: OasisDevice) -> None:
|
||||||
|
"""Ask device to publish STATUS topics."""
|
||||||
|
await self._publish_command(device, "GETSTATUS")
|
||||||
|
|
||||||
|
async def _enqueue_command(self, serial: str, payload: str) -> None:
|
||||||
|
"""Queue a command to be sent when connected.
|
||||||
|
|
||||||
|
If the queue is full, drop the oldest command to make room.
|
||||||
|
"""
|
||||||
|
if self._command_queue.full():
|
||||||
|
try:
|
||||||
|
dropped = self._command_queue.get_nowait()
|
||||||
|
self._command_queue.task_done()
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Command queue full, dropping oldest command: %s", dropped
|
||||||
|
)
|
||||||
|
except asyncio.QueueEmpty:
|
||||||
|
# race: became empty between full() and get_nowait()
|
||||||
|
pass
|
||||||
|
|
||||||
|
await self._command_queue.put((serial, payload))
|
||||||
|
_LOGGER.debug("Queued command for %s: %s", serial, payload)
|
||||||
|
|
||||||
|
async def _flush_pending_commands(self) -> None:
|
||||||
|
"""Send any queued commands now that we're connected."""
|
||||||
|
if not self._client:
|
||||||
|
return
|
||||||
|
|
||||||
|
while not self._command_queue.empty():
|
||||||
|
try:
|
||||||
|
serial, payload = self._command_queue.get_nowait()
|
||||||
|
except asyncio.QueueEmpty:
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Skip commands for unknown devices
|
||||||
|
if serial not in self._devices:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Skipping queued command for unknown device %s: %s",
|
||||||
|
serial,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
self._command_queue.task_done()
|
||||||
|
continue
|
||||||
|
|
||||||
|
topic = f"{serial}/COMMAND/CMD"
|
||||||
|
_LOGGER.debug("Flushing queued MQTT command %s => %s", topic, payload)
|
||||||
|
await self._client.publish(topic, payload.encode(), qos=1)
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Failed to flush queued command for %s, re-queuing", serial
|
||||||
|
)
|
||||||
|
# Put it back and break; we'll try again on next reconnect
|
||||||
|
await self._enqueue_command(serial, payload)
|
||||||
|
self._command_queue.task_done()
|
||||||
|
break
|
||||||
|
|
||||||
|
self._command_queue.task_done()
|
||||||
|
|
||||||
|
async def _publish_command(
|
||||||
|
self, device: OasisDevice, payload: str, wake: bool = False
|
||||||
|
) -> None:
|
||||||
|
serial = device.serial_number
|
||||||
|
if not serial:
|
||||||
|
raise RuntimeError("Device has no serial number set")
|
||||||
|
|
||||||
|
if wake and device.is_sleeping:
|
||||||
|
await self.async_get_all(device)
|
||||||
|
|
||||||
|
# If not connected, just queue the command
|
||||||
|
if not self._client or not self._connected_event.is_set():
|
||||||
|
_LOGGER.debug(
|
||||||
|
"MQTT not connected, queueing command for %s: %s", serial, payload
|
||||||
|
)
|
||||||
|
await self._enqueue_command(serial, payload)
|
||||||
|
return
|
||||||
|
|
||||||
|
topic = f"{serial}/COMMAND/CMD"
|
||||||
|
try:
|
||||||
|
_LOGGER.debug("MQTT publish %s => %s", topic, payload)
|
||||||
|
await self._client.publish(topic, payload.encode(), qos=1)
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"MQTT publish failed, queueing command for %s: %s", serial, payload
|
||||||
|
)
|
||||||
|
await self._enqueue_command(serial, payload)
|
||||||
|
|
||||||
|
async def _mqtt_loop(self) -> None:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
tls_context = await loop.run_in_executor(None, ssl.create_default_context)
|
||||||
|
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
try:
|
||||||
|
_LOGGER.info("Connecting MQTT WSS to wss://%s:%s/%s", HOST, PORT, PATH)
|
||||||
|
|
||||||
|
async with aiomqtt.Client(
|
||||||
|
hostname=HOST,
|
||||||
|
port=PORT,
|
||||||
|
transport="websockets",
|
||||||
|
tls_context=tls_context,
|
||||||
|
username=base64.b64decode(USERNAME).decode(),
|
||||||
|
password=base64.b64decode(PASSWORD).decode(),
|
||||||
|
keepalive=30,
|
||||||
|
websocket_path=f"/{PATH}",
|
||||||
|
) as client:
|
||||||
|
self._client = client
|
||||||
|
self._connected_event.set()
|
||||||
|
self._connected_at = datetime.now(UTC)
|
||||||
|
_LOGGER.info("Connected to MQTT broker")
|
||||||
|
|
||||||
|
# Subscribe only to STATUS topics for known devices
|
||||||
|
await self._resubscribe_all()
|
||||||
|
|
||||||
|
# Flush any queued commands now that we're connected
|
||||||
|
await self._flush_pending_commands()
|
||||||
|
|
||||||
|
async for msg in client.messages:
|
||||||
|
if self._stop_event.is_set():
|
||||||
|
break
|
||||||
|
await self._handle_status_message(msg)
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.info("MQTT connection error")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if self._connected_event.is_set():
|
||||||
|
self._connected_event.clear()
|
||||||
|
if self._connected_at:
|
||||||
|
_LOGGER.info(
|
||||||
|
"MQTT was connected for %s",
|
||||||
|
datetime.now(UTC) - self._connected_at,
|
||||||
|
)
|
||||||
|
self._connected_at = None
|
||||||
|
self._client = None
|
||||||
|
self._subscribed_serials.clear()
|
||||||
|
|
||||||
|
if not self._stop_event.is_set():
|
||||||
|
_LOGGER.info(
|
||||||
|
"Disconnected from broker, retrying in %.1fs", RECONNECT_INTERVAL
|
||||||
|
)
|
||||||
|
await asyncio.sleep(RECONNECT_INTERVAL)
|
||||||
|
|
||||||
|
async def _handle_status_message(self, msg: aiomqtt.Message) -> None:
|
||||||
|
"""Map MQTT STATUS topics → OasisDevice.update_from_status_dict payloads."""
|
||||||
|
topic_str = str(msg.topic) if msg.topic is not None else ""
|
||||||
|
payload = msg.payload.decode(errors="replace")
|
||||||
|
|
||||||
|
parts = topic_str.split("/")
|
||||||
|
# Expect: "<serial>/STATUS/<STATUS_NAME>"
|
||||||
|
if len(parts) < 3:
|
||||||
|
return
|
||||||
|
|
||||||
|
serial, _, status_name = parts[:3]
|
||||||
|
|
||||||
|
device = self._devices.get(serial)
|
||||||
|
if not device:
|
||||||
|
_LOGGER.debug("Received MQTT for unknown device %s: %s", serial, topic_str)
|
||||||
|
return
|
||||||
|
|
||||||
|
data: dict[str, Any] = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if status_name == "OASIS_STATUS":
|
||||||
|
data["status_code"] = int(payload)
|
||||||
|
elif status_name == "OASIS_ERROR":
|
||||||
|
data["error"] = int(payload)
|
||||||
|
elif status_name == "OASIS_SPEEED":
|
||||||
|
data["ball_speed"] = int(payload)
|
||||||
|
elif status_name == "JOBLIST":
|
||||||
|
data["playlist"] = [int(x) for x in payload.split(",") if x]
|
||||||
|
elif status_name == "CURRENTJOB":
|
||||||
|
data["playlist_index"] = int(payload)
|
||||||
|
elif status_name == "CURRENTLINE":
|
||||||
|
data["progress"] = int(payload)
|
||||||
|
elif status_name == "LED_EFFECT":
|
||||||
|
data["led_effect"] = payload
|
||||||
|
elif status_name == "LED_EFFECT_COLOR":
|
||||||
|
data["led_color_id"] = payload
|
||||||
|
elif status_name == "LED_SPEED":
|
||||||
|
data["led_speed"] = int(payload)
|
||||||
|
elif status_name == "LED_BRIGHTNESS":
|
||||||
|
data["brightness"] = int(payload)
|
||||||
|
elif status_name == "LED_MAX":
|
||||||
|
data["brightness_max"] = int(payload)
|
||||||
|
elif status_name == "LED_EFFECT_PARAM":
|
||||||
|
data["color"] = payload if payload.startswith("#") else None
|
||||||
|
elif status_name == "SYSTEM_BUSY":
|
||||||
|
data["busy"] = payload in ("1", "true", "True")
|
||||||
|
elif status_name == "DOWNLOAD_PROGRESS":
|
||||||
|
data["download_progress"] = int(payload)
|
||||||
|
elif status_name == "REPEAT_JOB":
|
||||||
|
data["repeat_playlist"] = payload in ("1", "true", "True")
|
||||||
|
elif status_name == "WAIT_AFTER_JOB":
|
||||||
|
data["autoplay"] = _parse_int(payload)
|
||||||
|
elif status_name == "AUTO_CLEAN":
|
||||||
|
data["auto_clean"] = payload in ("1", "true", "True")
|
||||||
|
elif status_name == "SOFTWARE_VER":
|
||||||
|
data["software_version"] = payload
|
||||||
|
elif status_name == "MAC_ADDRESS":
|
||||||
|
data["mac_address"] = payload
|
||||||
|
mac_event = self._mac_events.setdefault(serial, asyncio.Event())
|
||||||
|
mac_event.set()
|
||||||
|
elif status_name == "WIFI_SSID":
|
||||||
|
data["wifi_ssid"] = payload
|
||||||
|
elif status_name == "WIFI_IP":
|
||||||
|
data["wifi_ip"] = payload
|
||||||
|
elif status_name == "WIFI_PDNS":
|
||||||
|
data["wifi_pdns"] = payload
|
||||||
|
elif status_name == "WIFI_SDNS":
|
||||||
|
data["wifi_sdns"] = payload
|
||||||
|
elif status_name == "WIFI_GATE":
|
||||||
|
data["wifi_gate"] = payload
|
||||||
|
elif status_name == "WIFI_SUB":
|
||||||
|
data["wifi_sub"] = payload
|
||||||
|
elif status_name == "WIFI_STATUS":
|
||||||
|
data["wifi_connected"] = _bit_to_bool(payload)
|
||||||
|
elif status_name == "SCHEDULE":
|
||||||
|
data["schedule"] = payload
|
||||||
|
elif status_name == "ENVIRONMENT":
|
||||||
|
data["environment"] = payload
|
||||||
|
elif status_name == "FULLSTATUS":
|
||||||
|
if parsed := device.parse_status_string(payload):
|
||||||
|
data = parsed
|
||||||
|
else:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Unknown status received for %s: %s=%s",
|
||||||
|
serial,
|
||||||
|
status_name,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
_LOGGER.exception(
|
||||||
|
"Error parsing MQTT payload for %s %s: %r", serial, status_name, payload
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if data:
|
||||||
|
device.update_from_status_dict(data)
|
||||||
|
|
||||||
|
first_status_event = self._first_status_events.setdefault(
|
||||||
|
serial, asyncio.Event()
|
||||||
|
)
|
||||||
|
if not first_status_event.is_set():
|
||||||
|
first_status_event.set()
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from ..device import OasisDevice
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class OasisClientProtocol(Protocol):
|
||||||
|
"""Transport/client interface for an Oasis device.
|
||||||
|
|
||||||
|
Concrete implementations:
|
||||||
|
- MQTT client (remote connection)
|
||||||
|
- HTTP client (direct LAN)
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def async_get_mac_address(self, device: OasisDevice) -> str | None: ...
|
||||||
|
|
||||||
|
async def async_send_ball_speed_command(
|
||||||
|
self,
|
||||||
|
device: OasisDevice,
|
||||||
|
speed: int,
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
async def async_send_led_command(
|
||||||
|
self,
|
||||||
|
device: OasisDevice,
|
||||||
|
led_effect: str,
|
||||||
|
color: str,
|
||||||
|
led_speed: int,
|
||||||
|
brightness: int,
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
async def async_send_sleep_command(self, device: OasisDevice) -> None: ...
|
||||||
|
|
||||||
|
async def async_send_move_job_command(
|
||||||
|
self,
|
||||||
|
device: OasisDevice,
|
||||||
|
from_index: int,
|
||||||
|
to_index: int,
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
async def async_send_change_track_command(
|
||||||
|
self,
|
||||||
|
device: OasisDevice,
|
||||||
|
index: int,
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
async def async_send_add_joblist_command(
|
||||||
|
self,
|
||||||
|
device: OasisDevice,
|
||||||
|
tracks: list[int],
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
async def async_send_set_playlist_command(
|
||||||
|
self,
|
||||||
|
device: OasisDevice,
|
||||||
|
playlist: list[int],
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
async def async_send_set_repeat_playlist_command(
|
||||||
|
self,
|
||||||
|
device: OasisDevice,
|
||||||
|
repeat: bool,
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
async def async_send_set_autoplay_command(
|
||||||
|
self,
|
||||||
|
device: OasisDevice,
|
||||||
|
option: str,
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
async def async_send_upgrade_command(
|
||||||
|
self,
|
||||||
|
device: OasisDevice,
|
||||||
|
beta: bool,
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
async def async_send_play_command(self, device: OasisDevice) -> None: ...
|
||||||
|
|
||||||
|
async def async_send_pause_command(self, device: OasisDevice) -> None: ...
|
||||||
|
|
||||||
|
async def async_send_stop_command(self, device: OasisDevice) -> None: ...
|
||||||
|
|
||||||
|
async def async_send_reboot_command(self, device: OasisDevice) -> None: ...
|
||||||
|
|
||||||
|
async def async_get_all(self, device: OasisDevice) -> None: ...
|
||||||
|
|
||||||
|
async def async_get_status(self, device: OasisDevice) -> None: ...
|
||||||
110
custom_components/oasis_mini/pyoasiscontrol/const.py
Normal file
110
custom_components/oasis_mini/pyoasiscontrol/const.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
"""Constants."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Any, Final
|
||||||
|
|
||||||
|
__TRACKS_FILE = os.path.join(os.path.dirname(__file__), "tracks.json")
|
||||||
|
try:
|
||||||
|
with open(__TRACKS_FILE, "r", encoding="utf8") as file:
|
||||||
|
TRACKS: Final[dict[int, dict[str, Any]]] = {
|
||||||
|
int(k): v for k, v in json.load(file).items()
|
||||||
|
}
|
||||||
|
except Exception: # ignore: broad-except
|
||||||
|
TRACKS = {}
|
||||||
|
|
||||||
|
AUTOPLAY_MAP: Final[dict[str, str]] = {
|
||||||
|
"0": "on",
|
||||||
|
"1": "off",
|
||||||
|
"2": "5 minutes",
|
||||||
|
"3": "10 minutes",
|
||||||
|
"4": "30 minutes",
|
||||||
|
"6": "1 hour",
|
||||||
|
"7": "6 hours",
|
||||||
|
"8": "12 hours",
|
||||||
|
"5": "24 hours",
|
||||||
|
}
|
||||||
|
|
||||||
|
ERROR_CODE_MAP: Final[dict[int, str]] = {
|
||||||
|
0: "None",
|
||||||
|
1: "Error has occurred while reading the flash memory",
|
||||||
|
2: "Error while starting the Wifi",
|
||||||
|
3: "Error when starting DNS settings for your machine",
|
||||||
|
4: "Failed to open the file to write",
|
||||||
|
5: "Not enough memory to perform the upgrade",
|
||||||
|
6: "Error while trying to upgrade your system",
|
||||||
|
7: "Error while trying to download the new version of the software",
|
||||||
|
8: "Error while reading the upgrading file",
|
||||||
|
9: "Failed to start downloading the upgrade file",
|
||||||
|
10: "Error while starting downloading the job file",
|
||||||
|
11: "Error while opening the file folder",
|
||||||
|
12: "Failed to delete a file",
|
||||||
|
13: "Error while opening the job file",
|
||||||
|
14: "You have wrong power adapter",
|
||||||
|
15: "Failed to update the device IP on Oasis Server",
|
||||||
|
16: "Your device failed centering itself",
|
||||||
|
17: "There appears to be an issue with your Oasis Device",
|
||||||
|
18: "Error while downloading the job file",
|
||||||
|
}
|
||||||
|
|
||||||
|
LED_EFFECTS: Final[dict[str, str]] = {
|
||||||
|
"0": "Solid",
|
||||||
|
"1": "Rainbow",
|
||||||
|
"2": "Glitter",
|
||||||
|
"3": "Confetti",
|
||||||
|
"4": "Sinelon",
|
||||||
|
"5": "BPM",
|
||||||
|
"6": "Juggle",
|
||||||
|
"7": "Theater",
|
||||||
|
"8": "Color Wipe",
|
||||||
|
"9": "Sparkle",
|
||||||
|
"10": "Comet",
|
||||||
|
"11": "Follow Ball",
|
||||||
|
"12": "Follow Rainbow",
|
||||||
|
"13": "Chasing Comet",
|
||||||
|
"14": "Gradient Follow",
|
||||||
|
"15": "Cumulative Fill",
|
||||||
|
"16": "Multi Comets A",
|
||||||
|
"17": "Rainbow Chaser",
|
||||||
|
"18": "Twinkle Lights",
|
||||||
|
"19": "Tennis Game",
|
||||||
|
"20": "Breathing Exercise 4-7-8",
|
||||||
|
"21": "Cylon Scanner",
|
||||||
|
"22": "Palette Mode",
|
||||||
|
"23": "Aurora Flow",
|
||||||
|
"24": "Colorful Drops",
|
||||||
|
"25": "Color Snake",
|
||||||
|
"26": "Flickering Candles",
|
||||||
|
"27": "Digital Rain",
|
||||||
|
"28": "Center Explosion",
|
||||||
|
"29": "Rainbow Plasma",
|
||||||
|
"30": "Comet Race",
|
||||||
|
"31": "Color Waves",
|
||||||
|
"32": "Meteor Storm",
|
||||||
|
"33": "Firefly Flicker",
|
||||||
|
"34": "Ripple",
|
||||||
|
"35": "Jelly Bean",
|
||||||
|
"36": "Forest Rain",
|
||||||
|
"37": "Multi Comets",
|
||||||
|
"38": "Multi Comets with Background",
|
||||||
|
"39": "Rainbow Fill",
|
||||||
|
"40": "White Red Comet",
|
||||||
|
"41": "Color Comets",
|
||||||
|
}
|
||||||
|
|
||||||
|
STATUS_CODE_SLEEPING: Final = 6
|
||||||
|
STATUS_CODE_MAP: Final[dict[int, str]] = {
|
||||||
|
0: "booting",
|
||||||
|
2: "stopped",
|
||||||
|
3: "centering",
|
||||||
|
4: "playing",
|
||||||
|
5: "paused",
|
||||||
|
STATUS_CODE_SLEEPING: "sleeping",
|
||||||
|
9: "error",
|
||||||
|
11: "updating",
|
||||||
|
13: "downloading",
|
||||||
|
14: "busy",
|
||||||
|
15: "live",
|
||||||
|
}
|
||||||
412
custom_components/oasis_mini/pyoasiscontrol/device.py
Normal file
412
custom_components/oasis_mini/pyoasiscontrol/device.py
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
"""Oasis device."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING, Any, Callable, Final, Iterable
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
ERROR_CODE_MAP,
|
||||||
|
LED_EFFECTS,
|
||||||
|
STATUS_CODE_MAP,
|
||||||
|
STATUS_CODE_SLEEPING,
|
||||||
|
TRACKS,
|
||||||
|
)
|
||||||
|
from .utils import _bit_to_bool, _parse_int
|
||||||
|
|
||||||
|
if TYPE_CHECKING: # avoid runtime circular imports
|
||||||
|
from .clients.transport import OasisClientProtocol
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
BALL_SPEED_MAX: Final = 400
|
||||||
|
BALL_SPEED_MIN: Final = 100
|
||||||
|
LED_SPEED_MAX: Final = 90
|
||||||
|
LED_SPEED_MIN: Final = -90
|
||||||
|
|
||||||
|
_STATE_FIELDS = (
|
||||||
|
"auto_clean",
|
||||||
|
"autoplay",
|
||||||
|
"ball_speed",
|
||||||
|
"brightness",
|
||||||
|
"busy",
|
||||||
|
"color",
|
||||||
|
"download_progress",
|
||||||
|
"error",
|
||||||
|
"led_effect",
|
||||||
|
"led_speed",
|
||||||
|
"mac_address",
|
||||||
|
"playlist",
|
||||||
|
"playlist_index",
|
||||||
|
"progress",
|
||||||
|
"repeat_playlist",
|
||||||
|
"serial_number",
|
||||||
|
"software_version",
|
||||||
|
"status_code",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OasisDevice:
|
||||||
|
"""Oasis device model + behavior.
|
||||||
|
|
||||||
|
Transport-agnostic; all I/O is delegated to an attached
|
||||||
|
OasisClientProtocol (MQTT, HTTP, etc.) via `attach_client`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
manufacturer: Final = "Kinetic Oasis"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
model: str | None = None,
|
||||||
|
serial_number: str | None = None,
|
||||||
|
ssid: str | None = None,
|
||||||
|
ip_address: str | None = None,
|
||||||
|
client: OasisClientProtocol | None = None,
|
||||||
|
) -> None:
|
||||||
|
# Transport
|
||||||
|
self._client: OasisClientProtocol | None = client
|
||||||
|
self._listeners: list[Callable[[], None]] = []
|
||||||
|
|
||||||
|
# Details
|
||||||
|
self.model: str | None = model
|
||||||
|
self.serial_number: str | None = serial_number
|
||||||
|
self.ssid: str | None = ssid
|
||||||
|
self.ip_address: str | None = ip_address
|
||||||
|
|
||||||
|
# Status
|
||||||
|
self.auto_clean: bool = False
|
||||||
|
self.autoplay: int = 0
|
||||||
|
self.ball_speed: int = BALL_SPEED_MIN
|
||||||
|
self._brightness: int = 0
|
||||||
|
self.brightness_max: int = 200
|
||||||
|
self.brightness_on: int = 0
|
||||||
|
self.busy: bool = False
|
||||||
|
self.color: str | None = None
|
||||||
|
self.download_progress: int = 0
|
||||||
|
self.error: int = 0
|
||||||
|
self.led_color_id: str = "0"
|
||||||
|
self.led_effect: str = "0"
|
||||||
|
self.led_speed: int = 0
|
||||||
|
self.mac_address: str | None = None
|
||||||
|
self.playlist: list[int] = []
|
||||||
|
self.playlist_index: int = 0
|
||||||
|
self.progress: int = 0
|
||||||
|
self.repeat_playlist: bool = False
|
||||||
|
self.software_version: str | None = None
|
||||||
|
self.status_code: int = 0
|
||||||
|
self.wifi_connected: bool = False
|
||||||
|
self.wifi_ip: str | None = None
|
||||||
|
self.wifi_ssid: str | None = None
|
||||||
|
self.wifi_pdns: str | None = None
|
||||||
|
self.wifi_sdns: str | None = None
|
||||||
|
self.wifi_gate: str | None = None
|
||||||
|
self.wifi_sub: str | None = None
|
||||||
|
self.environment: str | None = None
|
||||||
|
self.schedule: Any | None = None
|
||||||
|
|
||||||
|
# Track metadata cache (used if you hydrate from cloud)
|
||||||
|
self._track: dict | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def brightness(self) -> int:
|
||||||
|
"""Return the brightness."""
|
||||||
|
return 0 if self.is_sleeping else self._brightness
|
||||||
|
|
||||||
|
@brightness.setter
|
||||||
|
def brightness(self, value: int) -> None:
|
||||||
|
self._brightness = value
|
||||||
|
if value:
|
||||||
|
self.brightness_on = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_sleeping(self) -> bool:
|
||||||
|
"""Return `True` if the status is set to sleeping."""
|
||||||
|
return self.status_code == STATUS_CODE_SLEEPING
|
||||||
|
|
||||||
|
def attach_client(self, client: OasisClientProtocol) -> None:
|
||||||
|
"""Attach a transport client (MQTT, HTTP, etc.) to this device."""
|
||||||
|
self._client = client
|
||||||
|
|
||||||
|
@property
|
||||||
|
def client(self) -> OasisClientProtocol | None:
|
||||||
|
"""Return the current transport client, if any."""
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
def _require_client(self) -> OasisClientProtocol:
|
||||||
|
"""Return the attached client or raise if missing."""
|
||||||
|
if self._client is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"No client/transport attached for device {self.serial_number!r}"
|
||||||
|
)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
def _update_field(self, name: str, value: Any) -> bool:
|
||||||
|
old = getattr(self, name, None)
|
||||||
|
if old != value:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"%s changed: '%s' -> '%s'",
|
||||||
|
name.replace("_", " ").capitalize(),
|
||||||
|
old,
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
setattr(self, name, value)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def update_from_status_dict(self, data: dict[str, Any]) -> None:
|
||||||
|
"""Update device fields from a status payload (from any transport)."""
|
||||||
|
changed = False
|
||||||
|
for key, value in data.items():
|
||||||
|
if hasattr(self, key):
|
||||||
|
if self._update_field(key, value):
|
||||||
|
changed = True
|
||||||
|
else:
|
||||||
|
_LOGGER.warning("Unknown field: %s=%s", key, value)
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
self._notify_listeners()
|
||||||
|
|
||||||
|
def parse_status_string(self, raw_status: str) -> dict[str, Any] | None:
|
||||||
|
"""Parse a semicolon-separated status string into a state dict.
|
||||||
|
|
||||||
|
Used by:
|
||||||
|
- HTTP GETSTATUS response
|
||||||
|
- MQTT FULLSTATUS payload (includes software_version)
|
||||||
|
"""
|
||||||
|
if not raw_status:
|
||||||
|
return None
|
||||||
|
|
||||||
|
values = raw_status.split(";")
|
||||||
|
|
||||||
|
# We rely on indices 0..17 existing (18 fields)
|
||||||
|
if (n := len(values)) < 18:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Unexpected status format for %s: %s", self.serial_number, values
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
playlist = [_parse_int(track) for track in values[3].split(",") if track]
|
||||||
|
|
||||||
|
try:
|
||||||
|
status: dict[str, Any] = {
|
||||||
|
"status_code": _parse_int(values[0]),
|
||||||
|
"error": _parse_int(values[1]),
|
||||||
|
"ball_speed": _parse_int(values[2]),
|
||||||
|
"playlist": playlist,
|
||||||
|
"playlist_index": min(_parse_int(values[4]), len(playlist)),
|
||||||
|
"progress": _parse_int(values[5]),
|
||||||
|
"led_effect": values[6],
|
||||||
|
"led_color_id": values[7],
|
||||||
|
"led_speed": _parse_int(values[8]),
|
||||||
|
"brightness": _parse_int(values[9]),
|
||||||
|
"color": values[10] if "#" in values[10] else None,
|
||||||
|
"busy": _bit_to_bool(values[11]),
|
||||||
|
"download_progress": _parse_int(values[12]),
|
||||||
|
"brightness_max": _parse_int(values[13]),
|
||||||
|
"wifi_connected": _bit_to_bool(values[14]),
|
||||||
|
"repeat_playlist": _bit_to_bool(values[15]),
|
||||||
|
"autoplay": _parse_int(values[16]),
|
||||||
|
"auto_clean": _bit_to_bool(values[17]),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Optional trailing field(s)
|
||||||
|
if n > 18:
|
||||||
|
status["software_version"] = values[18]
|
||||||
|
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
_LOGGER.exception(
|
||||||
|
"Error parsing status string for %s: %r", self.serial_number, raw_status
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return status
|
||||||
|
|
||||||
|
def update_from_status_string(self, raw_status: str) -> None:
|
||||||
|
"""Parse and apply a raw status string."""
|
||||||
|
if status := self.parse_status_string(raw_status):
|
||||||
|
self.update_from_status_dict(status)
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
"""Return core state as a dict."""
|
||||||
|
return {field: getattr(self, field) for field in _STATE_FIELDS}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def error_message(self) -> str | None:
|
||||||
|
"""Return the error message, if any."""
|
||||||
|
if self.status_code == 9:
|
||||||
|
return ERROR_CODE_MAP.get(self.error, f"Unknown ({self.error})")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status(self) -> str:
|
||||||
|
"""Return human-readable status from status_code."""
|
||||||
|
return STATUS_CODE_MAP.get(self.status_code, f"Unknown ({self.status_code})")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def track_id(self) -> int | None:
|
||||||
|
if not self.playlist:
|
||||||
|
return None
|
||||||
|
i = self.playlist_index
|
||||||
|
return self.playlist[0] if i >= len(self.playlist) else self.playlist[i]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def track(self) -> dict | None:
|
||||||
|
"""Return cached track info if it matches the current `track_id`."""
|
||||||
|
if self._track and self._track.get("id") == self.track_id:
|
||||||
|
return self._track
|
||||||
|
if track := TRACKS.get(self.track_id):
|
||||||
|
self._track = track
|
||||||
|
return self._track
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def drawing_progress(self) -> float | None:
|
||||||
|
"""Return drawing progress percentage for the current track."""
|
||||||
|
# if not (self.track and (svg_content := self.track.get("svg_content"))):
|
||||||
|
# return None
|
||||||
|
# svg_content = decrypt_svg_content(svg_content)
|
||||||
|
# paths = svg_content.split("L")
|
||||||
|
total = self.track.get("reduced_svg_content_new", 0) # or len(paths)
|
||||||
|
percent = (100 * self.progress) / total
|
||||||
|
return percent
|
||||||
|
|
||||||
|
@property
|
||||||
|
def playlist_details(self) -> dict[int, dict[str, str]]:
|
||||||
|
"""Basic playlist details using built-in TRACKS metadata."""
|
||||||
|
return {
|
||||||
|
track_id: TRACKS.get(
|
||||||
|
track_id,
|
||||||
|
{"name": f"Unknown Title (#{track_id})"},
|
||||||
|
)
|
||||||
|
for track_id in self.playlist
|
||||||
|
}
|
||||||
|
|
||||||
|
def add_update_listener(self, listener: Callable[[], None]) -> Callable[[], None]:
|
||||||
|
"""Register a callback for state changes.
|
||||||
|
|
||||||
|
Returns an unsubscribe function.
|
||||||
|
"""
|
||||||
|
self._listeners.append(listener)
|
||||||
|
|
||||||
|
def _unsub() -> None:
|
||||||
|
try:
|
||||||
|
self._listeners.remove(listener)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return _unsub
|
||||||
|
|
||||||
|
def _notify_listeners(self) -> None:
|
||||||
|
"""Call all registered listeners."""
|
||||||
|
for listener in list(self._listeners):
|
||||||
|
try:
|
||||||
|
listener()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
_LOGGER.exception("Error in update listener")
|
||||||
|
|
||||||
|
async def async_get_mac_address(self) -> str | None:
|
||||||
|
"""Return the device MAC address, refreshing via transport if needed."""
|
||||||
|
if self.mac_address:
|
||||||
|
return self.mac_address
|
||||||
|
|
||||||
|
client = self._require_client()
|
||||||
|
mac = await client.async_get_mac_address(self)
|
||||||
|
if mac:
|
||||||
|
self._update_field("mac_address", mac)
|
||||||
|
return mac
|
||||||
|
|
||||||
|
async def async_set_ball_speed(self, speed: int) -> None:
|
||||||
|
if not BALL_SPEED_MIN <= speed <= BALL_SPEED_MAX:
|
||||||
|
raise ValueError("Invalid speed specified")
|
||||||
|
client = self._require_client()
|
||||||
|
await client.async_send_ball_speed_command(self, speed)
|
||||||
|
|
||||||
|
async def async_set_led(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
led_effect: str | None = None,
|
||||||
|
color: str | None = None,
|
||||||
|
led_speed: int | None = None,
|
||||||
|
brightness: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Set the Oasis Mini LED (shared validation & attribute updates)."""
|
||||||
|
if led_effect is None:
|
||||||
|
led_effect = self.led_effect
|
||||||
|
if color is None:
|
||||||
|
color = self.color or "#ffffff"
|
||||||
|
if led_speed is None:
|
||||||
|
led_speed = self.led_speed
|
||||||
|
if brightness is None:
|
||||||
|
brightness = self.brightness
|
||||||
|
|
||||||
|
if led_effect not in LED_EFFECTS:
|
||||||
|
raise ValueError("Invalid led effect specified")
|
||||||
|
if not LED_SPEED_MIN <= led_speed <= LED_SPEED_MAX:
|
||||||
|
raise ValueError("Invalid led speed specified")
|
||||||
|
if not 0 <= brightness <= self.brightness_max:
|
||||||
|
raise ValueError("Invalid brightness specified")
|
||||||
|
|
||||||
|
client = self._require_client()
|
||||||
|
await client.async_send_led_command(
|
||||||
|
self, led_effect, color, led_speed, brightness
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_sleep(self) -> None:
|
||||||
|
client = self._require_client()
|
||||||
|
await client.async_send_sleep_command(self)
|
||||||
|
|
||||||
|
async def async_move_track(self, from_index: int, to_index: int) -> None:
|
||||||
|
client = self._require_client()
|
||||||
|
await client.async_send_move_job_command(self, from_index, to_index)
|
||||||
|
|
||||||
|
async def async_change_track(self, index: int) -> None:
|
||||||
|
client = self._require_client()
|
||||||
|
await client.async_send_change_track_command(self, index)
|
||||||
|
|
||||||
|
async def async_add_track_to_playlist(self, track: int | Iterable[int]) -> None:
|
||||||
|
if isinstance(track, int):
|
||||||
|
tracks = [track]
|
||||||
|
else:
|
||||||
|
tracks = list(track)
|
||||||
|
client = self._require_client()
|
||||||
|
await client.async_send_add_joblist_command(self, tracks)
|
||||||
|
|
||||||
|
async def async_set_playlist(self, playlist: int | Iterable[int]) -> None:
|
||||||
|
if isinstance(playlist, int):
|
||||||
|
playlist_list = [playlist]
|
||||||
|
else:
|
||||||
|
playlist_list = list(playlist)
|
||||||
|
client = self._require_client()
|
||||||
|
await client.async_send_set_playlist_command(self, playlist_list)
|
||||||
|
|
||||||
|
async def async_set_repeat_playlist(self, repeat: bool) -> None:
|
||||||
|
client = self._require_client()
|
||||||
|
await client.async_send_set_repeat_playlist_command(self, repeat)
|
||||||
|
|
||||||
|
async def async_set_autoplay(self, option: bool | int | str) -> None:
|
||||||
|
"""Set autoplay / wait-after behavior."""
|
||||||
|
if isinstance(option, bool):
|
||||||
|
option = 0 if option else 1
|
||||||
|
client = self._require_client()
|
||||||
|
await client.async_send_set_autoplay_command(self, str(option))
|
||||||
|
|
||||||
|
async def async_upgrade(self, beta: bool = False) -> None:
|
||||||
|
client = self._require_client()
|
||||||
|
await client.async_send_upgrade_command(self, beta)
|
||||||
|
|
||||||
|
async def async_play(self) -> None:
|
||||||
|
client = self._require_client()
|
||||||
|
await client.async_send_play_command(self)
|
||||||
|
|
||||||
|
async def async_pause(self) -> None:
|
||||||
|
client = self._require_client()
|
||||||
|
await client.async_send_pause_command(self)
|
||||||
|
|
||||||
|
async def async_stop(self) -> None:
|
||||||
|
client = self._require_client()
|
||||||
|
await client.async_send_stop_command(self)
|
||||||
|
|
||||||
|
async def async_reboot(self) -> None:
|
||||||
|
client = self._require_client()
|
||||||
|
await client.async_send_reboot_command(self)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Exceptions."""
|
||||||
|
|
||||||
|
|
||||||
|
class UnauthenticatedError(Exception):
|
||||||
|
"""Unauthenticated."""
|
||||||
16166
custom_components/oasis_mini/pyoasiscontrol/tracks.json
Normal file
16166
custom_components/oasis_mini/pyoasiscontrol/tracks.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,19 +1,25 @@
|
|||||||
"""Oasis Mini utils."""
|
"""Oasis Mini utils."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from datetime import UTC, datetime
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
from xml.etree.ElementTree import Element, SubElement, tostring
|
from xml.etree.ElementTree import Element, SubElement, tostring
|
||||||
|
|
||||||
# import re
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
COLOR_DARK = "#28292E"
|
APP_KEY = "5joW8W4Usk4xUXu5bIIgGiHloQmzMZUMgz6NWQnNI04="
|
||||||
COLOR_LIGHT = "#FFFFFF"
|
|
||||||
COLOR_LIGHT_SHADE = "#FFFFFF"
|
BACKGROUND_FILL = ("#CCC9C4", "#28292E")
|
||||||
COLOR_MEDIUM_SHADE = "#E5E2DE"
|
COLOR_DARK = ("#28292E", "#F4F5F8")
|
||||||
COLOR_MEDIUM_TINT = "#B8B8B8"
|
COLOR_LIGHT = ("#FFFFFF", "#222428")
|
||||||
FILL_SVG_STATUS = "#CCC9C4"
|
COLOR_LIGHT_SHADE = ("#FFFFFF", "#86888F")
|
||||||
|
COLOR_MEDIUM_SHADE = ("#E5E2DE", "#86888F")
|
||||||
|
COLOR_MEDIUM_TINT = ("#B8B8B8", "#FFFFFF")
|
||||||
|
|
||||||
|
|
||||||
def _bit_to_bool(val: str) -> bool:
|
def _bit_to_bool(val: str) -> bool:
|
||||||
@@ -21,16 +27,24 @@ def _bit_to_bool(val: str) -> bool:
|
|||||||
return val == "1"
|
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:
|
def draw_svg(track: dict, progress: int, model_id: str) -> str | None:
|
||||||
"""Draw SVG."""
|
"""Draw SVG."""
|
||||||
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")
|
||||||
# paths=re.findall('([a-zA-Z][^a-zA-Z]+)',svg_content)
|
total = track.get("reduced_svg_content_new", 0) or len(paths)
|
||||||
total = track.get("reduced_svg_content", {}).get(model_id, len(paths))
|
percent = min((100 * progress) / total, 100)
|
||||||
percent = (100 * progress) / total
|
progress = math.floor((percent / 100) * (len(paths) - 1))
|
||||||
progress = math.floor((percent / 100) * len(paths))
|
|
||||||
|
|
||||||
svg = Element(
|
svg = Element(
|
||||||
"svg",
|
"svg",
|
||||||
@@ -42,15 +56,24 @@ def draw_svg(track: dict, progress: int, model_id: str) -> str | None:
|
|||||||
"class": "svg-status",
|
"class": "svg-status",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
# style = SubElement(svg, "style")
|
|
||||||
# style.text = """
|
style = SubElement(svg, "style")
|
||||||
# .progress_arc_incomplete {
|
style.text = f"""
|
||||||
# stroke: #E5E2DE;
|
circle.background {{ fill: {BACKGROUND_FILL[0]}; }}
|
||||||
# }
|
circle.ball {{ stroke: {COLOR_DARK[0]}; fill: {COLOR_LIGHT[0]}; }}
|
||||||
# circle.circleClass {
|
path.progress_arc {{ stroke: {COLOR_MEDIUM_SHADE[0]}; }}
|
||||||
# stroke: #006600;
|
path.progress_arc_complete {{ stroke: {COLOR_DARK[0]}; }}
|
||||||
# fill: #cc0000;
|
path.track {{ stroke: {COLOR_LIGHT_SHADE[0]}; }}
|
||||||
# }"""
|
path.track_complete {{ stroke: {COLOR_MEDIUM_TINT[0]}; }}
|
||||||
|
@media (prefers-color-scheme: dark) {{
|
||||||
|
circle.background {{ fill: {BACKGROUND_FILL[1]}; }}
|
||||||
|
circle.ball {{ stroke: {COLOR_DARK[1]}; fill: {COLOR_LIGHT[1]}; }}
|
||||||
|
path.progress_arc {{ stroke: {COLOR_MEDIUM_SHADE[1]}; }}
|
||||||
|
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(
|
group = SubElement(
|
||||||
svg,
|
svg,
|
||||||
"g",
|
"g",
|
||||||
@@ -63,8 +86,7 @@ def draw_svg(track: dict, progress: int, model_id: str) -> str | None:
|
|||||||
group,
|
group,
|
||||||
"path",
|
"path",
|
||||||
{
|
{
|
||||||
"class": "progress_arc_incomplete",
|
"class": "progress_arc",
|
||||||
"stroke": COLOR_MEDIUM_SHADE,
|
|
||||||
"stroke-width": "2",
|
"stroke-width": "2",
|
||||||
"d": progress_arc,
|
"d": progress_arc,
|
||||||
},
|
},
|
||||||
@@ -76,7 +98,7 @@ def draw_svg(track: dict, progress: int, model_id: str) -> str | None:
|
|||||||
group,
|
group,
|
||||||
"path",
|
"path",
|
||||||
{
|
{
|
||||||
"stroke": COLOR_DARK,
|
"class": "progress_arc_complete",
|
||||||
"stroke-width": "4",
|
"stroke-width": "4",
|
||||||
"d": "L".join(progress_arc_paths[:paths_to_draw]),
|
"d": "L".join(progress_arc_paths[:paths_to_draw]),
|
||||||
},
|
},
|
||||||
@@ -86,8 +108,8 @@ def draw_svg(track: dict, progress: int, model_id: str) -> str | None:
|
|||||||
group,
|
group,
|
||||||
"circle",
|
"circle",
|
||||||
{
|
{
|
||||||
|
"class": "background",
|
||||||
"r": "100",
|
"r": "100",
|
||||||
"fill": FILL_SVG_STATUS,
|
|
||||||
"cx": "100",
|
"cx": "100",
|
||||||
"cy": "100",
|
"cy": "100",
|
||||||
"opacity": "0.3",
|
"opacity": "0.3",
|
||||||
@@ -98,7 +120,7 @@ def draw_svg(track: dict, progress: int, model_id: str) -> str | None:
|
|||||||
group,
|
group,
|
||||||
"path",
|
"path",
|
||||||
{
|
{
|
||||||
"stroke": COLOR_LIGHT_SHADE,
|
"class": "track",
|
||||||
"stroke-width": "1.4",
|
"stroke-width": "1.4",
|
||||||
"d": svg_content,
|
"d": svg_content,
|
||||||
},
|
},
|
||||||
@@ -108,7 +130,7 @@ def draw_svg(track: dict, progress: int, model_id: str) -> str | None:
|
|||||||
group,
|
group,
|
||||||
"path",
|
"path",
|
||||||
{
|
{
|
||||||
"stroke": COLOR_MEDIUM_TINT,
|
"class": "track_complete",
|
||||||
"stroke-width": "1.8",
|
"stroke-width": "1.8",
|
||||||
"d": "L".join(paths[:progress]),
|
"d": "L".join(paths[:progress]),
|
||||||
},
|
},
|
||||||
@@ -119,9 +141,8 @@ def draw_svg(track: dict, progress: int, model_id: str) -> str | None:
|
|||||||
group,
|
group,
|
||||||
"circle",
|
"circle",
|
||||||
{
|
{
|
||||||
"stroke": COLOR_DARK,
|
"class": "ball",
|
||||||
"stroke-width": "1",
|
"stroke-width": "1",
|
||||||
"fill": COLOR_LIGHT,
|
|
||||||
"cx": f"{_cx:.2f}",
|
"cx": f"{_cx:.2f}",
|
||||||
"cy": f"{_cy:.2f}",
|
"cy": f"{_cy:.2f}",
|
||||||
"r": "5",
|
"r": "5",
|
||||||
@@ -131,3 +152,33 @@ def draw_svg(track: dict, progress: int, model_id: str) -> str | None:
|
|||||||
return tostring(svg).decode()
|
return tostring(svg).decode()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_LOGGER.exception(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
|
||||||
|
|
||||||
|
|
||||||
|
def now() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
"""Oasis Mini API client."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any, Callable, Final
|
|
||||||
from urllib.parse import urljoin
|
|
||||||
|
|
||||||
from aiohttp import ClientSession
|
|
||||||
|
|
||||||
from .utils import _bit_to_bool
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
STATUS_CODE_MAP = {
|
|
||||||
2: "stopped",
|
|
||||||
3: "centering",
|
|
||||||
4: "running",
|
|
||||||
5: "paused",
|
|
||||||
9: "error",
|
|
||||||
13: "downloading",
|
|
||||||
}
|
|
||||||
|
|
||||||
ATTRIBUTES: Final[list[tuple[str, Callable[[str], Any]]]] = [
|
|
||||||
("status_code", int), # see status code map
|
|
||||||
("error", str), # error, 0 = none, and 10 = ?
|
|
||||||
("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),
|
|
||||||
]
|
|
||||||
|
|
||||||
LED_EFFECTS: Final[dict[str, str]] = {
|
|
||||||
"0": "Solid",
|
|
||||||
"1": "Rainbow",
|
|
||||||
"2": "Glitter",
|
|
||||||
"3": "Confetti",
|
|
||||||
"4": "Sinelon",
|
|
||||||
"5": "BPM",
|
|
||||||
"6": "Juggle",
|
|
||||||
"7": "Theater",
|
|
||||||
"8": "Color Wipe",
|
|
||||||
"9": "Sparkle",
|
|
||||||
"10": "Comet",
|
|
||||||
"11": "Follow Ball",
|
|
||||||
"12": "Follow Rainbow",
|
|
||||||
"13": "Chasing Comet",
|
|
||||||
"14": "Gradient Follow",
|
|
||||||
}
|
|
||||||
|
|
||||||
CLOUD_BASE_URL = "https://app.grounded.so"
|
|
||||||
CLOUD_API_URL = f"{CLOUD_BASE_URL}/api"
|
|
||||||
|
|
||||||
|
|
||||||
class OasisMini:
|
|
||||||
"""Oasis Mini API client class."""
|
|
||||||
|
|
||||||
_access_token: str | None = None
|
|
||||||
_current_track_details: dict | None = None
|
|
||||||
_serial_number: str | None = None
|
|
||||||
_software_version: str | None = None
|
|
||||||
|
|
||||||
brightness: int
|
|
||||||
color: str
|
|
||||||
led_effect: str
|
|
||||||
led_speed: int
|
|
||||||
max_brightness: int
|
|
||||||
playlist: list[int]
|
|
||||||
playlist_index: int
|
|
||||||
progress: int
|
|
||||||
status_code: int
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
host: str,
|
|
||||||
access_token: str | None = None,
|
|
||||||
session: ClientSession | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize the client."""
|
|
||||||
self._host = host
|
|
||||||
self._access_token = access_token
|
|
||||||
self._session = session if session else ClientSession()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def access_token(self) -> str | None:
|
|
||||||
"""Return the access token, if any."""
|
|
||||||
return self._access_token
|
|
||||||
|
|
||||||
@property
|
|
||||||
def 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]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def serial_number(self) -> str | None:
|
|
||||||
"""Return the serial number."""
|
|
||||||
return self._serial_number
|
|
||||||
|
|
||||||
@property
|
|
||||||
def session(self) -> ClientSession:
|
|
||||||
"""Return the session."""
|
|
||||||
return self._session
|
|
||||||
|
|
||||||
@property
|
|
||||||
def software_version(self) -> str | None:
|
|
||||||
"""Return the software version."""
|
|
||||||
return self._software_version
|
|
||||||
|
|
||||||
@property
|
|
||||||
def status(self) -> str:
|
|
||||||
"""Return the status."""
|
|
||||||
return STATUS_CODE_MAP.get(self.status_code, f"Unknown ({self.status_code})")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def url(self) -> str:
|
|
||||||
"""Return the url."""
|
|
||||||
return f"http://{self._host}/"
|
|
||||||
|
|
||||||
async def async_change_track(self, index: int) -> None:
|
|
||||||
"""Change the track."""
|
|
||||||
if index >= len(self.playlist):
|
|
||||||
raise ValueError("Invalid selection")
|
|
||||||
await self._async_command(params={"CMDCHANGETRACK": index})
|
|
||||||
|
|
||||||
async def async_get_serial_number(self) -> str | None:
|
|
||||||
"""Get the serial number."""
|
|
||||||
self._serial_number = await self._async_get(params={"GETOASISID": ""})
|
|
||||||
_LOGGER.debug("Serial number: %s", self._serial_number)
|
|
||||||
return self._serial_number
|
|
||||||
|
|
||||||
async def async_get_software_version(self) -> str | None:
|
|
||||||
"""Get the software version."""
|
|
||||||
self._software_version = await self._async_get(params={"GETSOFTWAREVER": ""})
|
|
||||||
_LOGGER.debug("Software version: %s", self._software_version)
|
|
||||||
return self._software_version
|
|
||||||
|
|
||||||
async def async_get_status(self) -> None:
|
|
||||||
"""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
|
|
||||||
|
|
||||||
async def async_pause(self) -> None:
|
|
||||||
"""Send pause command."""
|
|
||||||
await self._async_command(params={"CMDPAUSE": ""})
|
|
||||||
|
|
||||||
async def async_play(self) -> None:
|
|
||||||
"""Send play command."""
|
|
||||||
await self._async_command(params={"CMDPLAY": ""})
|
|
||||||
|
|
||||||
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")
|
|
||||||
|
|
||||||
await self._async_command(params={"WRIOASISSPEED": speed})
|
|
||||||
|
|
||||||
async def async_set_led(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
led_effect: str | None = None,
|
|
||||||
color: str | None = None,
|
|
||||||
led_speed: int | None = None,
|
|
||||||
brightness: int | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Set the Oasis Mini led."""
|
|
||||||
if led_effect is None:
|
|
||||||
led_effect = self.led_effect
|
|
||||||
if color is None:
|
|
||||||
color = self.color
|
|
||||||
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")
|
|
||||||
|
|
||||||
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_repeat_playlist(self, repeat: bool) -> None:
|
|
||||||
"""Set the Oasis Mini 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_get(self, **kwargs: Any) -> str | None:
|
|
||||||
"""Perform a GET request."""
|
|
||||||
response = await self._session.get(self.url, **kwargs)
|
|
||||||
if response.status == 200:
|
|
||||||
text = await response.text()
|
|
||||||
return text
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def async_cloud_login(self, email: str, password: str) -> None:
|
|
||||||
"""Login via the cloud."""
|
|
||||||
response = await self._async_request(
|
|
||||||
"POST",
|
|
||||||
urljoin(CLOUD_BASE_URL, "api/auth/login"),
|
|
||||||
json={"email": email, "password": password},
|
|
||||||
)
|
|
||||||
self._access_token = response.get("access_token")
|
|
||||||
|
|
||||||
async def async_cloud_logout(self) -> None:
|
|
||||||
"""Login via the cloud."""
|
|
||||||
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}"},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_cloud_get_track_info(self, track_id: int) -> None:
|
|
||||||
"""Get cloud track info."""
|
|
||||||
if not self.access_token:
|
|
||||||
return
|
|
||||||
|
|
||||||
response = await self._async_request(
|
|
||||||
"GET",
|
|
||||||
urljoin(CLOUD_BASE_URL, f"api/track/{track_id}"),
|
|
||||||
headers={"Authorization": f"Bearer {self.access_token}"},
|
|
||||||
)
|
|
||||||
return response
|
|
||||||
|
|
||||||
async def _async_request(self, method: str, url: str, **kwargs) -> Any:
|
|
||||||
"""Login via the cloud."""
|
|
||||||
response = await self._session.request(method, url, **kwargs)
|
|
||||||
if response.status == 200:
|
|
||||||
if response.headers.get("Content-Type") == "application/json":
|
|
||||||
return await response.json()
|
|
||||||
return await response.text()
|
|
||||||
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
|
|
||||||
)
|
|
||||||
162
custom_components/oasis_mini/select.py
Normal file
162
custom_components/oasis_mini/select.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
"""Oasis device select entity."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Awaitable, Callable
|
||||||
|
|
||||||
|
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||||
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.helpers.entity import EntityDescription
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
|
from . import OasisDeviceConfigEntry
|
||||||
|
from .coordinator import OasisDeviceCoordinator
|
||||||
|
from .entity import OasisDeviceEntity
|
||||||
|
from .pyoasiscontrol import OasisDevice
|
||||||
|
from .pyoasiscontrol.const import AUTOPLAY_MAP, TRACKS
|
||||||
|
|
||||||
|
AUTOPLAY_MAP_LIST = list(AUTOPLAY_MAP)
|
||||||
|
|
||||||
|
|
||||||
|
def playlists_update_handler(entity: OasisDeviceSelectEntity) -> None:
|
||||||
|
"""Handle playlists updates."""
|
||||||
|
# pylint: disable=protected-access
|
||||||
|
device = entity.device
|
||||||
|
counts = defaultdict(int)
|
||||||
|
options = []
|
||||||
|
current_option: str | None = None
|
||||||
|
for playlist in device.playlists:
|
||||||
|
name = playlist["name"]
|
||||||
|
counts[name] += 1
|
||||||
|
if counts[name] > 1:
|
||||||
|
name = f"{name} ({counts[name]})"
|
||||||
|
options.append(name)
|
||||||
|
if device.playlist == [pattern["id"] for pattern in playlist["patterns"]]:
|
||||||
|
current_option = name
|
||||||
|
entity._attr_options = options
|
||||||
|
entity._attr_current_option = current_option
|
||||||
|
|
||||||
|
|
||||||
|
def queue_update_handler(entity: OasisDeviceSelectEntity) -> None:
|
||||||
|
"""Handle queue updates."""
|
||||||
|
# pylint: disable=protected-access
|
||||||
|
device = entity.device
|
||||||
|
counts = defaultdict(int)
|
||||||
|
options = []
|
||||||
|
for track in device.playlist:
|
||||||
|
name = device.playlist_details.get(track, {}).get(
|
||||||
|
"name",
|
||||||
|
TRACKS.get(track, {"id": track, "name": f"Unknown Title (#{track})"}).get(
|
||||||
|
"name",
|
||||||
|
device.track["name"]
|
||||||
|
if device.track and device.track["id"] == track
|
||||||
|
else str(track),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
counts[name] += 1
|
||||||
|
if counts[name] > 1:
|
||||||
|
name = f"{name} ({counts[name]})"
|
||||||
|
options.append(name)
|
||||||
|
entity._attr_options = options
|
||||||
|
index = min(device.playlist_index, len(options) - 1)
|
||||||
|
entity._attr_current_option = options[index] if options else None
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: OasisDeviceConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up Oasis device select using config entry."""
|
||||||
|
coordinator: OasisDeviceCoordinator = entry.runtime_data
|
||||||
|
entities = [
|
||||||
|
OasisDeviceSelectEntity(coordinator, device, descriptor)
|
||||||
|
for device in coordinator.data
|
||||||
|
for descriptor in DESCRIPTORS
|
||||||
|
]
|
||||||
|
# if coordinator.device.access_token:
|
||||||
|
# entities.extend(
|
||||||
|
# OasisDeviceSelectEntity(coordinator, device, descriptor)
|
||||||
|
# for device in coordinator.data
|
||||||
|
# for descriptor in CLOUD_DESCRIPTORS
|
||||||
|
# )
|
||||||
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, kw_only=True)
|
||||||
|
class OasisDeviceSelectEntityDescription(SelectEntityDescription):
|
||||||
|
"""Oasis device select entity description."""
|
||||||
|
|
||||||
|
current_value: Callable[[OasisDevice], Any]
|
||||||
|
select_fn: Callable[[OasisDevice, int], Awaitable[None]]
|
||||||
|
update_handler: Callable[[OasisDeviceSelectEntity], None] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
DESCRIPTORS = (
|
||||||
|
OasisDeviceSelectEntityDescription(
|
||||||
|
key="autoplay",
|
||||||
|
translation_key="autoplay",
|
||||||
|
options=AUTOPLAY_MAP_LIST,
|
||||||
|
current_value=lambda device: str(device.autoplay),
|
||||||
|
select_fn=lambda device, index: (
|
||||||
|
device.async_set_autoplay(AUTOPLAY_MAP_LIST[index])
|
||||||
|
),
|
||||||
|
),
|
||||||
|
OasisDeviceSelectEntityDescription(
|
||||||
|
key="queue",
|
||||||
|
translation_key="queue",
|
||||||
|
current_value=lambda device: (device.playlist.copy(), device.playlist_index),
|
||||||
|
select_fn=lambda device, index: device.async_change_track(index),
|
||||||
|
update_handler=queue_update_handler,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
CLOUD_DESCRIPTORS = (
|
||||||
|
OasisDeviceSelectEntityDescription(
|
||||||
|
key="playlists",
|
||||||
|
translation_key="playlist",
|
||||||
|
current_value=lambda device: (device.playlists, device.playlist.copy()),
|
||||||
|
select_fn=lambda device, index: device.async_set_playlist(
|
||||||
|
[pattern["id"] for pattern in device.playlists[index]["patterns"]]
|
||||||
|
),
|
||||||
|
update_handler=playlists_update_handler,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OasisDeviceSelectEntity(OasisDeviceEntity, SelectEntity):
|
||||||
|
"""Oasis device select entity."""
|
||||||
|
|
||||||
|
entity_description: OasisDeviceSelectEntityDescription
|
||||||
|
_current_value: Any | None = None
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: OasisDeviceCoordinator,
|
||||||
|
device: OasisDevice,
|
||||||
|
description: EntityDescription,
|
||||||
|
) -> None:
|
||||||
|
"""Construct an Oasis device select entity."""
|
||||||
|
super().__init__(coordinator, device, description)
|
||||||
|
self._handle_coordinator_update()
|
||||||
|
|
||||||
|
async def async_select_option(self, option: str) -> None:
|
||||||
|
"""Change the selected option."""
|
||||||
|
await self.entity_description.select_fn(self.device, self.options.index(option))
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _handle_coordinator_update(self) -> None:
|
||||||
|
"""Handle updated data from the coordinator."""
|
||||||
|
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 = str(
|
||||||
|
getattr(self.device, self.entity_description.key)
|
||||||
|
)
|
||||||
|
if self.hass:
|
||||||
|
return super()._handle_coordinator_update()
|
||||||
@@ -1,86 +1,77 @@
|
|||||||
"""Oasis Mini sensor entity."""
|
"""Oasis device sensor entity."""
|
||||||
|
|
||||||
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 OasisDeviceConfigEntry
|
||||||
from .coordinator import OasisMiniCoordinator
|
from .coordinator import OasisDeviceCoordinator
|
||||||
from .entity import OasisMiniEntity
|
from .entity import OasisDeviceEntity
|
||||||
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: OasisDeviceConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
lookup_fn: Callable[[OasisMini], Any] | None = None
|
) -> None:
|
||||||
|
"""Set up Oasis device sensors using config entry."""
|
||||||
|
coordinator: OasisDeviceCoordinator = entry.runtime_data
|
||||||
class OasisMiniSensorEntity(OasisMiniEntity, SensorEntity):
|
entities = [
|
||||||
"""Oasis Mini sensor entity."""
|
OasisDeviceSensorEntity(coordinator, device, descriptor)
|
||||||
|
for device in coordinator.data
|
||||||
entity_description: OasisMiniSensorEntityDescription | SensorEntityDescription
|
for descriptor in DESCRIPTORS
|
||||||
|
]
|
||||||
@property
|
entities.extend(
|
||||||
def native_value(self) -> str | None:
|
OasisDeviceSensorEntity(coordinator, device, descriptor)
|
||||||
"""Return the value reported by the sensor."""
|
for device in coordinator.data
|
||||||
if lookup_fn := getattr(self.entity_description, "lookup_fn", None):
|
for descriptor in CLOUD_DESCRIPTORS
|
||||||
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,
|
||||||
),
|
),
|
||||||
OasisMiniSensorEntityDescription(
|
} | {
|
||||||
key="playlist",
|
|
||||||
name="Playlist",
|
|
||||||
lookup_fn=lambda device: ",".join(map(str, device.playlist)),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
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",
|
# for key in ("error_message", "led_color_id", "status")
|
||||||
"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 OasisDeviceSensorEntity(OasisDeviceEntity, SensorEntity):
|
||||||
) -> None:
|
"""Oasis device 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
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -3,26 +3,35 @@
|
|||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"data": {
|
"data": {
|
||||||
"host": "[%key:common::config_flow::data::host%]"
|
"email": "[%key:common::config_flow::data::email%]",
|
||||||
|
"password": "[%key:common::config_flow::data::password%]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"reauth_confirm": {
|
"reconfigure": {
|
||||||
"data": {}
|
"data": {
|
||||||
|
"email": "[%key:common::config_flow::data::email%]",
|
||||||
|
"password": "[%key:common::config_flow::data::password%]"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||||
|
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||||
"invalid_host": "[%key:common::config_flow::error::invalid_host%]",
|
"invalid_host": "[%key:common::config_flow::error::invalid_host%]",
|
||||||
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
|
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
|
||||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||||
|
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||||
|
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||||
|
"wrong_account": "Account used for the integration should not change"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
"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 +41,120 @@
|
|||||||
"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"
|
||||||
|
},
|
||||||
|
"sleep": {
|
||||||
|
"name": "Sleep"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"state": {
|
||||||
|
"0": "on",
|
||||||
|
"1": "off",
|
||||||
|
"2": "5 minutes",
|
||||||
|
"3": "10 minutes",
|
||||||
|
"4": "30 minutes",
|
||||||
|
"6": "1 hour",
|
||||||
|
"7": "6 hours",
|
||||||
|
"8": "12 hours",
|
||||||
|
"5": "24 hours"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"playlist": {
|
||||||
|
"name": "Playlist"
|
||||||
|
},
|
||||||
|
"queue": {
|
||||||
|
"name": "Queue"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sensor": {
|
||||||
|
"download_progress": {
|
||||||
|
"name": "Download progress"
|
||||||
|
},
|
||||||
|
"drawing_progress": {
|
||||||
|
"name": "Drawing progress"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"name": "Error",
|
||||||
|
"state": {
|
||||||
|
"0": "None",
|
||||||
|
"1": "Error has occurred while reading the flash memory",
|
||||||
|
"2": "Error while starting the Wifi",
|
||||||
|
"3": "Error when starting DNS settings for your machine",
|
||||||
|
"4": "Failed to open the file to write",
|
||||||
|
"5": "Not enough memory to perform the upgrade",
|
||||||
|
"6": "Error while trying to upgrade your system",
|
||||||
|
"7": "Error while trying to download the new version of the software",
|
||||||
|
"8": "Error while reading the upgrading file",
|
||||||
|
"9": "Failed to start downloading the upgrade file",
|
||||||
|
"10": "Error while starting downloading the job file",
|
||||||
|
"11": "Error while opening the file folder",
|
||||||
|
"12": "Failed to delete a file",
|
||||||
|
"13": "Error while opening the job file",
|
||||||
|
"14": "You have wrong power adapter",
|
||||||
|
"15": "Failed to update the device IP on Oasis Server",
|
||||||
|
"16": "Your device failed centering itself",
|
||||||
|
"17": "There appears to be an issue with your Oasis Device",
|
||||||
|
"18": "Error while downloading the job file"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"led_color_id": {
|
||||||
|
"name": "LED color ID"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "Status",
|
||||||
|
"state": {
|
||||||
|
"booting": "Booting",
|
||||||
|
"stopped": "Stopped",
|
||||||
|
"centering": "Centering",
|
||||||
|
"playing": "Playing",
|
||||||
|
"paused": "Paused",
|
||||||
|
"sleeping": "Sleeping",
|
||||||
|
"error": "Error",
|
||||||
|
"updating": "Updating",
|
||||||
|
"downloading": "Downloading",
|
||||||
|
"busy": "Busy",
|
||||||
|
"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.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
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -3,26 +3,35 @@
|
|||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"data": {
|
"data": {
|
||||||
"host": "Host"
|
"email": "Email",
|
||||||
|
"password": "Password"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"reauth_confirm": {
|
"reconfigure": {
|
||||||
"data": {}
|
"data": {
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Password"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"cannot_connect": "Failed to connect",
|
"cannot_connect": "Failed to connect",
|
||||||
|
"invalid_auth": "Invalid authentication",
|
||||||
"invalid_host": "Invalid hostname or IP address",
|
"invalid_host": "Invalid hostname or IP address",
|
||||||
"timeout_connect": "Timeout establishing connection",
|
"timeout_connect": "Timeout establishing connection",
|
||||||
"unknown": "Unexpected error"
|
"unknown": "Unexpected error"
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "Device is already configured"
|
"already_configured": "Account is already configured",
|
||||||
|
"reauth_successful": "Re-authentication was successful",
|
||||||
|
"reconfigure_successful": "Re-configuration was successful",
|
||||||
|
"wrong_account": "Account used for the integration should not change"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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 +41,120 @@
|
|||||||
"error": {
|
"error": {
|
||||||
"invalid_auth": "Invalid authentication"
|
"invalid_auth": "Invalid authentication"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"entity": {
|
||||||
|
"button": {
|
||||||
|
"random_track": {
|
||||||
|
"name": "Play random track"
|
||||||
|
},
|
||||||
|
"sleep": {
|
||||||
|
"name": "Sleep"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"state": {
|
||||||
|
"0": "on",
|
||||||
|
"1": "off",
|
||||||
|
"2": "5 minutes",
|
||||||
|
"3": "10 minutes",
|
||||||
|
"4": "30 minutes",
|
||||||
|
"6": "1 hour",
|
||||||
|
"7": "6 hours",
|
||||||
|
"8": "12 hours",
|
||||||
|
"5": "24 hours"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"playlist": {
|
||||||
|
"name": "Playlist"
|
||||||
|
},
|
||||||
|
"queue": {
|
||||||
|
"name": "Queue"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sensor": {
|
||||||
|
"download_progress": {
|
||||||
|
"name": "Download progress"
|
||||||
|
},
|
||||||
|
"drawing_progress": {
|
||||||
|
"name": "Drawing progress"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"name": "Error",
|
||||||
|
"state": {
|
||||||
|
"0": "None",
|
||||||
|
"1": "Error has occurred while reading the flash memory",
|
||||||
|
"2": "Error while starting the Wifi",
|
||||||
|
"3": "Error when starting DNS settings for your machine",
|
||||||
|
"4": "Failed to open the file to write",
|
||||||
|
"5": "Not enough memory to perform the upgrade",
|
||||||
|
"6": "Error while trying to upgrade your system",
|
||||||
|
"7": "Error while trying to download the new version of the software",
|
||||||
|
"8": "Error while reading the upgrading file",
|
||||||
|
"9": "Failed to start downloading the upgrade file",
|
||||||
|
"10": "Error while starting downloading the job file",
|
||||||
|
"11": "Error while opening the file folder",
|
||||||
|
"12": "Failed to delete a file",
|
||||||
|
"13": "Error while opening the job file",
|
||||||
|
"14": "You have wrong power adapter",
|
||||||
|
"15": "Failed to update the device IP on Oasis Server",
|
||||||
|
"16": "Your device failed centering itself",
|
||||||
|
"17": "There appears to be an issue with your Oasis Device",
|
||||||
|
"18": "Error while downloading the job file"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"led_color_id": {
|
||||||
|
"name": "LED color ID"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "Status",
|
||||||
|
"state": {
|
||||||
|
"booting": "Booting",
|
||||||
|
"stopped": "Stopped",
|
||||||
|
"centering": "Centering",
|
||||||
|
"playing": "Playing",
|
||||||
|
"paused": "Paused",
|
||||||
|
"sleeping": "Sleeping",
|
||||||
|
"error": "Error",
|
||||||
|
"updating": "Updating",
|
||||||
|
"downloading": "Downloading",
|
||||||
|
"busy": "Busy",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
86
custom_components/oasis_mini/update.py
Normal file
86
custom_components/oasis_mini/update.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"""Oasis device 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 OasisDeviceConfigEntry
|
||||||
|
from .coordinator import OasisDeviceCoordinator
|
||||||
|
from .entity import OasisDeviceEntity
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SCAN_INTERVAL = timedelta(hours=6)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: OasisDeviceConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up Oasis device updates using config entry."""
|
||||||
|
coordinator: OasisDeviceCoordinator = entry.runtime_data
|
||||||
|
entities = (
|
||||||
|
OasisDeviceUpdateEntity(coordinator, device, DESCRIPTOR)
|
||||||
|
for device in coordinator.data
|
||||||
|
)
|
||||||
|
async_add_entities(entities, True)
|
||||||
|
|
||||||
|
|
||||||
|
DESCRIPTOR = UpdateEntityDescription(
|
||||||
|
key="software", device_class=UpdateDeviceClass.FIRMWARE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OasisDeviceUpdateEntity(OasisDeviceEntity, UpdateEntity):
|
||||||
|
"""Oasis device 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."""
|
||||||
|
if self.latest_version == self.device.software_version:
|
||||||
|
return
|
||||||
|
await self.device.async_upgrade()
|
||||||
|
|
||||||
|
async def async_update(self) -> None:
|
||||||
|
"""Update the entity."""
|
||||||
|
client = self.coordinator.cloud_client
|
||||||
|
if not (software := await client.async_get_latest_software_details()):
|
||||||
|
_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",
|
"name": "Oasis Control",
|
||||||
"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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
# 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
|
||||||
|
aiomqtt # asyncio MQTT client
|
||||||
|
cryptography # should already be installed with Home Assistant
|
||||||
|
|
||||||
# Development
|
# Development
|
||||||
colorlog
|
colorlog
|
||||||
pip>=21.0
|
pip>=21.0
|
||||||
|
pre-commit
|
||||||
ruff
|
ruff
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
sudo apt-get update && sudo apt-get install libturbojpeg0 libpcap0.8 -y
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
python3 -m pip install --requirement requirements.txt --upgrade
|
python3 -m pip install --requirement requirements.txt --upgrade
|
||||||
|
|
||||||
|
pre-commit install
|
||||||
|
|
||||||
mkdir -p config
|
mkdir -p config
|
||||||
74
update_tracks.py
Normal file
74
update_tracks.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"""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.pyoasiscontrol import OasisCloudClient
|
||||||
|
from custom_components.oasis_mini.pyoasiscontrol.const import TRACKS
|
||||||
|
|
||||||
|
ACCESS_TOKEN = os.getenv("GROUNDED_TOKEN")
|
||||||
|
|
||||||
|
|
||||||
|
def get_author_name(data: dict) -> str:
|
||||||
|
"""Get author name from a dict."""
|
||||||
|
author = (data.get("author") or {}).get("user") or {}
|
||||||
|
return author.get("name") or author.get("nickname") or "Kinetic Oasis"
|
||||||
|
|
||||||
|
|
||||||
|
async def update_tracks() -> None:
|
||||||
|
"""Update tracks."""
|
||||||
|
client = OasisCloudClient(access_token=ACCESS_TOKEN)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = await client.async_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 any(
|
||||||
|
result[field] != TRACKS[track_id].get(field)
|
||||||
|
for field in ("name", "image", "png_image")
|
||||||
|
)
|
||||||
|
or TRACKS[track_id].get("author") != get_author_name(result)
|
||||||
|
):
|
||||||
|
print(f"Updating track {track_id}: {result['name']}")
|
||||||
|
track_info = await client.async_get_track_info(int(track_id))
|
||||||
|
if not track_info:
|
||||||
|
print("No track info")
|
||||||
|
break
|
||||||
|
result["author"] = get_author_name(result)
|
||||||
|
result["reduced_svg_content_new"] = track_info.get(
|
||||||
|
"reduced_svg_content_new"
|
||||||
|
)
|
||||||
|
updated_tracks[track_id] = result
|
||||||
|
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/pyoasiscontrol/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