mirror of
https://github.com/natekspencer/hacs-oasis_mini.git
synced 2025-12-06 18:44:14 -05:00
Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83de1d5606 | ||
|
|
2a92212aad | ||
|
|
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 |
@@ -1,8 +1,8 @@
|
|||||||
// 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.13-bullseye",
|
"image": "mcr.microsoft.com/devcontainers/python:1-3.13-bookworm",
|
||||||
"postCreateCommand": "sudo apt-get update && sudo apt-get install libturbojpeg0 libpcap0.8 -y",
|
"postCreateCommand": "scripts/setup",
|
||||||
"postAttachCommand": "scripts/setup",
|
"postAttachCommand": "scripts/setup",
|
||||||
"forwardPorts": [8123],
|
"forwardPorts": [8123],
|
||||||
"customizations": {
|
"customizations": {
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
10
.github/workflows/update-tracks.yml
vendored
10
.github/workflows/update-tracks.yml
vendored
@@ -1,10 +1,14 @@
|
|||||||
name: Update tracks
|
name: Update tracks
|
||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "0 19 * * *"
|
- cron: "0 19 * * 1"
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
tracks:
|
tracks:
|
||||||
name: Search and update new tracks
|
name: Search and update new tracks
|
||||||
@@ -12,16 +16,20 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout the repo
|
- name: Checkout the repo
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Python 3.13
|
- name: Set up Python 3.13
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pip install homeassistant
|
run: pip install homeassistant
|
||||||
|
|
||||||
- name: Update tracks
|
- name: Update tracks
|
||||||
env:
|
env:
|
||||||
GROUNDED_TOKEN: ${{ secrets.GROUNDED_TOKEN }}
|
GROUNDED_TOKEN: ${{ secrets.GROUNDED_TOKEN }}
|
||||||
run: python update_tracks.py
|
run: python update_tracks.py
|
||||||
|
|
||||||
- name: Create Pull Request
|
- name: Create Pull Request
|
||||||
uses: peter-evans/create-pull-request@v6
|
uses: peter-evans/create-pull-request@v6
|
||||||
with:
|
with:
|
||||||
|
|||||||
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
|
||||||
10
README.md
10
README.md
@@ -10,9 +10,9 @@
|
|||||||
<img alt="Oasis Mini logo" src="https://brands.home-assistant.io/oasis_mini/logo.png">
|
<img alt="Oasis Mini logo" src="https://brands.home-assistant.io/oasis_mini/logo.png">
|
||||||
</picture>
|
</picture>
|
||||||
|
|
||||||
# Oasis Mini for Home Assistant
|
# Oasis Control for Home Assistant
|
||||||
|
|
||||||
Home Assistant integration for Oasis Mini kinetic sand art devices.
|
Home Assistant integration for Oasis kinetic sand art devices.
|
||||||
|
|
||||||
# Installation
|
# Installation
|
||||||
|
|
||||||
@@ -43,13 +43,13 @@ While the manual installation above seems like less steps, it's important to not
|
|||||||
|
|
||||||
[](https://my.home-assistant.io/redirect/config_flow_start/?domain=oasis_mini)
|
[](https://my.home-assistant.io/redirect/config_flow_start/?domain=oasis_mini)
|
||||||
|
|
||||||
There is a config flow for this Oasis Mini integration. After installing the custom component, use the convenient My Home Assistant link above.
|
There is a config flow for this Oasis Control integration. After installing the custom component, use the convenient My Home Assistant link above.
|
||||||
|
|
||||||
Alternatively:
|
Alternatively:
|
||||||
|
|
||||||
1. Go to **Configuration**->**Integrations**
|
1. Go to **Configuration**->**Integrations**
|
||||||
2. Click **+ ADD INTEGRATION** to setup a new integration
|
2. Click **+ ADD INTEGRATION** to setup a new integration
|
||||||
3. Search for **Oasis Mini** and click on it
|
3. Search for **Oasis Control** and click on it
|
||||||
4. You will be guided through the rest of the setup process via the config flow
|
4. You will be guided through the rest of the setup process via the config flow
|
||||||
|
|
||||||
# Options
|
# Options
|
||||||
@@ -76,6 +76,6 @@ data:
|
|||||||
|
|
||||||
I'm not employed by Kinetic Oasis, and provide this custom component purely for your own enjoyment and home automation needs.
|
I'm not employed by Kinetic Oasis, and provide this custom component purely for your own enjoyment and home automation needs.
|
||||||
|
|
||||||
If you already own an Oasis Mini, found this integration useful and want to donate, consider [sponsoring me on GitHub](https://github.com/sponsors/natekspencer) or buying me a coffee ☕ (or beer 🍺) instead by using the link below:
|
If you already own an Oasis device, found this integration useful and want to donate, consider [sponsoring me on GitHub](https://github.com/sponsors/natekspencer) or buying me a coffee ☕ (or beer 🍺) instead by using the link below:
|
||||||
|
|
||||||
<a href='https://ko-fi.com/Y8Y57F59S' target='_blank'><img height='36' style='border:0px;height:36px;' src='https://storage.ko-fi.com/cdn/kofi1.png?v=3' border='0' alt='Buy Me a Coffee at ko-fi.com' /></a>
|
<a href='https://ko-fi.com/Y8Y57F59S' target='_blank'><img height='36' style='border:0px;height:36px;' src='https://storage.ko-fi.com/cdn/kofi1.png?v=3' border='0' alt='Buy Me a Coffee at ko-fi.com' /></a>
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
"""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 ConfigEntryError, ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||||
import homeassistant.helpers.device_registry as dr
|
import homeassistant.helpers.entity_registry as er
|
||||||
|
import homeassistant.util.dt as dt_util
|
||||||
|
|
||||||
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 OasisMiniConfigEntry = ConfigEntry[OasisMiniCoordinator]
|
type OasisDeviceConfigEntry = ConfigEntry[OasisDeviceCoordinator]
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -27,65 +29,122 @@ PLATFORMS = [
|
|||||||
Platform.NUMBER,
|
Platform.NUMBER,
|
||||||
Platform.SELECT,
|
Platform.SELECT,
|
||||||
Platform.SENSOR,
|
Platform.SENSOR,
|
||||||
# Platform.SWITCH,
|
|
||||||
Platform.UPDATE,
|
Platform.UPDATE,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: OasisMiniConfigEntry) -> 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."""
|
||||||
client = create_client(entry.data | entry.options)
|
cloud_client = create_client(hass, entry.data)
|
||||||
coordinator = OasisMiniCoordinator(hass, client)
|
try:
|
||||||
|
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:
|
try:
|
||||||
await coordinator.async_config_entry_first_refresh()
|
await coordinator.async_config_entry_first_refresh()
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
_LOGGER.exception(ex)
|
_LOGGER.exception(ex)
|
||||||
|
|
||||||
if not entry.unique_id:
|
if entry.unique_id != (user_id := str(user["id"])):
|
||||||
if not (serial_number := coordinator.device.serial_number):
|
hass.config_entries.async_update_entry(entry, unique_id=user_id)
|
||||||
dev_reg = dr.async_get(hass)
|
|
||||||
devices = dr.async_entries_for_config_entry(dev_reg, entry.entry_id)
|
|
||||||
serial_number = next(
|
|
||||||
(
|
|
||||||
identifier[1]
|
|
||||||
for identifier in devices[0].identifiers
|
|
||||||
if identifier[0] == DOMAIN
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
hass.config_entries.async_update_entry(entry, unique_id=serial_number)
|
|
||||||
|
|
||||||
if not coordinator.data:
|
if not coordinator.data:
|
||||||
await client.session.close()
|
_LOGGER.warning("No devices associated with account")
|
||||||
raise ConfigEntryNotReady
|
|
||||||
|
|
||||||
if entry.unique_id != coordinator.device.serial_number:
|
|
||||||
await client.session.close()
|
|
||||||
raise ConfigEntryError("Serial number mismatch")
|
|
||||||
|
|
||||||
entry.runtime_data = coordinator
|
entry.runtime_data = coordinator
|
||||||
|
|
||||||
|
def _on_oasis_update() -> None:
|
||||||
|
coordinator.last_updated = dt_util.now()
|
||||||
|
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: OasisMiniConfigEntry) -> bool:
|
async def async_unload_entry(
|
||||||
|
hass: HomeAssistant, entry: OasisDeviceConfigEntry
|
||||||
|
) -> bool:
|
||||||
"""Unload a config entry."""
|
"""Unload a config entry."""
|
||||||
await entry.runtime_data.device.session.close()
|
mqtt_client = entry.runtime_data.mqtt_client
|
||||||
|
await mqtt_client.async_close()
|
||||||
|
|
||||||
|
cloud_client = entry.runtime_data.cloud_client
|
||||||
|
await cloud_client.async_close()
|
||||||
|
|
||||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
|
|
||||||
|
|
||||||
async def async_remove_entry(hass: HomeAssistant, entry: OasisMiniConfigEntry) -> 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: OasisMiniConfigEntry) -> 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
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Oasis Mini binary sensor entity."""
|
"""Oasis device binary sensor entity."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -11,20 +11,21 @@ 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 . import OasisMiniConfigEntry
|
from . import OasisDeviceConfigEntry
|
||||||
from .coordinator import OasisMiniCoordinator
|
from .coordinator import OasisDeviceCoordinator
|
||||||
from .entity import OasisMiniEntity
|
from .entity import OasisDeviceEntity
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entry: OasisMiniConfigEntry,
|
entry: OasisDeviceConfigEntry,
|
||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up Oasis Mini sensors using config entry."""
|
"""Set up Oasis device sensors using config entry."""
|
||||||
coordinator: OasisMiniCoordinator = entry.runtime_data
|
coordinator: OasisDeviceCoordinator = entry.runtime_data
|
||||||
async_add_entities(
|
async_add_entities(
|
||||||
OasisMiniBinarySensorEntity(coordinator, descriptor)
|
OasisDeviceBinarySensorEntity(coordinator, device, descriptor)
|
||||||
|
for device in coordinator.data
|
||||||
for descriptor in DESCRIPTORS
|
for descriptor in DESCRIPTORS
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -46,8 +47,8 @@ DESCRIPTORS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class OasisMiniBinarySensorEntity(OasisMiniEntity, BinarySensorEntity):
|
class OasisDeviceBinarySensorEntity(OasisDeviceEntity, BinarySensorEntity):
|
||||||
"""Oasis Mini binary sensor entity."""
|
"""Oasis device binary sensor entity."""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_on(self) -> bool:
|
def is_on(self) -> bool:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Oasis Mini button entity."""
|
"""Oasis device button entity."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -15,61 +15,66 @@ 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 . import OasisMiniConfigEntry
|
from . import OasisDeviceConfigEntry
|
||||||
from .entity import OasisMiniEntity
|
from .coordinator import OasisDeviceCoordinator
|
||||||
|
from .entity import OasisDeviceEntity
|
||||||
from .helpers import add_and_play_track
|
from .helpers import add_and_play_track
|
||||||
from .pyoasismini import OasisMini
|
from .pyoasiscontrol import OasisDevice
|
||||||
from .pyoasismini.const import TRACKS
|
from .pyoasiscontrol.const import TRACKS
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entry: OasisMiniConfigEntry,
|
entry: OasisDeviceConfigEntry,
|
||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up Oasis Mini button using config entry."""
|
"""Set up Oasis device button using config entry."""
|
||||||
|
coordinator: OasisDeviceCoordinator = entry.runtime_data
|
||||||
async_add_entities(
|
async_add_entities(
|
||||||
[
|
OasisDeviceButtonEntity(coordinator, device, descriptor)
|
||||||
OasisMiniButtonEntity(entry.runtime_data, descriptor)
|
for device in coordinator.data
|
||||||
for descriptor in DESCRIPTORS
|
for descriptor in DESCRIPTORS
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def play_random_track(device: OasisMini) -> None:
|
async def play_random_track(device: OasisDevice) -> None:
|
||||||
"""Play random track."""
|
"""Play random track."""
|
||||||
track = random.choice(list(TRACKS))
|
track = random.choice(list(TRACKS))
|
||||||
await add_and_play_track(device, track)
|
await add_and_play_track(device, track)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class OasisMiniButtonEntityDescription(ButtonEntityDescription):
|
class OasisDeviceButtonEntityDescription(ButtonEntityDescription):
|
||||||
"""Oasis Mini button entity description."""
|
"""Oasis device button entity description."""
|
||||||
|
|
||||||
press_fn: Callable[[OasisMini], Awaitable[None]]
|
press_fn: Callable[[OasisDevice], Awaitable[None]]
|
||||||
|
|
||||||
|
|
||||||
DESCRIPTORS = (
|
DESCRIPTORS = (
|
||||||
OasisMiniButtonEntityDescription(
|
OasisDeviceButtonEntityDescription(
|
||||||
key="reboot",
|
key="reboot",
|
||||||
device_class=ButtonDeviceClass.RESTART,
|
device_class=ButtonDeviceClass.RESTART,
|
||||||
entity_category=EntityCategory.CONFIG,
|
entity_category=EntityCategory.CONFIG,
|
||||||
press_fn=lambda device: device.async_reboot(),
|
press_fn=lambda device: device.async_reboot(),
|
||||||
),
|
),
|
||||||
OasisMiniButtonEntityDescription(
|
OasisDeviceButtonEntityDescription(
|
||||||
key="random_track",
|
key="random_track",
|
||||||
translation_key="random_track",
|
translation_key="random_track",
|
||||||
press_fn=play_random_track,
|
press_fn=play_random_track,
|
||||||
),
|
),
|
||||||
|
OasisDeviceButtonEntityDescription(
|
||||||
|
key="sleep",
|
||||||
|
translation_key="sleep",
|
||||||
|
press_fn=lambda device: device.async_sleep(),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class OasisMiniButtonEntity(OasisMiniEntity, ButtonEntity):
|
class OasisDeviceButtonEntity(OasisDeviceEntity, ButtonEntity):
|
||||||
"""Oasis Mini button entity."""
|
"""Oasis device button entity."""
|
||||||
|
|
||||||
entity_description: OasisMiniButtonEntityDescription
|
entity_description: OasisDeviceButtonEntityDescription
|
||||||
|
|
||||||
async def async_press(self) -> None:
|
async def async_press(self) -> None:
|
||||||
"""Press the button."""
|
"""Press the button."""
|
||||||
await self.entity_description.press_fn(self.device)
|
await self.entity_description.press_fn(self.device)
|
||||||
await self.coordinator.async_request_refresh()
|
|
||||||
|
|||||||
@@ -1,86 +1,52 @@
|
|||||||
"""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.components import dhcp
|
|
||||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_HOST, CONF_PASSWORD
|
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD
|
||||||
from homeassistant.core import callback
|
|
||||||
from homeassistant.helpers.schema_config_entry_flow import (
|
|
||||||
SchemaCommonFlowHandler,
|
|
||||||
SchemaFlowError,
|
|
||||||
SchemaFlowFormStep,
|
|
||||||
SchemaOptionsFlowHandler,
|
|
||||||
)
|
|
||||||
|
|
||||||
from . import OasisMiniConfigEntry
|
|
||||||
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})
|
|
||||||
OPTIONS_SCHEMA = vol.Schema(
|
|
||||||
{vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str}
|
{vol.Required(CONF_EMAIL): str, vol.Required(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]:
|
|
||||||
"""Cloud login."""
|
|
||||||
coordinator: OasisMiniCoordinator = handler.parent_handler.config_entry.runtime_data
|
|
||||||
|
|
||||||
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 Exception as ex:
|
|
||||||
raise SchemaFlowError("invalid_auth") from ex
|
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@staticmethod
|
async def async_step_reauth(
|
||||||
@callback
|
self, entry_data: Mapping[str, Any]
|
||||||
def async_get_options_flow(
|
|
||||||
config_entry: OasisMiniConfigEntry,
|
|
||||||
) -> SchemaOptionsFlowHandler:
|
|
||||||
"""Get the options flow for this handler."""
|
|
||||||
return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW)
|
|
||||||
|
|
||||||
async def async_step_dhcp(
|
|
||||||
self, discovery_info: dhcp.DhcpServiceInfo
|
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Handle DHCP discovery."""
|
"""Perform reauth upon an API authentication error."""
|
||||||
host = {CONF_HOST: discovery_info.ip}
|
return await self.async_step_reauth_confirm()
|
||||||
await self.validate_client(host)
|
|
||||||
self._abort_if_unique_id_configured(updates=host)
|
async def async_step_reauth_confirm(
|
||||||
# This should never happen since we only listen to DHCP requests
|
self, user_input: dict[str, Any] | None = None
|
||||||
# for configured devices.
|
) -> ConfigFlowResult:
|
||||||
return self.async_abort(reason="already_configured")
|
"""Dialog that informs the user that reauth is required."""
|
||||||
|
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(
|
||||||
|
"reauth_confirm", STEP_USER_DATA_SCHEMA, user_input, suggested_values
|
||||||
|
)
|
||||||
|
|
||||||
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
|
||||||
@@ -114,20 +80,22 @@ class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
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)):
|
||||||
if step_id != "reconfigure":
|
entry_id = self.context.get("entry_id")
|
||||||
self._abort_if_unique_id_configured(updates=user_input)
|
existing_entry = self.hass.config_entries.async_get_entry(entry_id)
|
||||||
if existing_entry := self.hass.config_entries.async_get_entry(
|
if existing_entry and existing_entry.unique_id:
|
||||||
self.context.get("entry_id")
|
self._abort_if_unique_id_mismatch(reason="wrong_account")
|
||||||
):
|
if existing_entry:
|
||||||
self.hass.config_entries.async_update_entry(
|
return self.async_update_reload_and_abort(
|
||||||
existing_entry, data=user_input
|
existing_entry,
|
||||||
)
|
unique_id=self.unique_id,
|
||||||
await self.hass.config_entries.async_reload(existing_entry.entry_id)
|
title=user_input[CONF_EMAIL],
|
||||||
return self.async_abort(reason="reconfigure_successful")
|
|
||||||
|
|
||||||
return self.async_create_entry(
|
|
||||||
title=f"Oasis Mini {self.unique_id}",
|
|
||||||
data=user_input,
|
data=user_input,
|
||||||
|
reload_even_if_entry_is_unchanged=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._abort_if_unique_id_configured(updates=user_input)
|
||||||
|
return self.async_create_entry(
|
||||||
|
title=user_input[CONF_EMAIL], data=user_input
|
||||||
)
|
)
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
@@ -141,21 +109,29 @@ class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
errors = {}
|
errors = {}
|
||||||
try:
|
try:
|
||||||
async with asyncio.timeout(10):
|
async with asyncio.timeout(10):
|
||||||
client = create_client(user_input)
|
client = create_client(self.hass, user_input)
|
||||||
await self.async_set_unique_id(await client.async_get_serial_number())
|
await client.async_login(
|
||||||
|
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:
|
if not self.unique_id:
|
||||||
errors["base"] = "invalid_host"
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -9,53 +9,71 @@ import async_timeout
|
|||||||
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
import homeassistant.util.dt as dt_util
|
||||||
|
|
||||||
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
|
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,
|
hass,
|
||||||
_LOGGER,
|
_LOGGER,
|
||||||
name=DOMAIN,
|
name=DOMAIN,
|
||||||
update_interval=timedelta(seconds=10),
|
update_interval=timedelta(minutes=10),
|
||||||
always_update=False,
|
always_update=False,
|
||||||
)
|
)
|
||||||
self.device = device
|
self.cloud_client = cloud_client
|
||||||
|
self.mqtt_client = mqtt_client
|
||||||
|
|
||||||
async def _async_update_data(self):
|
async def _async_update_data(self) -> list[OasisDevice]:
|
||||||
"""Update the data."""
|
"""Update the data."""
|
||||||
data: str | None = None
|
devices: list[OasisDevice] = []
|
||||||
self.attempt += 1
|
self.attempt += 1
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with async_timeout.timeout(10):
|
async with async_timeout.timeout(10):
|
||||||
if not self.device.mac_address:
|
if not self.data:
|
||||||
await self.device.async_get_mac_address()
|
raw_devices = await self.cloud_client.async_get_devices()
|
||||||
if not self.device.serial_number:
|
devices = [
|
||||||
await self.device.async_get_serial_number()
|
OasisDevice(
|
||||||
if not self.device.software_version:
|
model=raw_device.get("model", {}).get("name"),
|
||||||
await self.device.async_get_software_version()
|
serial_number=raw_device.get("serial_number"),
|
||||||
data = await self.device.async_get_status()
|
cloud=self.cloud_client,
|
||||||
|
)
|
||||||
|
for raw_device in raw_devices
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
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 await device.async_get_mac_address():
|
||||||
|
raise Exception(
|
||||||
|
"Could not get mac address for %s", device.serial_number
|
||||||
|
)
|
||||||
|
await self.cloud_client.async_get_playlists()
|
||||||
self.attempt = 0
|
self.attempt = 0
|
||||||
await self.device.async_get_current_track_details()
|
|
||||||
await self.device.async_get_playlist_details()
|
|
||||||
except Exception as ex: # pylint:disable=broad-except
|
except Exception as ex: # pylint:disable=broad-except
|
||||||
if self.attempt > 2 or not (data or self.data):
|
if self.attempt > 2 or not (devices or self.data):
|
||||||
raise UpdateFailed(
|
raise UpdateFailed(
|
||||||
f"Couldn't read from the Oasis Mini after {self.attempt} attempts"
|
f"Couldn't read from the Oasis device after {self.attempt} attempts"
|
||||||
) from ex
|
) from ex
|
||||||
|
|
||||||
if data != self.data:
|
if devices != self.data:
|
||||||
self.last_updated = datetime.now()
|
self.last_updated = dt_util.now()
|
||||||
return data
|
return devices
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Oasis Mini entity."""
|
"""Oasis device entity."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -7,36 +7,39 @@ 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
|
||||||
|
|
||||||
|
|
||||||
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, coordinator: OasisMiniCoordinator, description: EntityDescription
|
self,
|
||||||
|
coordinator: OasisDeviceCoordinator,
|
||||||
|
device: OasisDevice,
|
||||||
|
description: EntityDescription,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Construct an 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
|
||||||
device = coordinator.device
|
|
||||||
serial_number = 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}"
|
||||||
|
|
||||||
|
connections = set()
|
||||||
|
if mac_address := device.mac_address:
|
||||||
|
connections.add((CONNECTION_NETWORK_MAC, format_mac(mac_address)))
|
||||||
|
|
||||||
self._attr_device_info = DeviceInfo(
|
self._attr_device_info = DeviceInfo(
|
||||||
connections={(CONNECTION_NETWORK_MAC, format_mac(device.mac_address))},
|
connections=connections,
|
||||||
identifiers={(DOMAIN, serial_number)},
|
identifiers={(DOMAIN, serial_number)},
|
||||||
name=f"Oasis Mini {serial_number}",
|
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=device.software_version,
|
sw_version=device.software_version,
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
|
||||||
def device(self) -> OasisMini:
|
|
||||||
"""Return the device."""
|
|
||||||
return self.coordinator.device
|
|
||||||
|
|||||||
@@ -1,30 +1,43 @@
|
|||||||
"""Helpers for the Oasis Mini integration."""
|
"""Helpers for the Oasis devices integration."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST
|
import async_timeout
|
||||||
|
|
||||||
from .pyoasismini import TRACKS, OasisMini
|
from homeassistant.const import CONF_ACCESS_TOKEN
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
|
||||||
|
from .pyoasiscontrol import OasisCloudClient, OasisDevice
|
||||||
|
from .pyoasiscontrol.const import TRACKS
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_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: OasisMini, track: int) -> None:
|
async def add_and_play_track(device: OasisDevice, track: int) -> None:
|
||||||
"""Add and play a track."""
|
"""Add and play a track."""
|
||||||
|
async with async_timeout.timeout(10):
|
||||||
if track not in device.playlist:
|
if track not in device.playlist:
|
||||||
await device.async_add_track_to_playlist(track)
|
await device.async_add_track_to_playlist(track)
|
||||||
|
|
||||||
|
while track not in device.playlist:
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
# Move track to next item in the playlist and then select it
|
# Move track to next item in the playlist and then select it
|
||||||
if (index := device.playlist.index(track)) != device.playlist_index:
|
if (index := device.playlist.index(track)) != device.playlist_index:
|
||||||
if index != (_next := min(device.playlist_index + 1, len(device.playlist) - 1)):
|
if index != (
|
||||||
|
_next := min(device.playlist_index + 1, len(device.playlist) - 1)
|
||||||
|
):
|
||||||
await device.async_move_track(index, _next)
|
await device.async_move_track(index, _next)
|
||||||
await device.async_change_track(_next)
|
await device.async_change_track(_next)
|
||||||
|
|
||||||
|
|||||||
@@ -24,12 +24,14 @@
|
|||||||
"status": {
|
"status": {
|
||||||
"state": {
|
"state": {
|
||||||
"booting": "mdi:loading",
|
"booting": "mdi:loading",
|
||||||
|
"busy": "mdi:progress-clock",
|
||||||
"centering": "mdi:record-circle-outline",
|
"centering": "mdi:record-circle-outline",
|
||||||
"downloading": "mdi:progress-download",
|
"downloading": "mdi:progress-download",
|
||||||
"error": "mdi:alert-circle-outline",
|
"error": "mdi:alert-circle-outline",
|
||||||
"live": "mdi:pencil-circle-outline",
|
"live": "mdi:pencil-circle-outline",
|
||||||
"paused": "mdi:motion-pause-outline",
|
"paused": "mdi:motion-pause-outline",
|
||||||
"playing": "mdi:motion-play-outline",
|
"playing": "mdi:motion-play-outline",
|
||||||
|
"sleeping": "mdi:power-sleep",
|
||||||
"stopped": "mdi:stop-circle-outline",
|
"stopped": "mdi:stop-circle-outline",
|
||||||
"updating": "mdi:update"
|
"updating": "mdi:update"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Oasis Mini image entity."""
|
"""Oasis device image entity."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -7,69 +7,67 @@ from homeassistant.core import HomeAssistant, callback
|
|||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.typing import UNDEFINED
|
from homeassistant.helpers.typing import UNDEFINED
|
||||||
|
|
||||||
from . import OasisMiniConfigEntry
|
from . import OasisDeviceConfigEntry
|
||||||
from .coordinator import OasisMiniCoordinator
|
from .coordinator import OasisDeviceCoordinator
|
||||||
from .entity import OasisMiniEntity
|
from .entity import OasisDeviceEntity
|
||||||
from .pyoasismini.const import TRACKS
|
from .pyoasiscontrol import OasisDevice
|
||||||
from .pyoasismini.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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
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
|
_track_id: int | None = None
|
||||||
_progress: int = 0
|
_progress: int = 0
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, coordinator: OasisMiniCoordinator, description: ImageEntityDescription
|
self,
|
||||||
|
coordinator: OasisDeviceCoordinator,
|
||||||
|
device: OasisDevice,
|
||||||
|
description: ImageEntityDescription,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the entity."""
|
"""Initialize the entity."""
|
||||||
super().__init__(coordinator, description)
|
super().__init__(coordinator, device, description)
|
||||||
ImageEntity.__init__(self, coordinator.hass)
|
ImageEntity.__init__(self, coordinator.hass)
|
||||||
self._handle_coordinator_update()
|
self._handle_coordinator_update()
|
||||||
|
|
||||||
def image(self) -> bytes | None:
|
def image(self) -> bytes | None:
|
||||||
"""Return bytes of image."""
|
"""Return bytes of image."""
|
||||||
if not self._cached_image:
|
if not self._cached_image:
|
||||||
self._cached_image = Image(
|
self._cached_image = Image(self.content_type, self.device.create_svg())
|
||||||
self.content_type, draw_svg(self.device.track, self._progress, "1")
|
|
||||||
)
|
|
||||||
return self._cached_image.content
|
return self._cached_image.content
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _handle_coordinator_update(self) -> None:
|
def _handle_coordinator_update(self) -> None:
|
||||||
"""Handle updated data from the coordinator."""
|
"""Handle updated data from the coordinator."""
|
||||||
|
device = self.device
|
||||||
if (
|
if (
|
||||||
self._track_id != self.device.track_id
|
self._track_id != device.track_id or self._progress != device.progress
|
||||||
or (self._progress != self.device.progress and self.device.access_token)
|
) and (device.status == "playing" or self._cached_image is None):
|
||||||
) and (self.device.status == "playing" or self._cached_image is None):
|
|
||||||
self._attr_image_last_updated = self.coordinator.last_updated
|
self._attr_image_last_updated = self.coordinator.last_updated
|
||||||
self._track_id = self.device.track_id
|
self._track_id = device.track_id
|
||||||
self._progress = self.device.progress
|
self._progress = device.progress
|
||||||
self._cached_image = None
|
self._cached_image = None
|
||||||
if self.device.track and self.device.track.get("svg_content"):
|
if device.track and device.track.get("svg_content"):
|
||||||
self._attr_image_url = UNDEFINED
|
self._attr_image_url = UNDEFINED
|
||||||
else:
|
else:
|
||||||
self._attr_image_url = (
|
self._attr_image_url = device.track_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:
|
if self.hass:
|
||||||
super()._handle_coordinator_update()
|
super()._handle_coordinator_update()
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
entry: OasisMiniConfigEntry,
|
|
||||||
async_add_entities: AddEntitiesCallback,
|
|
||||||
) -> None:
|
|
||||||
"""Set up Oasis Mini camera using config entry."""
|
|
||||||
async_add_entities([OasisMiniImageEntity(entry.runtime_data, IMAGE)])
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Oasis Mini light entity."""
|
"""Oasis device light entity."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -23,20 +23,37 @@ from homeassistant.util.color import (
|
|||||||
value_to_brightness,
|
value_to_brightness,
|
||||||
)
|
)
|
||||||
|
|
||||||
from . import OasisMiniConfigEntry
|
from . import OasisDeviceConfigEntry
|
||||||
from .entity import OasisMiniEntity
|
from .coordinator import OasisDeviceCoordinator
|
||||||
from .pyoasismini import LED_EFFECTS
|
from .entity import OasisDeviceEntity
|
||||||
|
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
|
||||||
@@ -82,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)}"
|
||||||
@@ -103,16 +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", translation_key="led")
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
entry: OasisMiniConfigEntry,
|
|
||||||
async_add_entities: AddEntitiesCallback,
|
|
||||||
) -> None:
|
|
||||||
"""Set up Oasis Mini lights using config entry."""
|
|
||||||
async_add_entities([OasisMiniLightEntity(entry.runtime_data, DESCRIPTOR)])
|
|
||||||
|
|||||||
@@ -1,13 +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 }],
|
"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,4 +1,4 @@
|
|||||||
"""Oasis Mini media player entity."""
|
"""Oasis device media player entity."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -18,15 +18,31 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.exceptions import ServiceValidationError
|
from homeassistant.exceptions import ServiceValidationError
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from . import OasisMiniConfigEntry
|
from . import OasisDeviceConfigEntry
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .entity import OasisMiniEntity
|
from .coordinator import OasisDeviceCoordinator
|
||||||
|
from .entity import OasisDeviceEntity
|
||||||
from .helpers import get_track_id
|
from .helpers import get_track_id
|
||||||
from .pyoasismini.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 = (
|
||||||
@@ -49,18 +65,14 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
|
|||||||
@property
|
@property
|
||||||
def media_duration(self) -> int | None:
|
def media_duration(self) -> int | None:
|
||||||
"""Duration of current playing media in seconds."""
|
"""Duration of current playing media in seconds."""
|
||||||
if (track := self.device.track) and "reduced_svg_content" in track:
|
if (track := self.device.track) and "reduced_svg_content_new" in track:
|
||||||
return track["reduced_svg_content"].get("1")
|
return track["reduced_svg_content_new"]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def media_image_url(self) -> str | None:
|
def media_image_url(self) -> str | None:
|
||||||
"""Image url of current playing media."""
|
"""Image url of current playing media."""
|
||||||
if not (track := self.device.track):
|
return self.device.track_image_url
|
||||||
track = TRACKS.get(self.device.track_id)
|
|
||||||
if track and "image" in track:
|
|
||||||
return f"https://app.grounded.so/uploads/{track['image']}"
|
|
||||||
return None
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def media_position(self) -> int:
|
def media_position(self) -> int:
|
||||||
@@ -75,11 +87,7 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
|
|||||||
@property
|
@property
|
||||||
def media_title(self) -> str | None:
|
def media_title(self) -> str | None:
|
||||||
"""Title of current playing media."""
|
"""Title of current playing media."""
|
||||||
if not self.device.track_id:
|
return self.device.track_name
|
||||||
return None
|
|
||||||
if not (track := self.device.track):
|
|
||||||
track = TRACKS.get(self.device.track_id, {})
|
|
||||||
return track.get("name", f"Unknown Title (#{self.device.track_id})")
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def repeat(self) -> RepeatMode:
|
def repeat(self) -> RepeatMode:
|
||||||
@@ -117,19 +125,16 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
|
|||||||
"""Send pause command."""
|
"""Send pause command."""
|
||||||
self.abort_if_busy()
|
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()
|
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:
|
async def async_media_stop(self) -> None:
|
||||||
"""Send stop command."""
|
"""Send stop command."""
|
||||||
self.abort_if_busy()
|
self.abort_if_busy()
|
||||||
await self.device.async_stop()
|
await self.device.async_stop()
|
||||||
await self.coordinator.async_request_refresh()
|
|
||||||
|
|
||||||
async def async_set_repeat(self, repeat: RepeatMode) -> None:
|
async def async_set_repeat(self, repeat: RepeatMode) -> None:
|
||||||
"""Set repeat mode."""
|
"""Set repeat mode."""
|
||||||
@@ -137,7 +142,6 @@ 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:
|
async def async_media_previous_track(self) -> None:
|
||||||
"""Send previous track command."""
|
"""Send previous track command."""
|
||||||
@@ -145,7 +149,6 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
|
|||||||
if (index := self.device.playlist_index - 1) < 0:
|
if (index := self.device.playlist_index - 1) < 0:
|
||||||
index = len(self.device.playlist) - 1
|
index = len(self.device.playlist) - 1
|
||||||
await self.device.async_change_track(index)
|
await self.device.async_change_track(index)
|
||||||
await self.coordinator.async_request_refresh()
|
|
||||||
|
|
||||||
async def async_media_next_track(self) -> None:
|
async def async_media_next_track(self) -> None:
|
||||||
"""Send next track command."""
|
"""Send next track command."""
|
||||||
@@ -153,7 +156,6 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
|
|||||||
if (index := self.device.playlist_index + 1) >= len(self.device.playlist):
|
if (index := self.device.playlist_index + 1) >= len(self.device.playlist):
|
||||||
index = 0
|
index = 0
|
||||||
await self.device.async_change_track(index)
|
await self.device.async_change_track(index)
|
||||||
await self.coordinator.async_request_refresh()
|
|
||||||
|
|
||||||
async def async_play_media(
|
async def async_play_media(
|
||||||
self,
|
self,
|
||||||
@@ -203,22 +205,7 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
|
|||||||
):
|
):
|
||||||
await device.async_play()
|
await device.async_play()
|
||||||
|
|
||||||
await self.coordinator.async_request_refresh()
|
|
||||||
|
|
||||||
async def async_clear_playlist(self) -> None:
|
async def async_clear_playlist(self) -> None:
|
||||||
"""Clear players playlist."""
|
"""Clear players playlist."""
|
||||||
self.abort_if_busy()
|
self.abort_if_busy()
|
||||||
await self.device.async_clear_playlist()
|
await self.device.async_clear_playlist()
|
||||||
await self.coordinator.async_request_refresh()
|
|
||||||
|
|
||||||
|
|
||||||
DESCRIPTOR = MediaPlayerEntityDescription(key="oasis_mini", name=None)
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
entry: OasisMiniConfigEntry,
|
|
||||||
async_add_entities: AddEntitiesCallback,
|
|
||||||
) -> None:
|
|
||||||
"""Set up Oasis Mini media_players using config entry."""
|
|
||||||
async_add_entities([OasisMiniMediaPlayerEntity(entry.runtime_data, DESCRIPTOR)])
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Oasis Mini number entity."""
|
"""Oasis device number entity."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -10,26 +10,29 @@ from homeassistant.components.number import (
|
|||||||
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 . import OasisMiniConfigEntry
|
from . import OasisDeviceConfigEntry
|
||||||
from .entity import OasisMiniEntity
|
from .coordinator import OasisDeviceCoordinator
|
||||||
from .pyoasismini import BALL_SPEED_MAX, BALL_SPEED_MIN, LED_SPEED_MAX, LED_SPEED_MIN
|
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,
|
||||||
@property
|
async_add_entities: AddEntitiesCallback,
|
||||||
def native_value(self) -> str | None:
|
) -> None:
|
||||||
"""Return the value reported by the number."""
|
"""Set up Oasis device numbers using config entry."""
|
||||||
return getattr(self.device, self.entity_description.key)
|
coordinator: OasisDeviceCoordinator = entry.runtime_data
|
||||||
|
async_add_entities(
|
||||||
async def async_set_native_value(self, value: float) -> None:
|
OasisDeviceNumberEntity(coordinator, device, descriptor)
|
||||||
"""Set new value."""
|
for device in coordinator.data
|
||||||
if self.entity_description.key == "ball_speed":
|
for descriptor in DESCRIPTORS
|
||||||
await self.device.async_set_ball_speed(value)
|
)
|
||||||
elif self.entity_description.key == "led_speed":
|
|
||||||
await self.device.async_set_led(led_speed=value)
|
|
||||||
await self.coordinator.async_request_refresh()
|
|
||||||
|
|
||||||
|
|
||||||
DESCRIPTORS = {
|
DESCRIPTORS = {
|
||||||
@@ -50,15 +53,18 @@ DESCRIPTORS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
class OasisDeviceNumberEntity(OasisDeviceEntity, NumberEntity):
|
||||||
hass: HomeAssistant,
|
"""Oasis device number entity."""
|
||||||
entry: OasisMiniConfigEntry,
|
|
||||||
async_add_entities: AddEntitiesCallback,
|
@property
|
||||||
) -> None:
|
def native_value(self) -> str | None:
|
||||||
"""Set up Oasis Mini numbers using config entry."""
|
"""Return the value reported by the number."""
|
||||||
async_add_entities(
|
return getattr(self.device, self.entity_description.key)
|
||||||
[
|
|
||||||
OasisMiniNumberEntity(entry.runtime_data, descriptor)
|
async def async_set_native_value(self, value: float) -> None:
|
||||||
for descriptor in DESCRIPTORS
|
"""Set new value."""
|
||||||
]
|
value = int(value)
|
||||||
)
|
if self.entity_description.key == "ball_speed":
|
||||||
|
await self.device.async_set_ball_speed(value)
|
||||||
|
elif self.entity_description.key == "led_speed":
|
||||||
|
await self.device.async_set_led(led_speed=value)
|
||||||
|
|||||||
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 datetime, 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: datetime
|
||||||
|
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 = now()
|
||||||
|
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,605 @@
|
|||||||
|
"""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, device in self._devices.items():
|
||||||
|
await self._subscribe_serial(serial)
|
||||||
|
await self.async_get_all(device)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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",
|
||||||
|
}
|
||||||
479
custom_components/oasis_mini/pyoasiscontrol/device.py
Normal file
479
custom_components/oasis_mini/pyoasiscontrol/device.py
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
"""Oasis device."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
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, create_svg, decrypt_svg_content
|
||||||
|
|
||||||
|
if TYPE_CHECKING: # avoid runtime circular imports
|
||||||
|
from .clients import OasisCloudClient
|
||||||
|
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,
|
||||||
|
cloud: OasisCloudClient | None = None,
|
||||||
|
client: OasisClientProtocol | None = None,
|
||||||
|
) -> None:
|
||||||
|
# Transport
|
||||||
|
self._cloud = cloud
|
||||||
|
self._client = 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
|
||||||
|
self._track: dict | None = None
|
||||||
|
self._track_task: asyncio.Task | 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
|
||||||
|
playlist_or_index_changed = False
|
||||||
|
|
||||||
|
for key, value in data.items():
|
||||||
|
if hasattr(self, key):
|
||||||
|
if self._update_field(key, value):
|
||||||
|
changed = True
|
||||||
|
if key in ("playlist", "playlist_index"):
|
||||||
|
playlist_or_index_changed = True
|
||||||
|
else:
|
||||||
|
_LOGGER.warning("Unknown field: %s=%s", key, value)
|
||||||
|
|
||||||
|
if playlist_or_index_changed:
|
||||||
|
self._schedule_track_refresh()
|
||||||
|
|
||||||
|
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(self) -> dict | None:
|
||||||
|
"""Return cached track info if it matches the current `track_id`."""
|
||||||
|
if (track := self._track) and track["id"] == self.track_id:
|
||||||
|
return track
|
||||||
|
return TRACKS.get(self.track_id)
|
||||||
|
|
||||||
|
@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_image_url(self) -> str | None:
|
||||||
|
"""Return the track image url, if any."""
|
||||||
|
if (track := self.track) and (image := track.get("image")):
|
||||||
|
return f"https://app.grounded.so/uploads/{image}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def track_name(self) -> str | None:
|
||||||
|
"""Return the track name, if any."""
|
||||||
|
if track := self.track:
|
||||||
|
return track.get("name", f"Unknown Title (#{self.track_id})")
|
||||||
|
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 min(percent, 100)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def playlist_details(self) -> dict[int, dict[str, str]]:
|
||||||
|
"""Basic playlist details using built-in TRACKS metadata."""
|
||||||
|
return {
|
||||||
|
track_id: {self.track_id: self.track or {}, **TRACKS}.get(
|
||||||
|
track_id,
|
||||||
|
{"name": f"Unknown Title (#{track_id})"},
|
||||||
|
)
|
||||||
|
for track_id in self.playlist
|
||||||
|
}
|
||||||
|
|
||||||
|
def create_svg(self) -> str | None:
|
||||||
|
"""Create the current svg based on track and progress."""
|
||||||
|
return create_svg(self.track, self.progress)
|
||||||
|
|
||||||
|
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 device 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)
|
||||||
|
|
||||||
|
def _schedule_track_refresh(self) -> None:
|
||||||
|
"""Schedule an async refresh of current track info if track_id changed."""
|
||||||
|
if not self._cloud:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
_LOGGER.debug("No running loop; cannot schedule track refresh")
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._track_task and not self._track_task.done():
|
||||||
|
self._track_task.cancel()
|
||||||
|
|
||||||
|
self._track_task = loop.create_task(self._async_refresh_current_track())
|
||||||
|
|
||||||
|
async def _async_refresh_current_track(self) -> None:
|
||||||
|
"""Refresh the current track info."""
|
||||||
|
if not self._cloud:
|
||||||
|
return
|
||||||
|
|
||||||
|
if (track_id := self.track_id) is None:
|
||||||
|
self._track = None
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._track and self._track.get("id") == track_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
track = await self._cloud.async_get_track_info(track_id)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
_LOGGER.exception("Error fetching track info for %s", track_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not track:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._track = track
|
||||||
|
self._notify_listeners()
|
||||||
@@ -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,8 +1,9 @@
|
|||||||
"""Oasis Mini utils."""
|
"""Oasis control utils."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
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
|
||||||
@@ -26,14 +27,22 @@ def _bit_to_bool(val: str) -> bool:
|
|||||||
return val == "1"
|
return val == "1"
|
||||||
|
|
||||||
|
|
||||||
def draw_svg(track: dict, progress: int, model_id: str) -> str | None:
|
def _parse_int(val: str) -> int:
|
||||||
"""Draw SVG."""
|
"""Convert an int string to int."""
|
||||||
|
try:
|
||||||
|
return int(val)
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def create_svg(track: dict, progress: int) -> str | None:
|
||||||
|
"""Create an SVG from a track based on progress."""
|
||||||
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)
|
svg_content = decrypt_svg_content(svg_content)
|
||||||
paths = svg_content.split("L")
|
paths = svg_content.split("L")
|
||||||
total = track.get("reduced_svg_content", {}).get(model_id, len(paths))
|
total = track.get("reduced_svg_content_new", 0) or len(paths)
|
||||||
percent = min((100 * progress) / total, 100)
|
percent = min((100 * progress) / total, 100)
|
||||||
progress = math.floor((percent / 100) * (len(paths) - 1))
|
progress = math.floor((percent / 100) * (len(paths) - 1))
|
||||||
|
|
||||||
@@ -169,3 +178,7 @@ def decrypt_svg_content(svg_content: dict[str, str]):
|
|||||||
svg_content["decrypted"] = decrypted
|
svg_content["decrypted"] = decrypted
|
||||||
|
|
||||||
return decrypted
|
return decrypted
|
||||||
|
|
||||||
|
|
||||||
|
def now() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
@@ -1,450 +0,0 @@
|
|||||||
"""Oasis Mini API client."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from typing import Any, Awaitable, Final
|
|
||||||
from urllib.parse import urljoin
|
|
||||||
|
|
||||||
from aiohttp import ClientResponseError, ClientSession
|
|
||||||
|
|
||||||
from .const import TRACKS
|
|
||||||
from .utils import _bit_to_bool, decrypt_svg_content
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
STATUS_CODE_MAP = {
|
|
||||||
0: "booting", # maybe?
|
|
||||||
2: "stopped",
|
|
||||||
3: "centering",
|
|
||||||
4: "playing",
|
|
||||||
5: "paused",
|
|
||||||
9: "error",
|
|
||||||
11: "updating",
|
|
||||||
13: "downloading",
|
|
||||||
15: "live",
|
|
||||||
}
|
|
||||||
|
|
||||||
AUTOPLAY_MAP = {
|
|
||||||
"0": "on",
|
|
||||||
"1": "off",
|
|
||||||
"2": "5 minutes",
|
|
||||||
"3": "10 minutes",
|
|
||||||
"4": "30 minutes",
|
|
||||||
}
|
|
||||||
|
|
||||||
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"
|
|
||||||
|
|
||||||
BALL_SPEED_MAX: Final = 1000
|
|
||||||
BALL_SPEED_MIN: Final = 200
|
|
||||||
LED_SPEED_MAX: Final = 90
|
|
||||||
LED_SPEED_MIN: Final = -90
|
|
||||||
|
|
||||||
|
|
||||||
class OasisMini:
|
|
||||||
"""Oasis Mini API client class."""
|
|
||||||
|
|
||||||
_access_token: str | None = None
|
|
||||||
_mac_address: str | None = None
|
|
||||||
_ip_address: str | None = None
|
|
||||||
_playlist: dict[int, dict[str, str]] = {}
|
|
||||||
_serial_number: str | None = None
|
|
||||||
_software_version: str | None = None
|
|
||||||
_track: dict | None = None
|
|
||||||
|
|
||||||
autoplay: str
|
|
||||||
brightness: int
|
|
||||||
busy: bool
|
|
||||||
color: str | None = None
|
|
||||||
download_progress: int
|
|
||||||
error: int
|
|
||||||
led_effect: str
|
|
||||||
led_speed: int
|
|
||||||
max_brightness: int
|
|
||||||
playlist: list[int]
|
|
||||||
playlist_index: int
|
|
||||||
progress: int
|
|
||||||
repeat_playlist: bool
|
|
||||||
status_code: int
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
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 mac_address(self) -> str | None:
|
|
||||||
"""Return the mac address."""
|
|
||||||
return self._mac_address
|
|
||||||
|
|
||||||
@property
|
|
||||||
def drawing_progress(self) -> float | None:
|
|
||||||
"""Return the drawing progress percent."""
|
|
||||||
if not (self.track and (svg_content := self.track.get("svg_content"))):
|
|
||||||
return None
|
|
||||||
svg_content = decrypt_svg_content(svg_content)
|
|
||||||
paths = svg_content.split("L")
|
|
||||||
total = self.track.get("reduced_svg_content", {}).get("1", len(paths))
|
|
||||||
percent = (100 * self.progress) / total
|
|
||||||
return percent
|
|
||||||
|
|
||||||
@property
|
|
||||||
def serial_number(self) -> str | None:
|
|
||||||
"""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 track(self) -> dict | None:
|
|
||||||
"""Return the current track info."""
|
|
||||||
if self._track and self._track.get("id") == self.track_id:
|
|
||||||
return self._track
|
|
||||||
return None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def track_id(self) -> int | None:
|
|
||||||
"""Return the current track id."""
|
|
||||||
if not self.playlist:
|
|
||||||
return None
|
|
||||||
i = self.playlist_index
|
|
||||||
return self.playlist[0] if i >= len(self.playlist) else self.playlist[i]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def url(self) -> str:
|
|
||||||
"""Return the url."""
|
|
||||||
return f"http://{self._host}/"
|
|
||||||
|
|
||||||
async def async_add_track_to_playlist(self, track: int | list[int]) -> None:
|
|
||||||
"""Add track to playlist."""
|
|
||||||
if not track:
|
|
||||||
return
|
|
||||||
if isinstance(track, int):
|
|
||||||
track = [track]
|
|
||||||
if 0 in self.playlist:
|
|
||||||
playlist = [t for t in self.playlist if t] + track
|
|
||||||
return await self.async_set_playlist(playlist)
|
|
||||||
await self._async_command(params={"ADDJOBLIST": track})
|
|
||||||
self.playlist.extend(track)
|
|
||||||
|
|
||||||
async def async_change_track(self, index: int) -> None:
|
|
||||||
"""Change the track."""
|
|
||||||
if index >= len(self.playlist):
|
|
||||||
raise ValueError("Invalid index specified")
|
|
||||||
await self._async_command(params={"CMDCHANGETRACK": index})
|
|
||||||
|
|
||||||
async def async_clear_playlist(self) -> None:
|
|
||||||
"""Clear the playlist."""
|
|
||||||
await self.async_set_playlist([])
|
|
||||||
|
|
||||||
async def async_get_ip_address(self) -> str | None:
|
|
||||||
"""Get the ip address."""
|
|
||||||
self._ip_address = await self._async_get(params={"GETIP": ""})
|
|
||||||
_LOGGER.debug("IP address: %s", self._ip_address)
|
|
||||||
return self._ip_address
|
|
||||||
|
|
||||||
async def async_get_mac_address(self) -> str | None:
|
|
||||||
"""Get the mac address."""
|
|
||||||
self._mac_address = await self._async_get(params={"GETMAC": ""})
|
|
||||||
_LOGGER.debug("MAC address: %s", self._mac_address)
|
|
||||||
return self._mac_address
|
|
||||||
|
|
||||||
async def async_get_serial_number(self) -> str | None:
|
|
||||||
"""Get the serial number."""
|
|
||||||
self._serial_number = await self._async_get(params={"GETOASISID": ""})
|
|
||||||
_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) -> str:
|
|
||||||
"""Get the status from the device."""
|
|
||||||
raw_status = await self._async_get(params={"GETSTATUS": ""})
|
|
||||||
_LOGGER.debug("Status: %s", raw_status)
|
|
||||||
values = raw_status.split(";")
|
|
||||||
playlist = [int(track) for track in values[3].split(",") if track]
|
|
||||||
status = {
|
|
||||||
"status_code": int(values[0]), # see status code map
|
|
||||||
"error": int(values[1]), # noqa: E501; error, 0 = none, and 10 = ?, 18 = can't download?
|
|
||||||
"ball_speed": int(values[2]), # 200 - 1000
|
|
||||||
"playlist": playlist,
|
|
||||||
"playlist_index": min(int(values[4]), len(playlist)), # index of above
|
|
||||||
"progress": int(values[5]), # 0 - max svg path
|
|
||||||
"led_effect": values[6], # led effect (code lookup)
|
|
||||||
"led_color_id": values[7], # led color id?
|
|
||||||
"led_speed": int(values[8]), # -90 - 90
|
|
||||||
"brightness": int(values[9]) if values[10] else 0, # noqa: E501; 0 - 200 in app, but seems to be 0 (off) to 304 (max), then repeats
|
|
||||||
"color": values[10] or None, # hex color code
|
|
||||||
"busy": _bit_to_bool(values[11]), # noqa: E501; device is busy (downloading track, centering, software update)?
|
|
||||||
"download_progress": int(values[12]),
|
|
||||||
"max_brightness": int(values[13]),
|
|
||||||
"wifi_connected": _bit_to_bool(values[14]),
|
|
||||||
"repeat_playlist": _bit_to_bool(values[15]),
|
|
||||||
"autoplay": AUTOPLAY_MAP.get(values[16]),
|
|
||||||
}
|
|
||||||
for key, value in status.items():
|
|
||||||
if (old_value := getattr(self, key, None)) != value:
|
|
||||||
_LOGGER.debug(
|
|
||||||
"%s changed: '%s' -> '%s'",
|
|
||||||
key.replace("_", " ").capitalize(),
|
|
||||||
old_value,
|
|
||||||
value,
|
|
||||||
)
|
|
||||||
setattr(self, key, value)
|
|
||||||
return raw_status
|
|
||||||
|
|
||||||
async def async_move_track(self, _from: int, _to: int) -> None:
|
|
||||||
"""Move a track in the playlist."""
|
|
||||||
await self._async_command(params={"MOVEJOB": f"{_from};{_to}"})
|
|
||||||
|
|
||||||
async def async_pause(self) -> None:
|
|
||||||
"""Send pause command."""
|
|
||||||
await self._async_command(params={"CMDPAUSE": ""})
|
|
||||||
|
|
||||||
async def async_play(self) -> None:
|
|
||||||
"""Send play command."""
|
|
||||||
if self.status_code == 15:
|
|
||||||
await self.async_stop()
|
|
||||||
if self.track_id:
|
|
||||||
await self._async_command(params={"CMDPLAY": ""})
|
|
||||||
|
|
||||||
async def async_reboot(self) -> None:
|
|
||||||
"""Send reboot command."""
|
|
||||||
|
|
||||||
async def _no_response_needed(coro: Awaitable) -> None:
|
|
||||||
try:
|
|
||||||
await coro
|
|
||||||
except Exception as ex:
|
|
||||||
_LOGGER.error(ex)
|
|
||||||
|
|
||||||
reboot = self._async_command(params={"CMDBOOT": ""})
|
|
||||||
asyncio.create_task(_no_response_needed(reboot))
|
|
||||||
|
|
||||||
async def async_set_ball_speed(self, speed: int) -> None:
|
|
||||||
"""Set the Oasis Mini ball speed."""
|
|
||||||
if not BALL_SPEED_MIN <= speed <= BALL_SPEED_MAX:
|
|
||||||
raise ValueError("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 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.max_brightness:
|
|
||||||
raise ValueError("Invalid brightness specified")
|
|
||||||
|
|
||||||
await self._async_command(
|
|
||||||
params={"WRILED": f"{led_effect};0;{color};{led_speed};{brightness}"}
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_set_autoplay(self, option: bool | int | str) -> None:
|
|
||||||
"""Set autoplay."""
|
|
||||||
if isinstance(option, bool):
|
|
||||||
option = 0 if option else 1
|
|
||||||
if str(option) not in AUTOPLAY_MAP:
|
|
||||||
raise ValueError("Invalid pause option specified")
|
|
||||||
await self._async_command(params={"WRIWAITAFTER": option})
|
|
||||||
|
|
||||||
async def async_set_playlist(self, playlist: list[int] | int) -> None:
|
|
||||||
"""Set the playlist."""
|
|
||||||
if isinstance(playlist, int):
|
|
||||||
playlist = [playlist]
|
|
||||||
if is_playing := (self.status_code == 4):
|
|
||||||
await self.async_stop()
|
|
||||||
await self._async_command(params={"WRIJOBLIST": ",".join(map(str, playlist))})
|
|
||||||
self.playlist = playlist
|
|
||||||
if is_playing:
|
|
||||||
await self.async_play()
|
|
||||||
|
|
||||||
async def async_set_repeat_playlist(self, repeat: bool) -> None:
|
|
||||||
"""Set repeat playlist."""
|
|
||||||
await self._async_command(params={"WRIREPEATJOB": 1 if repeat else 0})
|
|
||||||
|
|
||||||
async def async_stop(self) -> None:
|
|
||||||
"""Send stop command."""
|
|
||||||
await self._async_command(params={"CMDSTOP": ""})
|
|
||||||
|
|
||||||
async def async_upgrade(self, beta: bool = False) -> None:
|
|
||||||
"""Trigger a software upgrade."""
|
|
||||||
await self._async_command(params={"CMDUPGRADE": 1 if beta else 0})
|
|
||||||
|
|
||||||
async def async_cloud_login(self, email: str, password: str) -> None:
|
|
||||||
"""Login via the cloud."""
|
|
||||||
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."""
|
|
||||||
await self._async_cloud_request("GET", "api/auth/logout")
|
|
||||||
|
|
||||||
async def async_cloud_get_track_info(self, track_id: int) -> dict[str, Any] | None:
|
|
||||||
"""Get cloud track info."""
|
|
||||||
try:
|
|
||||||
return await self._async_cloud_request("GET", f"api/track/{track_id}")
|
|
||||||
except ClientResponseError as err:
|
|
||||||
if err.status == 404:
|
|
||||||
return {"id": track_id, "name": f"Unknown Title (#{track_id})"}
|
|
||||||
except Exception as ex:
|
|
||||||
_LOGGER.exception(ex)
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def async_cloud_get_tracks(
|
|
||||||
self, tracks: list[int] | None = None
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""Get tracks info from the cloud"""
|
|
||||||
response = await self._async_cloud_request(
|
|
||||||
"GET", "api/track", params={"ids[]": tracks or []}
|
|
||||||
)
|
|
||||||
if not response:
|
|
||||||
return None
|
|
||||||
track_details = response.get("data", [])
|
|
||||||
while next_page_url := response.get("next_page_url"):
|
|
||||||
response = await self._async_cloud_request("GET", next_page_url)
|
|
||||||
track_details += response.get("data", [])
|
|
||||||
return track_details
|
|
||||||
|
|
||||||
async def async_cloud_get_latest_software_details(self) -> dict[str, int | str]:
|
|
||||||
"""Get the latest software details from the cloud."""
|
|
||||||
return await self._async_cloud_request("GET", "api/software/last-version")
|
|
||||||
|
|
||||||
async def async_get_current_track_details(self) -> dict | None:
|
|
||||||
"""Get current track info, refreshing if needed."""
|
|
||||||
track_id = self.track_id
|
|
||||||
if (track := self._track) and track.get("id") == track_id:
|
|
||||||
return track
|
|
||||||
if track_id:
|
|
||||||
self._track = await self.async_cloud_get_track_info(track_id)
|
|
||||||
if not self._track:
|
|
||||||
self._track = TRACKS.get(
|
|
||||||
track_id, {"id": track_id, "name": f"Unknown Title (#{track_id})"}
|
|
||||||
)
|
|
||||||
return self._track
|
|
||||||
|
|
||||||
async def async_get_playlist_details(self) -> dict[int, dict[str, str]]:
|
|
||||||
"""Get playlist info."""
|
|
||||||
if set(self.playlist).difference(self._playlist.keys()):
|
|
||||||
tracks = await self.async_cloud_get_tracks(self.playlist)
|
|
||||||
all_tracks = TRACKS | {
|
|
||||||
track["id"]: {
|
|
||||||
"name": track["name"],
|
|
||||||
"author": ((track.get("author") or {}).get("person") or {}).get(
|
|
||||||
"name", "Oasis Mini"
|
|
||||||
),
|
|
||||||
"image": track["image"],
|
|
||||||
}
|
|
||||||
for track in tracks
|
|
||||||
}
|
|
||||||
for track in self.playlist:
|
|
||||||
self._playlist[track] = all_tracks.get(
|
|
||||||
track, {"name": f"Unknown Title (#{track})"}
|
|
||||||
)
|
|
||||||
return self._playlist
|
|
||||||
|
|
||||||
async def _async_cloud_request(self, method: str, url: str, **kwargs: Any) -> Any:
|
|
||||||
"""Perform a cloud request."""
|
|
||||||
if not self.access_token:
|
|
||||||
return
|
|
||||||
|
|
||||||
return await self._async_request(
|
|
||||||
method,
|
|
||||||
urljoin(CLOUD_BASE_URL, url),
|
|
||||||
headers={"Authorization": f"Bearer {self.access_token}"},
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _async_command(self, **kwargs: Any) -> str | None:
|
|
||||||
"""Send a command to the device."""
|
|
||||||
result = await self._async_get(**kwargs)
|
|
||||||
_LOGGER.debug("Result: %s", result)
|
|
||||||
|
|
||||||
async def _async_get(self, **kwargs: Any) -> str | None:
|
|
||||||
"""Perform a GET request."""
|
|
||||||
return await self._async_request("GET", self.url, **kwargs)
|
|
||||||
|
|
||||||
async def _async_request(self, method: str, url: str, **kwargs) -> Any:
|
|
||||||
"""Perform a request."""
|
|
||||||
_LOGGER.debug(
|
|
||||||
"%s %s",
|
|
||||||
method,
|
|
||||||
self._session._build_url(url).update_query( # pylint: disable=protected-access
|
|
||||||
kwargs.get("params")
|
|
||||||
),
|
|
||||||
)
|
|
||||||
response = await self._session.request(method, url, **kwargs)
|
|
||||||
if response.status == 200:
|
|
||||||
if response.content_type == "application/json":
|
|
||||||
return await response.json()
|
|
||||||
if response.content_type == "text/plain":
|
|
||||||
return await response.text()
|
|
||||||
return None
|
|
||||||
response.raise_for_status()
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
"""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 = {}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
|||||||
"""Oasis Mini select entity."""
|
"""Oasis device select entity."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Awaitable, Callable
|
from typing import Any, Awaitable, Callable
|
||||||
|
|
||||||
@@ -10,41 +11,130 @@ from homeassistant.core import HomeAssistant, callback
|
|||||||
from homeassistant.helpers.entity import EntityDescription
|
from homeassistant.helpers.entity import EntityDescription
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from . import OasisMiniConfigEntry
|
from . import OasisDeviceConfigEntry
|
||||||
from .coordinator import OasisMiniCoordinator
|
from .coordinator import OasisDeviceCoordinator
|
||||||
from .entity import OasisMiniEntity
|
from .entity import OasisDeviceEntity
|
||||||
from .pyoasismini import AUTOPLAY_MAP, OasisMini
|
from .pyoasiscontrol import OasisDevice
|
||||||
from .pyoasismini.const import TRACKS
|
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._cloud.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
|
||||||
|
async_add_entities(
|
||||||
|
OasisDeviceSelectEntity(coordinator, device, descriptor)
|
||||||
|
for device in coordinator.data
|
||||||
|
for descriptor in DESCRIPTORS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class OasisMiniSelectEntityDescription(SelectEntityDescription):
|
class OasisDeviceSelectEntityDescription(SelectEntityDescription):
|
||||||
"""Oasis Mini select entity description."""
|
"""Oasis device select entity description."""
|
||||||
|
|
||||||
current_value: Callable[[OasisMini], Any]
|
current_value: Callable[[OasisDevice], Any]
|
||||||
select_fn: Callable[[OasisMini, int], Awaitable[None]]
|
select_fn: Callable[[OasisDevice, int], Awaitable[None]]
|
||||||
update_handler: Callable[[OasisMiniSelectEntity], None] | None = None
|
update_handler: Callable[[OasisDeviceSelectEntity], None] | None = None
|
||||||
|
|
||||||
|
|
||||||
class OasisMiniSelectEntity(OasisMiniEntity, SelectEntity):
|
DESCRIPTORS = (
|
||||||
"""Oasis Mini select entity."""
|
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="playlists",
|
||||||
|
translation_key="playlist",
|
||||||
|
current_value=lambda device: (device._cloud.playlists, device.playlist.copy()),
|
||||||
|
select_fn=lambda device, index: device.async_set_playlist(
|
||||||
|
[pattern["id"] for pattern in device._cloud.playlists[index]["patterns"]]
|
||||||
|
),
|
||||||
|
update_handler=playlists_update_handler,
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
entity_description: OasisMiniSelectEntityDescription
|
|
||||||
|
class OasisDeviceSelectEntity(OasisDeviceEntity, SelectEntity):
|
||||||
|
"""Oasis device select entity."""
|
||||||
|
|
||||||
|
entity_description: OasisDeviceSelectEntityDescription
|
||||||
_current_value: Any | None = None
|
_current_value: Any | None = None
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
coordinator: OasisMiniCoordinator,
|
coordinator: OasisDeviceCoordinator,
|
||||||
|
device: OasisDevice,
|
||||||
description: EntityDescription,
|
description: EntityDescription,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Construct an Oasis Mini select entity."""
|
"""Construct an Oasis device select entity."""
|
||||||
super().__init__(coordinator, description)
|
super().__init__(coordinator, device, description)
|
||||||
self._handle_coordinator_update()
|
self._handle_coordinator_update()
|
||||||
|
|
||||||
async def async_select_option(self, option: str) -> None:
|
async def async_select_option(self, option: str) -> None:
|
||||||
"""Change the selected option."""
|
"""Change the selected option."""
|
||||||
await self.entity_description.select_fn(self.device, self.options.index(option))
|
await self.entity_description.select_fn(self.device, self.options.index(option))
|
||||||
await self.coordinator.async_request_refresh()
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _handle_coordinator_update(self) -> None:
|
def _handle_coordinator_update(self) -> None:
|
||||||
@@ -56,61 +146,8 @@ class OasisMiniSelectEntity(OasisMiniEntity, SelectEntity):
|
|||||||
if update_handler := self.entity_description.update_handler:
|
if update_handler := self.entity_description.update_handler:
|
||||||
update_handler(self)
|
update_handler(self)
|
||||||
else:
|
else:
|
||||||
self._attr_current_option = getattr(
|
self._attr_current_option = str(
|
||||||
self.device, self.entity_description.key
|
getattr(self.device, self.entity_description.key)
|
||||||
)
|
)
|
||||||
if self.hass:
|
if self.hass:
|
||||||
return super()._handle_coordinator_update()
|
return super()._handle_coordinator_update()
|
||||||
|
|
||||||
|
|
||||||
def playlist_update_handler(entity: OasisMiniSelectEntity) -> None:
|
|
||||||
"""Handle playlist updates."""
|
|
||||||
# pylint: disable=protected-access
|
|
||||||
device = entity.device
|
|
||||||
options = [
|
|
||||||
device._playlist.get(track, {}).get(
|
|
||||||
"name",
|
|
||||||
TRACKS.get(track, {"id": track, "name": f"Unknown Title (#{track})"}).get(
|
|
||||||
"name",
|
|
||||||
device.track["name"]
|
|
||||||
if device.track and device.track["id"] == track
|
|
||||||
else str(track),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
for track in device.playlist
|
|
||||||
]
|
|
||||||
entity._attr_options = options
|
|
||||||
index = min(device.playlist_index, len(options) - 1)
|
|
||||||
entity._attr_current_option = options[index] if options else None
|
|
||||||
|
|
||||||
|
|
||||||
DESCRIPTORS = (
|
|
||||||
OasisMiniSelectEntityDescription(
|
|
||||||
key="autoplay",
|
|
||||||
translation_key="autoplay",
|
|
||||||
options=list(AUTOPLAY_MAP.values()),
|
|
||||||
current_value=lambda device: device.autoplay,
|
|
||||||
select_fn=lambda device, option: device.async_set_autoplay(option),
|
|
||||||
),
|
|
||||||
OasisMiniSelectEntityDescription(
|
|
||||||
key="playlist",
|
|
||||||
translation_key="playlist",
|
|
||||||
current_value=lambda device: (device.playlist.copy(), device.playlist_index),
|
|
||||||
select_fn=lambda device, option: device.async_change_track(option),
|
|
||||||
update_handler=playlist_update_handler,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
entry: OasisMiniConfigEntry,
|
|
||||||
async_add_entities: AddEntitiesCallback,
|
|
||||||
) -> None:
|
|
||||||
"""Set up Oasis Mini select using config entry."""
|
|
||||||
async_add_entities(
|
|
||||||
[
|
|
||||||
OasisMiniSelectEntity(entry.runtime_data, descriptor)
|
|
||||||
for descriptor in DESCRIPTORS
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Oasis Mini sensor entity."""
|
"""Oasis device sensor entity."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -11,29 +11,23 @@ from homeassistant.const import PERCENTAGE, EntityCategory
|
|||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from . import OasisMiniConfigEntry
|
from . import OasisDeviceConfigEntry
|
||||||
from .coordinator import OasisMiniCoordinator
|
from .coordinator import OasisDeviceCoordinator
|
||||||
from .entity import OasisMiniEntity
|
from .entity import OasisDeviceEntity
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entry: OasisMiniConfigEntry,
|
entry: OasisDeviceConfigEntry,
|
||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up Oasis Mini sensors using config entry."""
|
"""Set up Oasis device sensors using config entry."""
|
||||||
coordinator: OasisMiniCoordinator = entry.runtime_data
|
coordinator: OasisDeviceCoordinator = entry.runtime_data
|
||||||
entities = [
|
async_add_entities(
|
||||||
OasisMiniSensorEntity(coordinator, descriptor) for descriptor in DESCRIPTORS
|
OasisDeviceSensorEntity(coordinator, device, descriptor)
|
||||||
]
|
for device in coordinator.data
|
||||||
if coordinator.device.access_token:
|
for descriptor in DESCRIPTORS
|
||||||
entities.extend(
|
|
||||||
[
|
|
||||||
OasisMiniSensorEntity(coordinator, descriptor)
|
|
||||||
for descriptor in CLOUD_DESCRIPTORS
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
async_add_entities(entities)
|
|
||||||
|
|
||||||
|
|
||||||
DESCRIPTORS = {
|
DESCRIPTORS = {
|
||||||
@@ -45,17 +39,6 @@ DESCRIPTORS = {
|
|||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
),
|
),
|
||||||
} | {
|
|
||||||
SensorEntityDescription(
|
|
||||||
key=key,
|
|
||||||
translation_key=key,
|
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
entity_registry_enabled_default=False,
|
|
||||||
)
|
|
||||||
for key in ("error", "led_color_id", "status")
|
|
||||||
}
|
|
||||||
|
|
||||||
CLOUD_DESCRIPTORS = (
|
|
||||||
SensorEntityDescription(
|
SensorEntityDescription(
|
||||||
key="drawing_progress",
|
key="drawing_progress",
|
||||||
translation_key="drawing_progress",
|
translation_key="drawing_progress",
|
||||||
@@ -64,11 +47,20 @@ CLOUD_DESCRIPTORS = (
|
|||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
suggested_display_precision=1,
|
suggested_display_precision=1,
|
||||||
),
|
),
|
||||||
)
|
} | {
|
||||||
|
SensorEntityDescription(
|
||||||
|
key=key,
|
||||||
|
translation_key=key,
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
)
|
||||||
|
for key in ("error", "led_color_id", "status")
|
||||||
|
# for key in ("error_message", "led_color_id", "status")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class OasisMiniSensorEntity(OasisMiniEntity, SensorEntity):
|
class OasisDeviceSensorEntity(OasisDeviceEntity, SensorEntity):
|
||||||
"""Oasis Mini sensor entity."""
|
"""Oasis device sensor entity."""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_value(self) -> str | None:
|
def native_value(self) -> str | None:
|
||||||
|
|||||||
@@ -3,24 +3,29 @@
|
|||||||
"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%]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"reconfigure": {
|
"reconfigure": {
|
||||||
"data": {
|
"data": {
|
||||||
"host": "[%key:common::config_flow::data::host%]"
|
"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%]",
|
||||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
"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": {
|
||||||
@@ -41,6 +46,9 @@
|
|||||||
"button": {
|
"button": {
|
||||||
"random_track": {
|
"random_track": {
|
||||||
"name": "Play random track"
|
"name": "Play random track"
|
||||||
|
},
|
||||||
|
"sleep": {
|
||||||
|
"name": "Sleep"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binary_sensor": {
|
"binary_sensor": {
|
||||||
@@ -66,10 +74,24 @@
|
|||||||
},
|
},
|
||||||
"select": {
|
"select": {
|
||||||
"autoplay": {
|
"autoplay": {
|
||||||
"name": "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": {
|
"playlist": {
|
||||||
"name": "Playlist"
|
"name": "Playlist"
|
||||||
|
},
|
||||||
|
"queue": {
|
||||||
|
"name": "Queue"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sensor": {
|
"sensor": {
|
||||||
@@ -80,7 +102,28 @@
|
|||||||
"name": "Drawing progress"
|
"name": "Drawing progress"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"name": "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": {
|
"led_color_id": {
|
||||||
"name": "LED color ID"
|
"name": "LED color ID"
|
||||||
@@ -93,9 +136,11 @@
|
|||||||
"centering": "Centering",
|
"centering": "Centering",
|
||||||
"playing": "Playing",
|
"playing": "Playing",
|
||||||
"paused": "Paused",
|
"paused": "Paused",
|
||||||
|
"sleeping": "Sleeping",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"updating": "Updating",
|
"updating": "Updating",
|
||||||
"downloading": "Downloading",
|
"downloading": "Downloading",
|
||||||
|
"busy": "Busy",
|
||||||
"live": "Live drawing"
|
"live": "Live drawing"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,24 +3,29 @@
|
|||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"data": {
|
"data": {
|
||||||
"host": "Host"
|
"email": "Email",
|
||||||
|
"password": "Password"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"reconfigure": {
|
"reconfigure": {
|
||||||
"data": {
|
"data": {
|
||||||
"host": "Host"
|
"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",
|
||||||
"reconfigure_successful": "Re-configuration was successful"
|
"reauth_successful": "Re-authentication was successful",
|
||||||
|
"reconfigure_successful": "Re-configuration was successful",
|
||||||
|
"wrong_account": "Account used for the integration should not change"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
@@ -41,6 +46,9 @@
|
|||||||
"button": {
|
"button": {
|
||||||
"random_track": {
|
"random_track": {
|
||||||
"name": "Play random track"
|
"name": "Play random track"
|
||||||
|
},
|
||||||
|
"sleep": {
|
||||||
|
"name": "Sleep"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binary_sensor": {
|
"binary_sensor": {
|
||||||
@@ -66,10 +74,24 @@
|
|||||||
},
|
},
|
||||||
"select": {
|
"select": {
|
||||||
"autoplay": {
|
"autoplay": {
|
||||||
"name": "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": {
|
"playlist": {
|
||||||
"name": "Playlist"
|
"name": "Playlist"
|
||||||
|
},
|
||||||
|
"queue": {
|
||||||
|
"name": "Queue"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sensor": {
|
"sensor": {
|
||||||
@@ -80,7 +102,28 @@
|
|||||||
"name": "Drawing progress"
|
"name": "Drawing progress"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"name": "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": {
|
"led_color_id": {
|
||||||
"name": "LED color ID"
|
"name": "LED color ID"
|
||||||
@@ -93,9 +136,11 @@
|
|||||||
"centering": "Centering",
|
"centering": "Centering",
|
||||||
"playing": "Playing",
|
"playing": "Playing",
|
||||||
"paused": "Paused",
|
"paused": "Paused",
|
||||||
|
"sleeping": "Sleeping",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"updating": "Updating",
|
"updating": "Updating",
|
||||||
"downloading": "Downloading",
|
"downloading": "Downloading",
|
||||||
|
"busy": "Busy",
|
||||||
"live": "Live drawing"
|
"live": "Live drawing"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Oasis Mini update entity."""
|
"""Oasis device update entity."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -15,9 +15,9 @@ from homeassistant.components.update import (
|
|||||||
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 . import OasisMiniConfigEntry
|
from . import OasisDeviceConfigEntry
|
||||||
from .coordinator import OasisMiniCoordinator
|
from .coordinator import OasisDeviceCoordinator
|
||||||
from .entity import OasisMiniEntity
|
from .entity import OasisDeviceEntity
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -26,13 +26,16 @@ SCAN_INTERVAL = timedelta(hours=6)
|
|||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entry: OasisMiniConfigEntry,
|
entry: OasisDeviceConfigEntry,
|
||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up Oasis Mini updates using config entry."""
|
"""Set up Oasis device updates using config entry."""
|
||||||
coordinator: OasisMiniCoordinator = entry.runtime_data
|
coordinator: OasisDeviceCoordinator = entry.runtime_data
|
||||||
if coordinator.device.access_token:
|
entities = (
|
||||||
async_add_entities([OasisMiniUpdateEntity(coordinator, DESCRIPTOR)], True)
|
OasisDeviceUpdateEntity(coordinator, device, DESCRIPTOR)
|
||||||
|
for device in coordinator.data
|
||||||
|
)
|
||||||
|
async_add_entities(entities, True)
|
||||||
|
|
||||||
|
|
||||||
DESCRIPTOR = UpdateEntityDescription(
|
DESCRIPTOR = UpdateEntityDescription(
|
||||||
@@ -40,8 +43,8 @@ DESCRIPTOR = UpdateEntityDescription(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class OasisMiniUpdateEntity(OasisMiniEntity, UpdateEntity):
|
class OasisDeviceUpdateEntity(OasisDeviceEntity, UpdateEntity):
|
||||||
"""Oasis Mini update entity."""
|
"""Oasis device update entity."""
|
||||||
|
|
||||||
_attr_supported_features = (
|
_attr_supported_features = (
|
||||||
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
|
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
|
||||||
@@ -68,16 +71,14 @@ class OasisMiniUpdateEntity(OasisMiniEntity, UpdateEntity):
|
|||||||
self, version: str | None, backup: bool, **kwargs: Any
|
self, version: str | None, backup: bool, **kwargs: Any
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Install an update."""
|
"""Install an update."""
|
||||||
version = await self.device.async_get_software_version()
|
if self.latest_version == self.device.software_version:
|
||||||
if version == self.latest_version:
|
|
||||||
return
|
return
|
||||||
await self.device.async_upgrade()
|
await self.device.async_upgrade()
|
||||||
|
|
||||||
async def async_update(self) -> None:
|
async def async_update(self) -> None:
|
||||||
"""Update the entity."""
|
"""Update the entity."""
|
||||||
await self.device.async_get_software_version()
|
client = self.coordinator.cloud_client
|
||||||
software = await self.device.async_cloud_get_latest_software_details()
|
if not (software := await client.async_get_latest_software_details()):
|
||||||
if not software:
|
|
||||||
_LOGGER.warning("Unable to get latest software details")
|
_LOGGER.warning("Unable to get latest software details")
|
||||||
return
|
return
|
||||||
self._attr_latest_version = software["version"]
|
self._attr_latest_version = software["version"]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "Oasis Mini",
|
"name": "Oasis Control",
|
||||||
"homeassistant": "2024.5.0",
|
"homeassistant": "2024.5.0",
|
||||||
"render_readme": true,
|
"render_readme": true,
|
||||||
"zip_release": true,
|
"zip_release": true,
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ PyTurboJPEG
|
|||||||
|
|
||||||
# Integration
|
# Integration
|
||||||
aiohttp # should already be installed with Home Assistant
|
aiohttp # should already be installed with Home Assistant
|
||||||
|
aiomqtt # asyncio MQTT client
|
||||||
cryptography # should already be installed with Home Assistant
|
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
|
||||||
@@ -7,18 +7,24 @@ import json
|
|||||||
import os
|
import os
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from custom_components.oasis_mini.pyoasismini import OasisMini
|
from custom_components.oasis_mini.pyoasiscontrol import OasisCloudClient
|
||||||
from custom_components.oasis_mini.pyoasismini.const import TRACKS
|
from custom_components.oasis_mini.pyoasiscontrol.const import TRACKS
|
||||||
|
|
||||||
ACCESS_TOKEN = os.getenv("GROUNDED_TOKEN")
|
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:
|
async def update_tracks() -> None:
|
||||||
"""Update tracks."""
|
"""Update tracks."""
|
||||||
client = OasisMini("", ACCESS_TOKEN)
|
client = OasisCloudClient(access_token=ACCESS_TOKEN)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = await client.async_cloud_get_tracks()
|
data = await client.async_get_tracks()
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
print(type(ex).__name__, ex)
|
print(type(ex).__name__, ex)
|
||||||
await client.session.close()
|
await client.session.close()
|
||||||
@@ -32,23 +38,22 @@ async def update_tracks() -> None:
|
|||||||
for result in filter(lambda d: d["public"], data):
|
for result in filter(lambda d: d["public"], data):
|
||||||
if (
|
if (
|
||||||
(track_id := result["id"]) not in TRACKS
|
(track_id := result["id"]) not in TRACKS
|
||||||
or result["name"] != TRACKS[track_id].get("name")
|
or any(
|
||||||
or result["image"] != TRACKS[track_id].get("image")
|
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"]}")
|
print(f"Updating track {track_id}: {result['name']}")
|
||||||
track_info = await client.async_cloud_get_track_info(int(track_id))
|
track_info = await client.async_get_track_info(int(track_id))
|
||||||
if not track_info:
|
if not track_info:
|
||||||
print("No track info")
|
print("No track info")
|
||||||
break
|
break
|
||||||
author = (result.get("author") or {}).get("user") or {}
|
result["author"] = get_author_name(result)
|
||||||
updated_tracks[track_id] = {
|
result["reduced_svg_content_new"] = track_info.get(
|
||||||
"id": track_id,
|
"reduced_svg_content_new"
|
||||||
"name": result["name"],
|
)
|
||||||
"author": author.get("name") or author.get("nickname") or "Oasis Mini",
|
updated_tracks[track_id] = result
|
||||||
"image": result["image"],
|
|
||||||
"clean_pattern": track_info.get("cleanPattern", {}).get("id"),
|
|
||||||
"reduced_svg_content": track_info.get("reduced_svg_content"),
|
|
||||||
}
|
|
||||||
await client.session.close()
|
await client.session.close()
|
||||||
|
|
||||||
if not updated_tracks:
|
if not updated_tracks:
|
||||||
@@ -60,7 +65,7 @@ async def update_tracks() -> None:
|
|||||||
tracks = dict(sorted(tracks.items(), key=lambda t: t[1]["name"].lower()))
|
tracks = dict(sorted(tracks.items(), key=lambda t: t[1]["name"].lower()))
|
||||||
|
|
||||||
with open(
|
with open(
|
||||||
"custom_components/oasis_mini/pyoasismini/tracks.json", "w", encoding="utf8"
|
"custom_components/oasis_mini/pyoasiscontrol/tracks.json", "w", encoding="utf8"
|
||||||
) as file:
|
) as file:
|
||||||
json.dump(tracks, file, indent=2, ensure_ascii=False)
|
json.dump(tracks, file, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user