mirror of
https://github.com/natekspencer/hacs-oasis_mini.git
synced 2025-12-06 10:34:13 -05:00
Compare commits
114 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b32199c334 | ||
|
|
5c49119ae5 | ||
|
|
fbb3012379 | ||
|
|
ac005c70c2 | ||
|
|
873d2d4bb0 | ||
|
|
04be6626a7 | ||
|
|
14223bd1c9 | ||
|
|
1d521bcc18 | ||
|
|
2994e73187 | ||
|
|
e4f6cd2803 | ||
|
|
1cc3585653 | ||
|
|
2f28f7c4bd | ||
|
|
81668c595a | ||
|
|
c17d1682d0 | ||
|
|
f0669c7f63 | ||
|
|
8abfc047f9 | ||
|
|
0df118d18d | ||
|
|
0ebab392fb | ||
|
|
a15548e387 | ||
|
|
b459e3eb9d | ||
|
|
a6ecd740be | ||
|
|
aa7abc2174 | ||
|
|
a548ac5fe2 | ||
|
|
ce22238ae6 | ||
|
|
4ef28fc741 | ||
|
|
cf21a5d995 | ||
|
|
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 | ||
|
|
f850158a8e | ||
|
|
8bb8cf9447 | ||
|
|
1c8b2f052c | ||
|
|
73f96d8302 | ||
|
|
9cc1d6d314 | ||
|
|
4894e3549d | ||
|
|
221f314dd6 | ||
|
|
595621652a | ||
|
|
42040895e2 | ||
|
|
51c4c8a6a2 | ||
|
|
ddabccc4a8 | ||
|
|
94860106ea | ||
|
|
c4dd4f0499 | ||
|
|
2a5043298e | ||
|
|
8ee4076e8b | ||
|
|
09f4026480 |
@@ -1,8 +1,8 @@
|
||||
// See https://aka.ms/vscode-remote/devcontainer.json for format details.
|
||||
{
|
||||
"name": "Home Assistant integration development",
|
||||
"image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye",
|
||||
"postCreateCommand": "sudo apt-get update && sudo apt-get install libturbojpeg0",
|
||||
"image": "mcr.microsoft.com/devcontainers/python:1-3.13-bookworm",
|
||||
"postCreateCommand": "scripts/setup",
|
||||
"postAttachCommand": "scripts/setup",
|
||||
"forwardPorts": [8123],
|
||||
"customizations": {
|
||||
@@ -26,7 +26,10 @@
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": "always"
|
||||
},
|
||||
"files.trimTrailingWhitespace": true
|
||||
"files.trimTrailingWhitespace": true,
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "charliermarsh.ruff"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
14
.github/workflows/update-tracks.yml
vendored
14
.github/workflows/update-tracks.yml
vendored
@@ -1,10 +1,14 @@
|
||||
name: Update tracks
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 19 * * *"
|
||||
- cron: "0 19 * * 1"
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
tracks:
|
||||
name: Search and update new tracks
|
||||
@@ -12,16 +16,20 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout the repo
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Python 3.12
|
||||
|
||||
- name: Set up Python 3.13
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install homeassistant
|
||||
|
||||
- name: Update tracks
|
||||
env:
|
||||
GROUNDED_TOKEN: ${{ secrets.GROUNDED_TOKEN }}
|
||||
run: python update_tracks.py
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
with:
|
||||
|
||||
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.14.6
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
# Run the formatter.
|
||||
- id: ruff-format
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Nathan Spencer
|
||||
Copyright (c) Nathan Spencer
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
24
README.md
24
README.md
@@ -10,9 +10,9 @@
|
||||
<img alt="Oasis Mini logo" src="https://brands.home-assistant.io/oasis_mini/logo.png">
|
||||
</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
|
||||
|
||||
@@ -43,25 +43,39 @@ 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)
|
||||
|
||||
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:
|
||||
|
||||
1. Go to **Configuration**->**Integrations**
|
||||
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
|
||||
|
||||
# Options
|
||||
|
||||
After this integration is set up, you can configure the integration to connect to the Kinetic Oasis cloud API. This will allow pulling in certain details (such as track name and image) that are otherwise not available.
|
||||
|
||||
# Actions
|
||||
|
||||
The media player entity supports various actions, including managing the playlist queue. You can specify a track by its ID or name. If using a track name, it must match an entry in the [tracks list](custom_components/oasis_mini/pyoasiscontrol/tracks.json). To specify multiple tracks, separate them with commas. An example is below:
|
||||
|
||||
```yaml
|
||||
action: media_player.play_media
|
||||
target:
|
||||
entity_id: media_player.oasis_mini
|
||||
data:
|
||||
media_content_id: 63, Turtle
|
||||
media_content_type: track
|
||||
enqueue: replace
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Support Me
|
||||
|
||||
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>
|
||||
|
||||
@@ -4,6 +4,7 @@ automation:
|
||||
dhcp:
|
||||
frontend:
|
||||
history:
|
||||
isal:
|
||||
logbook:
|
||||
media_source:
|
||||
|
||||
|
||||
283
custom_components/oasis_mini/__init__.py
Executable file → Normal file
283
custom_components/oasis_mini/__init__.py
Executable file → Normal file
@@ -1,24 +1,31 @@
|
||||
"""Support for Oasis Mini."""
|
||||
"""Support for Oasis devices."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Callable, Iterable
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
|
||||
import homeassistant.helpers.device_registry as dr
|
||||
from homeassistant.const import CONF_EMAIL, Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
import homeassistant.helpers.entity_registry as er
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import OasisMiniCoordinator
|
||||
from .coordinator import OasisDeviceCoordinator
|
||||
from .entity import OasisDeviceEntity
|
||||
from .helpers import create_client
|
||||
from .pyoasiscontrol import OasisDevice, OasisMqttClient, UnauthenticatedError
|
||||
|
||||
type OasisMiniConfigEntry = ConfigEntry[OasisMiniCoordinator]
|
||||
type OasisDeviceConfigEntry = ConfigEntry[OasisDeviceCoordinator]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.IMAGE,
|
||||
Platform.LIGHT,
|
||||
@@ -26,65 +33,249 @@ PLATFORMS = [
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
# Platform.SWITCH,
|
||||
Platform.SWITCH,
|
||||
Platform.UPDATE,
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: OasisMiniConfigEntry) -> bool:
|
||||
"""Set up Oasis Mini from a config entry."""
|
||||
client = create_client(entry.data | entry.options)
|
||||
coordinator = OasisMiniCoordinator(hass, client)
|
||||
def setup_platform_from_coordinator(
|
||||
entry: OasisDeviceConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
make_entities: Callable[[Iterable[OasisDevice]], Iterable[OasisDeviceEntity]],
|
||||
update_before_add: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Populate entities for devices managed by the coordinator and add entities for any devices discovered later.
|
||||
|
||||
This registers a listener on the coordinator to detect newly discovered devices by serial number and calls `make_entities` to construct entity objects for those devices, passing them to `async_add_entities`. The initial device set is processed immediately; subsequent discoveries are handled via the coordinator listener.
|
||||
|
||||
Parameters:
|
||||
entry: Config entry containing the coordinator in its `runtime_data`.
|
||||
async_add_entities: Home Assistant callback to add entities to the platform.
|
||||
make_entities: Callable that accepts an iterable of `OasisDevice` objects and returns an iterable of `OasisDeviceEntity` instances to add.
|
||||
update_before_add: If true, entities will be updated before being added.
|
||||
"""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
known_serials: set[str] = set()
|
||||
|
||||
@callback
|
||||
def _check_devices() -> None:
|
||||
"""
|
||||
Detect newly discovered Oasis devices from the coordinator and register their entities.
|
||||
|
||||
Scans the coordinator's current device list for devices with a serial number that has not
|
||||
been seen before. For any newly discovered devices, creates entity instances via
|
||||
make_entities and adds them to Home Assistant using async_add_entities with the
|
||||
update_before_add flag. Does not return a value.
|
||||
"""
|
||||
devices = coordinator.data or []
|
||||
new_devices: list[OasisDevice] = []
|
||||
|
||||
for device in devices:
|
||||
serial = device.serial_number
|
||||
if not serial or serial in known_serials:
|
||||
continue
|
||||
|
||||
known_serials.add(serial)
|
||||
new_devices.append(device)
|
||||
|
||||
if not new_devices:
|
||||
return
|
||||
|
||||
if entities := make_entities(new_devices):
|
||||
async_add_entities(entities, update_before_add)
|
||||
|
||||
# Initial population
|
||||
_check_devices()
|
||||
# Future updates (new devices discovered)
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_devices))
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: OasisDeviceConfigEntry) -> bool:
|
||||
"""
|
||||
Initialize Oasis cloud and MQTT integration for a config entry, create and refresh the device coordinator, register update listeners for discovered devices, forward platform setup, and update the entry's metadata as needed.
|
||||
|
||||
Returns:
|
||||
True if the config entry was set up successfully.
|
||||
"""
|
||||
cloud_client = create_client(hass, entry.data)
|
||||
try:
|
||||
user = await cloud_client.async_get_user()
|
||||
except UnauthenticatedError as err:
|
||||
await cloud_client.async_close()
|
||||
raise ConfigEntryAuthFailed(err) from err
|
||||
except Exception:
|
||||
await cloud_client.async_close()
|
||||
raise
|
||||
|
||||
mqtt_client = OasisMqttClient()
|
||||
coordinator = OasisDeviceCoordinator(hass, entry, cloud_client, mqtt_client)
|
||||
|
||||
try:
|
||||
mqtt_client.start()
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
except Exception as ex:
|
||||
_LOGGER.exception(ex)
|
||||
except Exception:
|
||||
await mqtt_client.async_close()
|
||||
await cloud_client.async_close()
|
||||
raise
|
||||
|
||||
if not entry.unique_id:
|
||||
if not (serial_number := coordinator.device.serial_number):
|
||||
dev_reg = dr.async_get(hass)
|
||||
devices = dr.async_entries_for_config_entry(dev_reg, entry.entry_id)
|
||||
serial_number = next(
|
||||
(
|
||||
identifier[1]
|
||||
for identifier in devices[0].identifiers
|
||||
if identifier[0] == DOMAIN
|
||||
),
|
||||
None,
|
||||
)
|
||||
hass.config_entries.async_update_entry(entry, unique_id=serial_number)
|
||||
if entry.unique_id != (user_id := str(user["id"])):
|
||||
hass.config_entries.async_update_entry(entry, unique_id=user_id)
|
||||
|
||||
if not coordinator.data:
|
||||
await client.session.close()
|
||||
raise ConfigEntryNotReady
|
||||
|
||||
if entry.unique_id != coordinator.device.serial_number:
|
||||
await client.session.close()
|
||||
raise ConfigEntryError("Serial number mismatch")
|
||||
_LOGGER.warning("No devices associated with account")
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
def _on_oasis_update() -> None:
|
||||
"""
|
||||
Update the coordinator's last-updated timestamp and notify its listeners.
|
||||
|
||||
Sets the coordinator's last_updated to the current time and triggers its update listeners so dependent entities and tasks refresh.
|
||||
"""
|
||||
coordinator.last_updated = dt_util.now()
|
||||
coordinator.async_update_listeners()
|
||||
|
||||
for device in coordinator.data or []:
|
||||
device.add_update_listener(_on_oasis_update)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(update_listener))
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: OasisMiniConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
await entry.runtime_data.device.session.close()
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: OasisDeviceConfigEntry
|
||||
) -> bool:
|
||||
"""
|
||||
Cleanly unload an Oasis device config entry.
|
||||
|
||||
Closes the MQTT and cloud clients stored on the entry and unloads all supported platforms.
|
||||
|
||||
Returns:
|
||||
`True` if all platforms were unloaded successfully, `False` otherwise.
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
async def async_remove_entry(hass: HomeAssistant, entry: OasisMiniConfigEntry) -> None:
|
||||
"""Handle removal of an entry."""
|
||||
if entry.options:
|
||||
client = create_client(entry.data | entry.options)
|
||||
await client.async_cloud_logout()
|
||||
await client.session.close()
|
||||
async def async_remove_entry(
|
||||
hass: HomeAssistant, entry: OasisDeviceConfigEntry
|
||||
) -> None:
|
||||
"""
|
||||
Perform logout and cleanup for the cloud client associated with the config entry.
|
||||
|
||||
Attempts to call the cloud client's logout method and logs any exception encountered, then ensures the client is closed.
|
||||
"""
|
||||
cloud_client = create_client(hass, entry.data)
|
||||
try:
|
||||
await cloud_client.async_logout()
|
||||
except Exception:
|
||||
_LOGGER.exception("Error attempting to logout from the cloud")
|
||||
await cloud_client.async_close()
|
||||
|
||||
|
||||
async def update_listener(hass: HomeAssistant, entry: OasisMiniConfigEntry) -> None:
|
||||
"""Handle options update."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
async def async_migrate_entry(
|
||||
hass: HomeAssistant, entry: OasisDeviceConfigEntry
|
||||
) -> bool:
|
||||
"""
|
||||
Migrate an Oasis config entry to the current schema (minor version 3).
|
||||
|
||||
Performs in-place migrations for older entries:
|
||||
- Renames select entity unique IDs ending with `-playlist` to `-queue`.
|
||||
- When migrating to the auth-required schema, moves relevant options into entry data and clears options.
|
||||
- Updates the config entry's data, options, minor_version, title (from CONF_EMAIL or "Oasis Control"), unique_id, and version.
|
||||
|
||||
Parameters:
|
||||
entry: The config entry to migrate.
|
||||
|
||||
Returns:
|
||||
`True` if migration succeeded, `False` if migration could not be performed (e.g., entry.version is greater than supported).
|
||||
"""
|
||||
_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:
|
||||
"""
|
||||
Update a registry entry's unique_id suffix from "-playlist" to "-queue" when applicable.
|
||||
|
||||
Parameters:
|
||||
entity_entry (er.RegistryEntry): Registry entry to inspect.
|
||||
|
||||
Returns:
|
||||
dict[str, Any] | None: A mapping {"new_unique_id": <new id>} if the entry is in the "select" domain and its unique_id ends with "-playlist"; otherwise `None`.
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
async def async_remove_config_entry_device(
|
||||
hass: HomeAssistant, # noqa: ARG001
|
||||
config_entry: OasisDeviceConfigEntry,
|
||||
device_entry: DeviceEntry,
|
||||
) -> bool:
|
||||
"""
|
||||
Determine whether the config entry is no longer associated with the given device.
|
||||
|
||||
Parameters:
|
||||
config_entry (OasisDeviceConfigEntry): The config entry whose runtime data contains device serial numbers.
|
||||
device_entry (DeviceEntry): The device registry entry to check for matching identifiers.
|
||||
|
||||
Returns:
|
||||
bool: `true` if none of the device's identifiers match serial numbers present in the config entry's runtime data, `false` otherwise.
|
||||
"""
|
||||
current_serials = {d.serial_number for d in (config_entry.runtime_data.data or [])}
|
||||
return not any(
|
||||
identifier
|
||||
for identifier in device_entry.identifiers
|
||||
if identifier[0] == DOMAIN and identifier[1] in current_serials
|
||||
)
|
||||
|
||||
80
custom_components/oasis_mini/binary_sensor.py
Normal file
80
custom_components/oasis_mini/binary_sensor.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Oasis device binary sensor entity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import OasisDeviceConfigEntry, setup_platform_from_coordinator
|
||||
from .entity import OasisDeviceEntity
|
||||
from .pyoasiscontrol import OasisDevice
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, # noqa: ARG001
|
||||
entry: OasisDeviceConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""
|
||||
Set up Oasis device binary sensor entities for a config entry.
|
||||
|
||||
Registers a factory that creates an OasisDeviceBinarySensorEntity for each device and descriptor defined in DESCRIPTORS, and forwards those entities to Home Assistant via the provided add-entities callback.
|
||||
|
||||
Parameters:
|
||||
entry (OasisDeviceConfigEntry): Configuration entry for the Oasis integration containing runtime data and coordinator used to create entities.
|
||||
"""
|
||||
|
||||
def make_entities(new_devices: list[OasisDevice]):
|
||||
"""
|
||||
Create binary sensor entity instances for each provided Oasis device using the module's descriptors.
|
||||
|
||||
Parameters:
|
||||
new_devices (list[OasisDevice]): Devices to generate entities for.
|
||||
|
||||
Returns:
|
||||
list[OasisDeviceBinarySensorEntity]: A list of binary sensor entities pairing each device with every descriptor in DESCRIPTORS.
|
||||
"""
|
||||
return [
|
||||
OasisDeviceBinarySensorEntity(entry.runtime_data, device, descriptor)
|
||||
for device in new_devices
|
||||
for descriptor in DESCRIPTORS
|
||||
]
|
||||
|
||||
setup_platform_from_coordinator(entry, async_add_entities, make_entities)
|
||||
|
||||
|
||||
DESCRIPTORS = (
|
||||
BinarySensorEntityDescription(
|
||||
key="busy",
|
||||
translation_key="busy",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
BinarySensorEntityDescription(
|
||||
key="wifi_connected",
|
||||
translation_key="wifi_status",
|
||||
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class OasisDeviceBinarySensorEntity(OasisDeviceEntity, BinarySensorEntity):
|
||||
"""Oasis device binary sensor entity."""
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""
|
||||
Indicates whether the binary sensor is currently active.
|
||||
|
||||
Returns:
|
||||
bool: True if the sensor is on, False otherwise.
|
||||
"""
|
||||
return getattr(self.device, self.entity_description.key)
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Oasis Mini button entity."""
|
||||
"""Oasis device button entity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -13,63 +13,103 @@ from homeassistant.components.button import (
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import OasisMiniConfigEntry
|
||||
from .entity import OasisMiniEntity
|
||||
from . import OasisDeviceConfigEntry, setup_platform_from_coordinator
|
||||
from .entity import OasisDeviceEntity
|
||||
from .helpers import add_and_play_track
|
||||
from .pyoasismini import OasisMini
|
||||
from .pyoasismini.const import TRACKS
|
||||
from .pyoasiscontrol import OasisDevice
|
||||
from .pyoasiscontrol.const import TRACKS
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: OasisMiniConfigEntry,
|
||||
hass: HomeAssistant, # noqa: ARG001
|
||||
entry: OasisDeviceConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Oasis Mini button using config entry."""
|
||||
async_add_entities(
|
||||
[
|
||||
OasisMiniButtonEntity(entry.runtime_data, descriptor)
|
||||
"""
|
||||
Create and add button entities for each Oasis device defined in the config entry.
|
||||
|
||||
Parameters:
|
||||
entry (OasisDeviceConfigEntry): Config entry containing runtime data and registered Oasis devices.
|
||||
async_add_entities (AddEntitiesCallback): Callback used to register the created entities with Home Assistant.
|
||||
"""
|
||||
|
||||
def make_entities(new_devices: list[OasisDevice]):
|
||||
"""
|
||||
Create button entities for each provided Oasis device using the module descriptors.
|
||||
|
||||
Parameters:
|
||||
new_devices (list[OasisDevice]): Devices to create button entities for.
|
||||
|
||||
Returns:
|
||||
list[OasisDeviceButtonEntity]: Button entity instances created for each device and each descriptor in DESCRIPTORS.
|
||||
"""
|
||||
return [
|
||||
OasisDeviceButtonEntity(entry.runtime_data, device, descriptor)
|
||||
for device in new_devices
|
||||
for descriptor in DESCRIPTORS
|
||||
]
|
||||
)
|
||||
|
||||
setup_platform_from_coordinator(entry, async_add_entities, make_entities)
|
||||
|
||||
|
||||
async def play_random_track(device: OasisMini) -> None:
|
||||
"""Play random track."""
|
||||
async def play_random_track(device: OasisDevice) -> None:
|
||||
"""
|
||||
Play a random track on the given Oasis device.
|
||||
|
||||
Selects a track at random from the available TRACKS and attempts to add it to the device's queue and play it. Raises HomeAssistantError if adding the track times out.
|
||||
|
||||
Parameters:
|
||||
device: The Oasis device on which to play the track.
|
||||
|
||||
Raises:
|
||||
HomeAssistantError: If adding the selected track to the device's queue times out.
|
||||
"""
|
||||
track = random.choice(list(TRACKS))
|
||||
await add_and_play_track(device, track)
|
||||
try:
|
||||
await add_and_play_track(device, track)
|
||||
except TimeoutError as err:
|
||||
raise HomeAssistantError("Timeout adding track to queue") from err
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class OasisMiniButtonEntityDescription(ButtonEntityDescription):
|
||||
"""Oasis Mini button entity description."""
|
||||
class OasisDeviceButtonEntityDescription(ButtonEntityDescription):
|
||||
"""Oasis device button entity description."""
|
||||
|
||||
press_fn: Callable[[OasisMini], Awaitable[None]]
|
||||
press_fn: Callable[[OasisDevice], Awaitable[None]]
|
||||
|
||||
|
||||
DESCRIPTORS = (
|
||||
OasisMiniButtonEntityDescription(
|
||||
OasisDeviceButtonEntityDescription(
|
||||
key="reboot",
|
||||
device_class=ButtonDeviceClass.RESTART,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
press_fn=lambda device: device.async_reboot(),
|
||||
),
|
||||
OasisMiniButtonEntityDescription(
|
||||
OasisDeviceButtonEntityDescription(
|
||||
key="random_track",
|
||||
name="Play random track",
|
||||
translation_key="random_track",
|
||||
press_fn=play_random_track,
|
||||
),
|
||||
OasisDeviceButtonEntityDescription(
|
||||
key="sleep",
|
||||
translation_key="sleep",
|
||||
press_fn=lambda device: device.async_sleep(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class OasisMiniButtonEntity(OasisMiniEntity, ButtonEntity):
|
||||
"""Oasis Mini button entity."""
|
||||
class OasisDeviceButtonEntity(OasisDeviceEntity, ButtonEntity):
|
||||
"""Oasis device button entity."""
|
||||
|
||||
entity_description: OasisMiniButtonEntityDescription
|
||||
entity_description: OasisDeviceButtonEntityDescription
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Press the button."""
|
||||
"""
|
||||
Trigger the button's configured action on the associated device.
|
||||
|
||||
Calls the entity description's `press_fn` with the device to perform the button's effect.
|
||||
"""
|
||||
await self.entity_description.press_fn(self.device)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
186
custom_components/oasis_mini/config_flow.py
Executable file → Normal file
186
custom_components/oasis_mini/config_flow.py
Executable file → Normal file
@@ -1,91 +1,80 @@
|
||||
"""Config flow for Oasis Mini integration."""
|
||||
"""Config flow for Oasis device integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aiohttp import ClientConnectorError
|
||||
from httpx import ConnectError, HTTPStatusError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import dhcp
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_HOST, CONF_PASSWORD
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.schema_config_entry_flow import (
|
||||
SchemaCommonFlowHandler,
|
||||
SchemaFlowError,
|
||||
SchemaFlowFormStep,
|
||||
SchemaOptionsFlowHandler,
|
||||
)
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD
|
||||
|
||||
from . import OasisMiniConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .coordinator import OasisMiniCoordinator
|
||||
from .helpers import create_client
|
||||
from .pyoasiscontrol import UnauthenticatedError
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
|
||||
OPTIONS_SCHEMA = vol.Schema(
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str}
|
||||
)
|
||||
|
||||
|
||||
async def cloud_login(
|
||||
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
|
||||
) -> 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."""
|
||||
class OasisDeviceConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Oasis devices."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 3
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
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
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle DHCP discovery."""
|
||||
host = {CONF_HOST: discovery_info.ip}
|
||||
await self.validate_client(host)
|
||||
self._abort_if_unique_id_configured(updates=host)
|
||||
# This should never happen since we only listen to DHCP requests
|
||||
# for configured devices.
|
||||
return self.async_abort(reason="already_configured")
|
||||
"""
|
||||
Begin the reauthentication flow for an existing config entry.
|
||||
|
||||
Parameters:
|
||||
entry_data (Mapping[str, Any]): Data from the existing config entry that triggered the reauthentication flow.
|
||||
|
||||
Returns:
|
||||
ConfigFlowResult: Result that presents the reauthentication confirmation dialog to the user.
|
||||
"""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""
|
||||
Present a reauthentication confirmation form to the user.
|
||||
|
||||
If `user_input` is provided it will be used as the form values; otherwise the existing entry's data are used as suggested values.
|
||||
|
||||
Returns:
|
||||
ConfigFlowResult: Result of the config flow step that renders the reauthentication form or advances the flow.
|
||||
"""
|
||||
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(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
"""
|
||||
Handle the initial user configuration step for the Oasis integration.
|
||||
|
||||
Parameters:
|
||||
user_input (dict[str, Any] | None): Optional prefilled values (e.g., `email`, `password`) submitted by the user.
|
||||
|
||||
Returns:
|
||||
ConfigFlowResult: Result of the "user" step — a form prompting for credentials, an abort, or a created/updated config entry.
|
||||
"""
|
||||
return await self._async_step(
|
||||
"user", STEP_USER_DATA_SCHEMA, user_input, user_input
|
||||
)
|
||||
@@ -109,25 +98,40 @@ class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
user_input: dict[str, Any] | None = None,
|
||||
suggested_values: dict[str, Any] | None = None,
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle step setup."""
|
||||
"""
|
||||
Handle a single config flow step: validate input, create or update entries, or render the form.
|
||||
|
||||
If valid credentials are provided, this will create a new config entry (title set to the provided email) or update an existing entry and trigger a reload. The step will abort if the validated account conflicts with an existing entry's unique ID. If no input is provided or validation fails, the flow returns a form populated with the given schema, any suggested values, and validation errors.
|
||||
|
||||
Parameters:
|
||||
step_id: Identifier of the flow step to render or process.
|
||||
schema: Voluptuous schema used to build the form.
|
||||
user_input: Submitted values from the form; when present, used for validation and entry creation/update.
|
||||
suggested_values: Values to pre-fill into the form schema when rendering.
|
||||
|
||||
Returns:
|
||||
A ConfigFlowResult representing either a created entry, an update-and-reload abort, an abort due to a unique-id conflict, or a form to display with errors and suggested values.
|
||||
"""
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
if not (errors := await self.validate_client(user_input)):
|
||||
if step_id != "reconfigure":
|
||||
self._abort_if_unique_id_configured(updates=user_input)
|
||||
if existing_entry := self.hass.config_entries.async_get_entry(
|
||||
self.context.get("entry_id")
|
||||
):
|
||||
self.hass.config_entries.async_update_entry(
|
||||
existing_entry, data=user_input
|
||||
entry_id = self.context.get("entry_id")
|
||||
existing_entry = self.hass.config_entries.async_get_entry(entry_id)
|
||||
if existing_entry and existing_entry.unique_id:
|
||||
self._abort_if_unique_id_mismatch(reason="wrong_account")
|
||||
if existing_entry:
|
||||
return self.async_update_reload_and_abort(
|
||||
existing_entry,
|
||||
unique_id=self.unique_id,
|
||||
title=user_input[CONF_EMAIL],
|
||||
data=user_input,
|
||||
reload_even_if_entry_is_unchanged=False,
|
||||
)
|
||||
await self.hass.config_entries.async_reload(existing_entry.entry_id)
|
||||
return self.async_abort(reason="reconfigure_successful")
|
||||
|
||||
self._abort_if_unique_id_configured(updates=user_input)
|
||||
return self.async_create_entry(
|
||||
title=f"Oasis Mini {self.unique_id}",
|
||||
data=user_input,
|
||||
title=user_input[CONF_EMAIL], data=user_input
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
@@ -137,25 +141,47 @@ class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
|
||||
async def validate_client(self, user_input: dict[str, Any]) -> dict[str, str]:
|
||||
"""Validate client setup."""
|
||||
"""
|
||||
Validate provided credentials by attempting to authenticate with the Oasis API and retrieve the user's identity.
|
||||
|
||||
Parameters:
|
||||
user_input (dict[str, Any]): Mutable credential mapping containing at least `email` and `password`.
|
||||
On success, this mapping will be updated with `CONF_ACCESS_TOKEN` (the received access token)
|
||||
and the `password` key will be removed.
|
||||
|
||||
Returns:
|
||||
dict[str, str]: A mapping of form field names to error keys. Common keys:
|
||||
- `"base": "invalid_auth"` when credentials are incorrect or connection refused.
|
||||
- `"base": "timeout_connect"` when the authentication request times out.
|
||||
- `"base": "unknown"` for unexpected errors.
|
||||
- `"base": "<http error text>"` when the server returns an HTTP error.
|
||||
"""
|
||||
errors = {}
|
||||
client = create_client(self.hass, user_input)
|
||||
try:
|
||||
async with asyncio.timeout(10):
|
||||
client = create_client(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:
|
||||
errors["base"] = "invalid_host"
|
||||
errors["base"] = "invalid_auth"
|
||||
except UnauthenticatedError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except asyncio.TimeoutError:
|
||||
errors["base"] = "timeout_connect"
|
||||
except ConnectError:
|
||||
errors["base"] = "invalid_host"
|
||||
errors["base"] = "invalid_auth"
|
||||
except ClientConnectorError:
|
||||
errors["base"] = "invalid_host"
|
||||
errors["base"] = "invalid_auth"
|
||||
except HTTPStatusError as err:
|
||||
errors["base"] = str(err)
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
_LOGGER.error(ex)
|
||||
except Exception:
|
||||
_LOGGER.exception("Error while attempting to validate client")
|
||||
errors["base"] = "unknown"
|
||||
finally:
|
||||
await client.session.close()
|
||||
await client.async_close()
|
||||
return errors
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Constants for the Oasis Mini integration."""
|
||||
"""Constants for the Oasis devices integration."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
|
||||
@@ -1,61 +1,185 @@
|
||||
"""Oasis Mini coordinator."""
|
||||
"""Oasis devices coordinator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import async_timeout
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
from .pyoasismini import OasisMini
|
||||
from .pyoasiscontrol import OasisCloudClient, OasisDevice, OasisMqttClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import OasisDeviceConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OasisMiniCoordinator(DataUpdateCoordinator[str]):
|
||||
"""Oasis Mini data update coordinator."""
|
||||
class OasisDeviceCoordinator(DataUpdateCoordinator[list[OasisDevice]]):
|
||||
"""Oasis device data update coordinator."""
|
||||
|
||||
attempt: int = 0
|
||||
last_updated: datetime | None = None
|
||||
|
||||
def __init__(self, hass: HomeAssistant, device: OasisMini) -> None:
|
||||
"""Initialize."""
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: OasisDeviceConfigEntry,
|
||||
cloud_client: OasisCloudClient,
|
||||
mqtt_client: OasisMqttClient,
|
||||
) -> None:
|
||||
"""
|
||||
Create an OasisDeviceCoordinator that manages OasisDevice discovery and updates using cloud and MQTT clients.
|
||||
|
||||
Parameters:
|
||||
config_entry (OasisDeviceConfigEntry): The config entry whose runtime data contains device serial numbers.
|
||||
cloud_client (OasisCloudClient): Client for communicating with the Oasis cloud API and fetching device data.
|
||||
mqtt_client (OasisMqttClient): Client for registering devices and coordinating MQTT-based readiness/status.
|
||||
"""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(seconds=10),
|
||||
update_interval=timedelta(minutes=10),
|
||||
always_update=False,
|
||||
)
|
||||
self.device = device
|
||||
self.cloud_client = cloud_client
|
||||
self.mqtt_client = mqtt_client
|
||||
|
||||
async def _async_update_data(self):
|
||||
"""Update the data."""
|
||||
data: str | None = None
|
||||
async def _async_update_data(self) -> list[OasisDevice]:
|
||||
"""
|
||||
Fetch and assemble the current list of OasisDevice objects, reconcile removed devices in Home Assistant, register discovered devices with MQTT, and verify per-device readiness.
|
||||
|
||||
Returns:
|
||||
A list of OasisDevice instances representing devices currently available for the account.
|
||||
|
||||
Raises:
|
||||
UpdateFailed: If no devices can be read after repeated attempts or an unexpected error persists past retry limits.
|
||||
"""
|
||||
devices: list[OasisDevice] = []
|
||||
self.attempt += 1
|
||||
|
||||
try:
|
||||
async with async_timeout.timeout(10):
|
||||
if not self.device.mac_address:
|
||||
await self.device.async_get_mac_address()
|
||||
if not self.device.serial_number:
|
||||
await self.device.async_get_serial_number()
|
||||
if not self.device.software_version:
|
||||
await self.device.async_get_software_version()
|
||||
data = await self.device.async_get_status()
|
||||
self.attempt = 0
|
||||
await self.device.async_get_current_track_details()
|
||||
await self.device.async_get_playlist_details()
|
||||
except Exception as ex: # pylint:disable=broad-except
|
||||
if self.attempt > 2 or not (data or self.data):
|
||||
raise UpdateFailed(
|
||||
f"Couldn't read from the Oasis Mini after {self.attempt} attempts"
|
||||
) from ex
|
||||
async with async_timeout.timeout(30):
|
||||
raw_devices = await self.cloud_client.async_get_devices()
|
||||
|
||||
if data != self.data:
|
||||
self.last_updated = datetime.now()
|
||||
return data
|
||||
existing_by_serial = {
|
||||
d.serial_number: d for d in (self.data or []) if d.serial_number
|
||||
}
|
||||
|
||||
for raw in raw_devices:
|
||||
if not (serial := raw.get("serial_number")):
|
||||
continue
|
||||
|
||||
if device := existing_by_serial.get(serial):
|
||||
if name := raw.get("name"):
|
||||
device.name = name
|
||||
else:
|
||||
device = OasisDevice(
|
||||
model=(raw.get("model") or {}).get("name"),
|
||||
serial_number=serial,
|
||||
name=raw.get("name"),
|
||||
cloud=self.cloud_client,
|
||||
)
|
||||
|
||||
devices.append(device)
|
||||
|
||||
new_serials = {d.serial_number for d in devices if d.serial_number}
|
||||
removed_serials = set(existing_by_serial) - new_serials
|
||||
|
||||
if removed_serials:
|
||||
device_registry = dr.async_get(self.hass)
|
||||
for serial in removed_serials:
|
||||
_LOGGER.info(
|
||||
"Oasis device %s removed from account; cleaning up in HA",
|
||||
serial,
|
||||
)
|
||||
device_entry = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, serial)}
|
||||
)
|
||||
if device_entry:
|
||||
device_registry.async_update_device(
|
||||
device_id=device_entry.id,
|
||||
remove_config_entry_id=self.config_entry.entry_id,
|
||||
)
|
||||
|
||||
# ✅ Valid state: logged in but no devices on account
|
||||
if not devices:
|
||||
_LOGGER.debug("No Oasis devices found for account")
|
||||
self.attempt = 0
|
||||
if devices != self.data:
|
||||
self.last_updated = dt_util.now()
|
||||
return []
|
||||
|
||||
self.mqtt_client.register_devices(devices)
|
||||
|
||||
# Best-effort playlists
|
||||
try:
|
||||
await self.cloud_client.async_get_playlists()
|
||||
except Exception:
|
||||
_LOGGER.exception("Error fetching playlists from cloud")
|
||||
|
||||
any_success = False
|
||||
|
||||
for device in devices:
|
||||
try:
|
||||
ready = await self.mqtt_client.wait_until_ready(
|
||||
device, request_status=True
|
||||
)
|
||||
if not ready:
|
||||
_LOGGER.warning(
|
||||
"Timeout waiting for Oasis device %s to be ready",
|
||||
device.serial_number,
|
||||
)
|
||||
continue
|
||||
|
||||
mac = await device.async_get_mac_address()
|
||||
if not mac:
|
||||
_LOGGER.warning(
|
||||
"Could not get MAC address for Oasis device %s",
|
||||
device.serial_number,
|
||||
)
|
||||
continue
|
||||
|
||||
any_success = True
|
||||
device.schedule_track_refresh()
|
||||
|
||||
except Exception:
|
||||
_LOGGER.exception(
|
||||
"Error preparing Oasis device %s", device.serial_number
|
||||
)
|
||||
|
||||
if any_success:
|
||||
self.attempt = 0
|
||||
else:
|
||||
if self.attempt > 2 or not self.data:
|
||||
raise UpdateFailed(
|
||||
"Couldn't read from any Oasis device "
|
||||
f"after {self.attempt} attempts"
|
||||
)
|
||||
|
||||
except UpdateFailed:
|
||||
raise
|
||||
except Exception as ex:
|
||||
if self.attempt > 2 or not (devices or self.data):
|
||||
raise UpdateFailed(
|
||||
"Unexpected error talking to Oasis devices "
|
||||
f"after {self.attempt} attempts"
|
||||
) from ex
|
||||
_LOGGER.warning(
|
||||
"Error updating Oasis devices; reusing previous data", exc_info=ex
|
||||
)
|
||||
return self.data or devices
|
||||
|
||||
if devices != self.data:
|
||||
self.last_updated = dt_util.now()
|
||||
|
||||
return devices
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Oasis Mini entity."""
|
||||
"""Oasis device entity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -7,36 +7,48 @@ from homeassistant.helpers.entity import DeviceInfo, EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import OasisMiniCoordinator
|
||||
from .pyoasismini import OasisMini
|
||||
from .coordinator import OasisDeviceCoordinator
|
||||
from .pyoasiscontrol import OasisDevice
|
||||
|
||||
|
||||
class OasisMiniEntity(CoordinatorEntity[OasisMiniCoordinator]):
|
||||
"""Base class for Oasis Mini entities."""
|
||||
class OasisDeviceEntity(CoordinatorEntity[OasisDeviceCoordinator]):
|
||||
"""Base class for Oasis device entities."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self, coordinator: OasisMiniCoordinator, description: EntityDescription
|
||||
self,
|
||||
coordinator: OasisDeviceCoordinator,
|
||||
device: OasisDevice,
|
||||
description: EntityDescription,
|
||||
) -> None:
|
||||
"""Construct an Oasis Mini entity."""
|
||||
"""
|
||||
Initialize an entity representing an Oasis device.
|
||||
|
||||
Sets the entity's unique_id from the device serial number and the provided description key, stores the given device on the entity, and constructs DeviceInfo containing identifiers, name, manufacturer, model, software version, and a network MAC connection if the device exposes a MAC address.
|
||||
|
||||
Parameters:
|
||||
coordinator: The coordinator responsible for updating the device state.
|
||||
device: OasisDevice instance providing metadata and identifiers (serial_number, mac_address, name, manufacturer, model, software_version).
|
||||
description: EntityDescription used to derive the entity key for the unique_id.
|
||||
"""
|
||||
super().__init__(coordinator)
|
||||
self.device = device
|
||||
self.entity_description = description
|
||||
device = coordinator.device
|
||||
|
||||
serial_number = device.serial_number
|
||||
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(
|
||||
connections={(CONNECTION_NETWORK_MAC, format_mac(device.mac_address))},
|
||||
connections=connections,
|
||||
identifiers={(DOMAIN, serial_number)},
|
||||
name=f"Oasis Mini {serial_number}",
|
||||
manufacturer="Kinetic Oasis",
|
||||
model="Oasis Mini",
|
||||
name=device.name,
|
||||
manufacturer=device.manufacturer,
|
||||
model=device.model,
|
||||
serial_number=serial_number,
|
||||
sw_version=device.software_version,
|
||||
)
|
||||
|
||||
@property
|
||||
def device(self) -> OasisMini:
|
||||
"""Return the device."""
|
||||
return self.coordinator.device
|
||||
|
||||
93
custom_components/oasis_mini/helpers.py
Executable file → Normal file
93
custom_components/oasis_mini/helpers.py
Executable file → Normal file
@@ -1,29 +1,88 @@
|
||||
"""Helpers for the Oasis Mini integration."""
|
||||
"""Helpers for the Oasis devices integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST
|
||||
import async_timeout
|
||||
|
||||
from .pyoasismini import 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__)
|
||||
|
||||
|
||||
def create_client(data: dict[str, Any]) -> OasisMini:
|
||||
"""Create a Oasis Mini local client."""
|
||||
return OasisMini(data[CONF_HOST], data.get(CONF_ACCESS_TOKEN))
|
||||
def create_client(hass: HomeAssistant, data: dict[str, Any]) -> OasisCloudClient:
|
||||
"""
|
||||
Create an Oasis cloud client configured with the Home Assistant HTTP session and access token.
|
||||
|
||||
Parameters:
|
||||
hass (HomeAssistant): Home Assistant instance used to obtain the shared HTTP client session.
|
||||
data (dict[str, Any]): Configuration mapping; the function reads the `CONF_ACCESS_TOKEN` key for the cloud access token.
|
||||
|
||||
Returns:
|
||||
An `OasisCloudClient` initialized with the Home Assistant HTTP session and the configured 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:
|
||||
"""Add and play a track."""
|
||||
if track not in device.playlist:
|
||||
await device.async_add_track_to_playlist(track)
|
||||
async def add_and_play_track(device: OasisDevice, track: int) -> None:
|
||||
"""
|
||||
Ensure a track is present in the device playlist, position it as the next item, select it, and start playback if necessary.
|
||||
|
||||
# Move track to next item in the playlist and then select it
|
||||
if (index := device.playlist.index(track)) != device.playlist_index:
|
||||
if index != (_next := min(device.playlist_index + 1, len(device.playlist) - 1)):
|
||||
await device.async_move_track(index, _next)
|
||||
await device.async_change_track(_next)
|
||||
Adds the specified track to the device playlist if missing, waits up to 10 seconds for the track to appear, moves it to be the next item after the current playlist index if needed, selects that track, and starts playback when the device is not already playing.
|
||||
|
||||
if device.status_code != 4:
|
||||
await device.async_play()
|
||||
Parameters:
|
||||
device (OasisDevice): The target Oasis device.
|
||||
track (int): The track id to add and play.
|
||||
|
||||
Raises:
|
||||
async_timeout.TimeoutError: If the operation does not complete within 10 seconds.
|
||||
"""
|
||||
async with async_timeout.timeout(10):
|
||||
if track not in device.playlist:
|
||||
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
|
||||
if (index := device.playlist.index(track)) != device.playlist_index:
|
||||
if index != (
|
||||
_next := min(device.playlist_index + 1, len(device.playlist) - 1)
|
||||
):
|
||||
await device.async_move_track(index, _next)
|
||||
await device.async_change_track(_next)
|
||||
|
||||
if device.status_code != 4:
|
||||
await device.async_play()
|
||||
|
||||
|
||||
def get_track_id(track: str) -> int | None:
|
||||
"""
|
||||
Convert a track identifier or title to its integer track id.
|
||||
|
||||
Parameters:
|
||||
track: A track reference, either a numeric id as a string or a track title.
|
||||
|
||||
Returns:
|
||||
The integer track id if the input is a valid id or matches a known title, `None` if the input is invalid.
|
||||
"""
|
||||
track = track.lower().strip()
|
||||
if track not in map(str, TRACKS):
|
||||
track = next(
|
||||
(id for id, info in TRACKS.items() if info["name"].lower() == track), track
|
||||
)
|
||||
|
||||
try:
|
||||
return int(track)
|
||||
except ValueError:
|
||||
_LOGGER.warning("Invalid track: %s", track)
|
||||
return None
|
||||
|
||||
47
custom_components/oasis_mini/icons.json
Normal file
47
custom_components/oasis_mini/icons.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"wifi_status": {
|
||||
"default": "mdi:wifi",
|
||||
"state": {
|
||||
"off": "mdi:wifi-off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"download_progress": {
|
||||
"default": "mdi:progress-download"
|
||||
},
|
||||
"drawing_progress": {
|
||||
"default": "mdi:progress-pencil"
|
||||
},
|
||||
"error": {
|
||||
"default": "mdi:alert-circle-outline",
|
||||
"state": {
|
||||
"0": "mdi:circle-outline"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"state": {
|
||||
"booting": "mdi:loading",
|
||||
"busy": "mdi:progress-clock",
|
||||
"centering": "mdi:record-circle-outline",
|
||||
"downloading": "mdi:progress-download",
|
||||
"error": "mdi:alert-circle-outline",
|
||||
"live": "mdi:pencil-circle-outline",
|
||||
"paused": "mdi:motion-pause-outline",
|
||||
"playing": "mdi:motion-play-outline",
|
||||
"sleeping": "mdi:power-sleep",
|
||||
"stopped": "mdi:stop-circle-outline",
|
||||
"updating": "mdi:update"
|
||||
}
|
||||
},
|
||||
"wifi_connected": {
|
||||
"default": "mdi:wifi",
|
||||
"state": {
|
||||
"off": "mdi:wifi-off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Oasis Mini image entity."""
|
||||
"""Oasis device image entity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -6,69 +6,118 @@ from homeassistant.components.image import Image, ImageEntity, ImageEntityDescri
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import UNDEFINED
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import OasisDeviceConfigEntry, setup_platform_from_coordinator
|
||||
from .coordinator import OasisDeviceCoordinator
|
||||
from .entity import OasisDeviceEntity
|
||||
from .pyoasiscontrol import OasisDevice
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, # noqa: ARG001
|
||||
entry: OasisDeviceConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""
|
||||
Set up image entities for Oasis devices from a config entry.
|
||||
|
||||
Creates an OasisDeviceImageEntity for each device in the entry's runtime data and registers them with Home Assistant.
|
||||
|
||||
Parameters:
|
||||
hass (HomeAssistant): Home Assistant core instance.
|
||||
entry (OasisDeviceConfigEntry): Config entry containing runtime data and device registrations.
|
||||
async_add_entities (AddEntitiesCallback): Callback to add created entities to Home Assistant.
|
||||
"""
|
||||
|
||||
def make_entities(new_devices: list[OasisDevice]):
|
||||
"""
|
||||
Create an Image entity for each OasisDevice using the enclosing config entry's runtime data.
|
||||
|
||||
Parameters:
|
||||
new_devices (list[OasisDevice]): Devices to create image entities for.
|
||||
|
||||
Returns:
|
||||
list[OasisDeviceImageEntity]: A list of image entity instances, one per device.
|
||||
"""
|
||||
return [
|
||||
OasisDeviceImageEntity(entry.runtime_data, device, IMAGE)
|
||||
for device in new_devices
|
||||
]
|
||||
|
||||
setup_platform_from_coordinator(entry, async_add_entities, make_entities)
|
||||
|
||||
from . import OasisMiniConfigEntry
|
||||
from .coordinator import OasisMiniCoordinator
|
||||
from .entity import OasisMiniEntity
|
||||
from .pyoasismini.const import TRACKS
|
||||
from .pyoasismini.utils import draw_svg
|
||||
|
||||
IMAGE = ImageEntityDescription(key="image", name=None)
|
||||
|
||||
|
||||
class OasisMiniImageEntity(OasisMiniEntity, ImageEntity):
|
||||
"""Oasis Mini image entity."""
|
||||
class OasisDeviceImageEntity(OasisDeviceEntity, ImageEntity):
|
||||
"""Oasis device image entity."""
|
||||
|
||||
_attr_content_type = "image/svg+xml"
|
||||
_track_id: int | None = None
|
||||
_progress: int = 0
|
||||
|
||||
def __init__(
|
||||
self, coordinator: OasisMiniCoordinator, description: ImageEntityDescription
|
||||
self,
|
||||
coordinator: OasisDeviceCoordinator,
|
||||
device: OasisDevice,
|
||||
description: ImageEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator, description)
|
||||
"""
|
||||
Create an Oasis device image entity tied to a coordinator and a specific device.
|
||||
|
||||
Initializes the entity with the provided coordinator, device, and image description and synchronizes its initial state from the coordinator.
|
||||
|
||||
Parameters:
|
||||
coordinator (OasisDeviceCoordinator): Coordinator providing updates and Home Assistant context.
|
||||
device (OasisDevice): The Oasis device this entity represents.
|
||||
description (ImageEntityDescription): Metadata describing the image entity.
|
||||
"""
|
||||
super().__init__(coordinator, device, description)
|
||||
ImageEntity.__init__(self, coordinator.hass)
|
||||
self._handle_coordinator_update()
|
||||
|
||||
def image(self) -> bytes | None:
|
||||
"""Return bytes of image."""
|
||||
"""
|
||||
Provide the entity's image bytes, generating and caching an SVG from the device when available.
|
||||
|
||||
If the device cannot produce an SVG, the entity's image URL and last-updated timestamp are set and no bytes are returned. When an SVG is produced, the content type is set to "image/svg+xml" and the SVG bytes are cached for future calls.
|
||||
|
||||
Returns:
|
||||
bytes: The image content bytes, or `None` if no image is available yet.
|
||||
"""
|
||||
if not self._cached_image:
|
||||
self._cached_image = Image(
|
||||
self.content_type, draw_svg(self.device.track, self._progress, "1")
|
||||
)
|
||||
if (svg := self.device.create_svg()) is None:
|
||||
self._attr_image_url = self.device.track_image_url
|
||||
self._attr_image_last_updated = dt_util.now()
|
||||
return None
|
||||
self._attr_content_type = "image/svg+xml"
|
||||
self._cached_image = Image(self.content_type, svg.encode())
|
||||
return self._cached_image.content
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
if self._track_id != self.device.track_id or (
|
||||
self._progress != self.device.progress and self.device.access_token
|
||||
):
|
||||
"""
|
||||
Update image metadata and cached image when the coordinator reports changes to the device's track or progress.
|
||||
|
||||
If the device's track_id or progress changed and updates are allowed (the device is playing or there is no cached image), update image last-updated timestamp, record the new track_id and progress, clear the cached image to force regeneration, and set the image URL to UNDEFINED when the track contains inline SVG content or to the device's track_image_url otherwise. When Home Assistant is available, propagate the update to the base class handler.
|
||||
"""
|
||||
device = self.device
|
||||
|
||||
track_changed = self._track_id != device.track_id
|
||||
progress_changed = self._progress != device.progress
|
||||
allow_update = device.status == "playing" or self._cached_image is None
|
||||
|
||||
if (track_changed or progress_changed) and allow_update:
|
||||
self._attr_image_last_updated = self.coordinator.last_updated
|
||||
self._track_id = self.device.track_id
|
||||
self._progress = self.device.progress
|
||||
self._track_id = device.track_id
|
||||
self._progress = device.progress
|
||||
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
|
||||
else:
|
||||
self._attr_image_url = (
|
||||
f"https://app.grounded.so/uploads/{track['image']}"
|
||||
if (
|
||||
track := (self.device.track or TRACKS.get(self.device.track_id))
|
||||
)
|
||||
and "image" in track
|
||||
else None
|
||||
)
|
||||
self._attr_image_url = device.track_image_url
|
||||
|
||||
if self.hass:
|
||||
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
|
||||
|
||||
@@ -23,20 +23,54 @@ from homeassistant.util.color import (
|
||||
value_to_brightness,
|
||||
)
|
||||
|
||||
from . import OasisMiniConfigEntry
|
||||
from .entity import OasisMiniEntity
|
||||
from .pyoasismini import LED_EFFECTS
|
||||
from . import OasisDeviceConfigEntry, setup_platform_from_coordinator
|
||||
from .entity import OasisDeviceEntity
|
||||
from .pyoasiscontrol import OasisDevice
|
||||
from .pyoasiscontrol.const import LED_EFFECTS
|
||||
|
||||
|
||||
class OasisMiniLightEntity(OasisMiniEntity, LightEntity):
|
||||
"""Oasis Mini light entity."""
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, # noqa: ARG001
|
||||
entry: OasisDeviceConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Oasis device lights using config entry."""
|
||||
|
||||
def make_entities(new_devices: list[OasisDevice]):
|
||||
"""
|
||||
Create OasisDeviceLightEntity instances for each provided Oasis device.
|
||||
|
||||
Parameters:
|
||||
new_devices (list[OasisDevice]): Devices to wrap as light entities.
|
||||
|
||||
Returns:
|
||||
list[OasisDeviceLightEntity]: A list of light entity instances corresponding to the input devices.
|
||||
"""
|
||||
return [
|
||||
OasisDeviceLightEntity(entry.runtime_data, device, DESCRIPTOR)
|
||||
for device in new_devices
|
||||
]
|
||||
|
||||
setup_platform_from_coordinator(entry, async_add_entities, make_entities)
|
||||
|
||||
|
||||
DESCRIPTOR = LightEntityDescription(key="led", translation_key="led")
|
||||
|
||||
|
||||
class OasisDeviceLightEntity(OasisDeviceEntity, LightEntity):
|
||||
"""Oasis device light entity."""
|
||||
|
||||
_attr_supported_features = LightEntityFeature.EFFECT
|
||||
|
||||
@property
|
||||
def brightness(self) -> int:
|
||||
"""Return the brightness of this light between 0..255."""
|
||||
scale = (1, self.device.max_brightness)
|
||||
"""
|
||||
Get the light's brightness on a 0-255 scale.
|
||||
|
||||
Returns:
|
||||
int: Brightness value between 0 and 255.
|
||||
"""
|
||||
scale = (1, self.device.brightness_max)
|
||||
return value_to_brightness(scale, self.device.brightness)
|
||||
|
||||
@property
|
||||
@@ -82,15 +116,31 @@ class OasisMiniLightEntity(OasisMiniEntity, LightEntity):
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the entity off."""
|
||||
await self.device.async_set_led(brightness=0)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the entity on."""
|
||||
"""
|
||||
Turn the light on and set its LED state.
|
||||
|
||||
Processes optional keyword arguments to compute the device-specific LED parameters, then updates the device's LEDs with the resulting brightness, color, and effect.
|
||||
|
||||
Parameters:
|
||||
kwargs: Optional control parameters recognized by the method:
|
||||
ATTR_BRIGHTNESS (int): Brightness in the 0-255 Home Assistant scale. When provided,
|
||||
it is converted and rounded up to the device's brightness scale (1..device.brightness_max).
|
||||
When omitted, uses self.device.brightness or self.device.brightness_on.
|
||||
ATTR_RGB_COLOR (tuple[int, int, int]): RGB tuple (R, G, B). When provided, it is
|
||||
converted to a hex color string prefixed with '#'.
|
||||
ATTR_EFFECT (str): Human-readable effect name. When provided, it is mapped to the
|
||||
device's internal effect key; if no mapping exists, `None` is used.
|
||||
|
||||
Side effects:
|
||||
Updates the underlying device LED state with the computed `brightness`, `color`, and `led_effect`.
|
||||
"""
|
||||
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))
|
||||
else:
|
||||
brightness = self.device.brightness or 100
|
||||
brightness = self.device.brightness or self.device.brightness_on
|
||||
|
||||
if color := kwargs.get(ATTR_RGB_COLOR):
|
||||
color = f"#{color_rgb_to_hex(*color)}"
|
||||
@@ -103,16 +153,3 @@ class OasisMiniLightEntity(OasisMiniEntity, LightEntity):
|
||||
await self.device.async_set_led(
|
||||
brightness=brightness, color=color, led_effect=led_effect
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
|
||||
DESCRIPTOR = LightEntityDescription(key="led", name="LED")
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: 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",
|
||||
"name": "Oasis Mini",
|
||||
"name": "Oasis Control",
|
||||
"codeowners": ["@natekspencer"],
|
||||
"config_flow": true,
|
||||
"dhcp": [{ "registered_devices": true }],
|
||||
"documentation": "https://github.com/natekspencer/hacs-oasis_mini",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"issue_tracker": "https://github.com/natekspencer/hacs-oasis_mini/issues",
|
||||
"loggers": ["custom_components.oasis_mini"],
|
||||
"requirements": ["aiomqtt"],
|
||||
"version": "0.0.0"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Oasis Mini media player entity."""
|
||||
"""Oasis device media player entity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -18,13 +18,43 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import OasisMiniConfigEntry
|
||||
from .entity import OasisMiniEntity
|
||||
from .pyoasismini.const import TRACKS
|
||||
from . import OasisDeviceConfigEntry, setup_platform_from_coordinator
|
||||
from .const import DOMAIN
|
||||
from .entity import OasisDeviceEntity
|
||||
from .helpers import get_track_id
|
||||
from .pyoasiscontrol import OasisDevice
|
||||
|
||||
|
||||
class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
|
||||
"""Oasis Mini media player entity."""
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, # noqa: ARG001
|
||||
entry: OasisDeviceConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Oasis device media_players using config entry."""
|
||||
|
||||
def make_entities(new_devices: list[OasisDevice]):
|
||||
"""
|
||||
Create media player entities for the given Oasis devices.
|
||||
|
||||
Parameters:
|
||||
new_devices (list[OasisDevice]): Devices to wrap as media player entities.
|
||||
|
||||
Returns:
|
||||
list[OasisDeviceMediaPlayerEntity]: Media player entities corresponding to each device.
|
||||
"""
|
||||
return [
|
||||
OasisDeviceMediaPlayerEntity(entry.runtime_data, device, DESCRIPTOR)
|
||||
for device in new_devices
|
||||
]
|
||||
|
||||
setup_platform_from_coordinator(entry, async_add_entities, make_entities)
|
||||
|
||||
|
||||
DESCRIPTOR = MediaPlayerEntityDescription(key="oasis_mini", name=None)
|
||||
|
||||
|
||||
class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity):
|
||||
"""Oasis device media player entity."""
|
||||
|
||||
_attr_media_image_remotely_accessible = True
|
||||
_attr_supported_features = (
|
||||
@@ -47,22 +77,28 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
|
||||
@property
|
||||
def media_duration(self) -> int | None:
|
||||
"""Duration of current playing media in seconds."""
|
||||
if (track := self.device.track) and "reduced_svg_content" in track:
|
||||
return track["reduced_svg_content"].get("1")
|
||||
if (track := self.device.track) and "reduced_svg_content_new" in track:
|
||||
return track["reduced_svg_content_new"]
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_image_url(self) -> str | None:
|
||||
"""Image url of current playing media."""
|
||||
if not (track := self.device.track):
|
||||
track = TRACKS.get(self.device.track_id)
|
||||
if track and "image" in track:
|
||||
return f"https://app.grounded.so/uploads/{track['image']}"
|
||||
return None
|
||||
"""
|
||||
URL of the image representing the currently playing media.
|
||||
|
||||
Returns:
|
||||
The image URL as a string, or `None` if no image is available.
|
||||
"""
|
||||
return self.device.track_image_url
|
||||
|
||||
@property
|
||||
def media_position(self) -> int:
|
||||
"""Position of current playing media in seconds."""
|
||||
"""
|
||||
Playback position of the current media in seconds.
|
||||
|
||||
Returns:
|
||||
int: Position in seconds of the currently playing media.
|
||||
"""
|
||||
return self.device.progress
|
||||
|
||||
@property
|
||||
@@ -72,16 +108,22 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
|
||||
|
||||
@property
|
||||
def media_title(self) -> str | None:
|
||||
"""Title of current playing media."""
|
||||
if not self.device.track_id:
|
||||
return None
|
||||
if not (track := self.device.track):
|
||||
track = TRACKS.get(self.device.track_id, {})
|
||||
return track.get("name", f"Unknown Title (#{self.device.track_id})")
|
||||
"""
|
||||
Provide the title of the currently playing track.
|
||||
|
||||
Returns:
|
||||
str | None: The track title, or None if no title is available.
|
||||
"""
|
||||
return self.device.track_name
|
||||
|
||||
@property
|
||||
def repeat(self) -> RepeatMode:
|
||||
"""Return current repeat mode."""
|
||||
"""
|
||||
Get the current repeat mode for the device.
|
||||
|
||||
Returns:
|
||||
`RepeatMode.ALL` if the device is configured to repeat the playlist, `RepeatMode.OFF` otherwise.
|
||||
"""
|
||||
return RepeatMode.ALL if self.device.repeat_playlist else RepeatMode.OFF
|
||||
|
||||
@property
|
||||
@@ -102,42 +144,85 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
|
||||
return MediaPlayerState.ON
|
||||
return MediaPlayerState.IDLE
|
||||
|
||||
def abort_if_busy(self) -> None:
|
||||
"""Abort if the device is currently busy."""
|
||||
if self.device.busy:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_busy",
|
||||
translation_placeholders={"name": self._friendly_name_internal()},
|
||||
)
|
||||
|
||||
async def async_media_pause(self) -> None:
|
||||
"""Send pause command."""
|
||||
"""
|
||||
Pause playback on the device.
|
||||
|
||||
Raises:
|
||||
ServiceValidationError: If the device is busy and cannot accept commands.
|
||||
"""
|
||||
self.abort_if_busy()
|
||||
await self.device.async_pause()
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_media_play(self) -> None:
|
||||
"""Send play command."""
|
||||
"""
|
||||
Start playback on the device.
|
||||
|
||||
Raises:
|
||||
ServiceValidationError: If the device is currently busy.
|
||||
"""
|
||||
self.abort_if_busy()
|
||||
await self.device.async_play()
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_media_stop(self) -> None:
|
||||
"""Send stop command."""
|
||||
"""
|
||||
Stop playback on the Oasis device.
|
||||
|
||||
Raises:
|
||||
ServiceValidationError: If the device is currently busy.
|
||||
"""
|
||||
self.abort_if_busy()
|
||||
await self.device.async_stop()
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_set_repeat(self, repeat: RepeatMode) -> None:
|
||||
"""Set repeat mode."""
|
||||
"""
|
||||
Set the device playlist repeat behavior.
|
||||
|
||||
Enables or disables looping of the playlist according to the provided RepeatMode:
|
||||
- RepeatMode.OFF disables playlist repeat.
|
||||
- RepeatMode.ALL enables playlist repeat for the entire playlist.
|
||||
- RepeatMode.ONE enables single-track repeat, except when the device is currently repeating the entire playlist; in that case the playlist repeat is disabled to preserve single-track semantics.
|
||||
|
||||
Parameters:
|
||||
repeat (RepeatMode): The desired repeat mode to apply to the device playlist.
|
||||
"""
|
||||
await self.device.async_set_repeat_playlist(
|
||||
repeat != RepeatMode.OFF
|
||||
and not (repeat == RepeatMode.ONE and self.repeat == RepeatMode.ALL)
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_media_previous_track(self) -> None:
|
||||
"""Send previous track command."""
|
||||
"""
|
||||
Move playback to the previous track in the device's playlist, wrapping to the last track when currently at the first.
|
||||
|
||||
Raises:
|
||||
ServiceValidationError: If the device is busy.
|
||||
"""
|
||||
self.abort_if_busy()
|
||||
if (index := self.device.playlist_index - 1) < 0:
|
||||
index = len(self.device.playlist) - 1
|
||||
await self.device.async_change_track(index)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_media_next_track(self) -> None:
|
||||
"""Send next track command."""
|
||||
"""
|
||||
Advance the device to the next track in its playlist, wrapping to the first track when at the end.
|
||||
|
||||
Raises:
|
||||
ServiceValidationError: if the device is busy.
|
||||
"""
|
||||
self.abort_if_busy()
|
||||
if (index := self.device.playlist_index + 1) >= len(self.device.playlist):
|
||||
index = 0
|
||||
await self.device.async_change_track(index)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_play_media(
|
||||
self,
|
||||
@@ -146,33 +231,48 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
|
||||
enqueue: MediaPlayerEnqueue | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Play a piece of media."""
|
||||
if media_id not in map(str, TRACKS):
|
||||
media_id = next(
|
||||
(
|
||||
id
|
||||
for id, info in TRACKS.items()
|
||||
if info["name"].lower() == media_id.lower()
|
||||
),
|
||||
media_id,
|
||||
"""
|
||||
Play or enqueue one or more Oasis tracks on the device.
|
||||
|
||||
Validates the media type and parses one or more track identifiers from `media_id`, then updates the device playlist according to `enqueue`. Depending on the enqueue mode the method can replace the playlist, append tracks, move appended tracks to the next play position, and optionally start playback.
|
||||
|
||||
Parameters:
|
||||
media_type (MediaType | str): The media type being requested.
|
||||
media_id (str): A comma-separated string of track identifiers.
|
||||
enqueue (MediaPlayerEnqueue | None): How to insert the tracks into the playlist; if omitted defaults to NEXT.
|
||||
|
||||
Raises:
|
||||
ServiceValidationError: If the device is busy, if `media_type` is a playlist (playlists are unsupported), or if `media_id` does not contain any valid track identifiers.
|
||||
"""
|
||||
self.abort_if_busy()
|
||||
if media_type == MediaType.PLAYLIST:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN, translation_key="playlists_unsupported"
|
||||
)
|
||||
try:
|
||||
track = int(media_id)
|
||||
except ValueError as err:
|
||||
raise ServiceValidationError(f"Invalid media: {media_id}") from err
|
||||
else:
|
||||
track = list(filter(None, map(get_track_id, media_id.split(","))))
|
||||
if not track:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_media",
|
||||
translation_placeholders={"media": media_id},
|
||||
)
|
||||
|
||||
device = self.device
|
||||
enqueue = MediaPlayerEnqueue.NEXT if not enqueue else enqueue
|
||||
if enqueue == MediaPlayerEnqueue.REPLACE:
|
||||
await device.async_set_playlist([track])
|
||||
await device.async_set_playlist(track)
|
||||
else:
|
||||
await device.async_add_track_to_playlist(track)
|
||||
|
||||
if enqueue in (MediaPlayerEnqueue.NEXT, MediaPlayerEnqueue.PLAY):
|
||||
# Move track to next item in the playlist
|
||||
if (index := (len(device.playlist) - 1)) != device.playlist_index:
|
||||
new_tracks = 1 if isinstance(track, int) else len(track)
|
||||
if (index := (len(device.playlist) - new_tracks)) != device.playlist_index:
|
||||
if index != (
|
||||
_next := min(device.playlist_index + 1, len(device.playlist) - 1)
|
||||
_next := min(
|
||||
device.playlist_index + 1, len(device.playlist) - new_tracks
|
||||
)
|
||||
):
|
||||
await device.async_move_track(index, _next)
|
||||
if enqueue == MediaPlayerEnqueue.PLAY:
|
||||
@@ -184,21 +284,12 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
|
||||
):
|
||||
await device.async_play()
|
||||
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_clear_playlist(self) -> None:
|
||||
"""Clear players playlist."""
|
||||
"""
|
||||
Clear the device's playlist.
|
||||
|
||||
Raises:
|
||||
ServiceValidationError: If the device is busy and cannot accept commands.
|
||||
"""
|
||||
self.abort_if_busy()
|
||||
await self.device.async_clear_playlist()
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
|
||||
DESCRIPTOR = MediaPlayerEntityDescription(key="oasis_mini", name=None)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: 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
|
||||
|
||||
@@ -7,58 +7,100 @@ from homeassistant.components.number import (
|
||||
NumberEntityDescription,
|
||||
NumberMode,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import OasisMiniConfigEntry
|
||||
from .entity import OasisMiniEntity
|
||||
from .pyoasismini import BALL_SPEED_MAX, BALL_SPEED_MIN, LED_SPEED_MAX, LED_SPEED_MIN
|
||||
from . import OasisDeviceConfigEntry, setup_platform_from_coordinator
|
||||
from .entity import OasisDeviceEntity
|
||||
from .pyoasiscontrol import OasisDevice
|
||||
from .pyoasiscontrol.device import (
|
||||
BALL_SPEED_MAX,
|
||||
BALL_SPEED_MIN,
|
||||
LED_SPEED_MAX,
|
||||
LED_SPEED_MIN,
|
||||
)
|
||||
|
||||
|
||||
class OasisMiniNumberEntity(OasisMiniEntity, NumberEntity):
|
||||
"""Oasis Mini number entity."""
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, # noqa: ARG001
|
||||
entry: OasisDeviceConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""
|
||||
Set up number entities for Oasis devices from a configuration entry.
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | None:
|
||||
"""Return the value reported by the number."""
|
||||
return getattr(self.device, self.entity_description.key)
|
||||
Creates number entities for each discovered Oasis device and each descriptor in DESCRIPTORS, then registers those entities with the platform coordinator so they are added to Home Assistant.
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Set new 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)
|
||||
await self.coordinator.async_request_refresh()
|
||||
Parameters:
|
||||
hass (HomeAssistant): Home Assistant core object.
|
||||
entry (OasisDeviceConfigEntry): Configuration entry containing runtime data and devices to expose.
|
||||
async_add_entities (AddEntitiesCallback): Callback to add created entities to Home Assistant.
|
||||
"""
|
||||
|
||||
def make_entities(new_devices: list[OasisDevice]):
|
||||
"""
|
||||
Create number entity instances for each provided Oasis device using the module's DESCRIPTORS.
|
||||
|
||||
Parameters:
|
||||
new_devices (list[OasisDevice]): Devices to create entities for.
|
||||
|
||||
Returns:
|
||||
list[OasisDeviceNumberEntity]: A flat list of number entities (one per descriptor for each device).
|
||||
"""
|
||||
return [
|
||||
OasisDeviceNumberEntity(entry.runtime_data, device, descriptor)
|
||||
for device in new_devices
|
||||
for descriptor in DESCRIPTORS
|
||||
]
|
||||
|
||||
setup_platform_from_coordinator(entry, async_add_entities, make_entities)
|
||||
|
||||
|
||||
DESCRIPTORS = {
|
||||
DESCRIPTORS = (
|
||||
NumberEntityDescription(
|
||||
key="ball_speed",
|
||||
name="Ball speed",
|
||||
translation_key="ball_speed",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
mode=NumberMode.SLIDER,
|
||||
native_max_value=BALL_SPEED_MAX,
|
||||
native_min_value=BALL_SPEED_MIN,
|
||||
),
|
||||
NumberEntityDescription(
|
||||
key="led_speed",
|
||||
name="LED speed",
|
||||
translation_key="led_speed",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
mode=NumberMode.SLIDER,
|
||||
native_max_value=LED_SPEED_MAX,
|
||||
native_min_value=LED_SPEED_MIN,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: OasisMiniConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Oasis Mini numbers using config entry."""
|
||||
async_add_entities(
|
||||
[
|
||||
OasisMiniNumberEntity(entry.runtime_data, descriptor)
|
||||
for descriptor in DESCRIPTORS
|
||||
]
|
||||
)
|
||||
class OasisDeviceNumberEntity(OasisDeviceEntity, NumberEntity):
|
||||
"""Oasis device number entity."""
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""
|
||||
Get the current value of the number entity from the underlying device.
|
||||
|
||||
Returns:
|
||||
float | None: The current value as a float, or `None` if the device has no value.
|
||||
"""
|
||||
return getattr(self.device, self.entity_description.key)
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""
|
||||
Set the configured numeric value on the underlying Oasis device.
|
||||
|
||||
The provided value is converted to an integer and applied to the device property indicated by this entity's description key: if the key is "ball_speed" the device's ball speed is updated; if the key is "led_speed" the device's LED speed is updated.
|
||||
|
||||
Parameters:
|
||||
value (float): New numeric value to apply; will be converted to an integer.
|
||||
"""
|
||||
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,390 @@
|
||||
"""Oasis cloud client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from aiohttp import ClientResponseError, ClientSession
|
||||
|
||||
from ..exceptions import UnauthenticatedError
|
||||
from ..utils import now
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
BASE_URL = "https://app.grounded.so"
|
||||
PLAYLISTS_REFRESH_LIMITER = timedelta(minutes=5)
|
||||
SOFTWARE_REFRESH_LIMITER = timedelta(hours=1)
|
||||
|
||||
|
||||
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
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
session: ClientSession | None = None,
|
||||
access_token: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the OasisCloudClient.
|
||||
|
||||
Sets the optional aiohttp session and access token, records whether the client owns the session, and initializes caches and asyncio locks for playlists and software metadata.
|
||||
|
||||
Parameters:
|
||||
session (ClientSession | None): Optional aiohttp ClientSession to use. If None, the client will create and own a session later.
|
||||
access_token (str | None): Optional initial access token for authenticated requests.
|
||||
"""
|
||||
self._session = session
|
||||
self._owns_session = session is None
|
||||
self._access_token = access_token
|
||||
|
||||
now_dt = now()
|
||||
|
||||
# playlists cache
|
||||
self._playlists_cache: dict[bool, list[dict[str, Any]]] = {False: [], True: []}
|
||||
self._playlists_next_refresh = {False: now_dt, True: now_dt}
|
||||
self._playlists_lock = asyncio.Lock()
|
||||
|
||||
# software metadata cache
|
||||
self._software_details: dict[str, int | str] | None = None
|
||||
self._software_next_refresh = now()
|
||||
self._software_lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
def playlists(self) -> list[dict]:
|
||||
"""Return all cached playlists, deduplicated by ID."""
|
||||
seen = set()
|
||||
merged: list[dict] = []
|
||||
|
||||
for items in self._playlists_cache.values():
|
||||
for pl in items:
|
||||
if (pid := pl.get("id")) not in seen:
|
||||
seen.add(pid)
|
||||
merged.append(pl)
|
||||
|
||||
return merged
|
||||
|
||||
@property
|
||||
def session(self) -> ClientSession:
|
||||
"""
|
||||
Get the active aiohttp ClientSession, creating and owning a new session if none exists or the existing session is closed.
|
||||
|
||||
Returns:
|
||||
ClientSession: The active aiohttp ClientSession; a new session is created and marked as owned by this client when necessary.
|
||||
"""
|
||||
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 the aiohttp ClientSession owned by this client if it exists and is open.
|
||||
|
||||
This should be called during teardown when the client is responsible for the session.
|
||||
"""
|
||||
if self._session and not self._session.closed and self._owns_session:
|
||||
await self._session.close()
|
||||
|
||||
@property
|
||||
def access_token(self) -> str | None:
|
||||
"""
|
||||
Access token used for authenticated requests or None if not set.
|
||||
|
||||
Returns:
|
||||
The current access token string, or `None` if no token is stored.
|
||||
"""
|
||||
return self._access_token
|
||||
|
||||
@access_token.setter
|
||||
def access_token(self, value: str | None) -> None:
|
||||
"""
|
||||
Set the access token used for authenticated requests.
|
||||
|
||||
Parameters:
|
||||
value (str | None): The bearer token to store; pass None to clear the stored token.
|
||||
"""
|
||||
self._access_token = value
|
||||
|
||||
async def async_login(self, email: str, password: str) -> None:
|
||||
"""
|
||||
Log in to the Oasis cloud and store the received access token on the client.
|
||||
|
||||
Performs an authentication request using the provided credentials and saves the returned access token to self.access_token for use in subsequent authenticated requests.
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
End the current authenticated session with the Oasis cloud.
|
||||
|
||||
Performs a logout request and clears the stored access token on success.
|
||||
"""
|
||||
await self._async_auth_request("GET", "api/auth/logout")
|
||||
self.access_token = None
|
||||
|
||||
async def async_get_user(self) -> dict:
|
||||
"""
|
||||
Return information about the currently authenticated user.
|
||||
|
||||
Returns:
|
||||
dict: A mapping containing the user's details as returned by the cloud API.
|
||||
|
||||
Raises:
|
||||
UnauthenticatedError: If no access token is available or the request is unauthorized.
|
||||
"""
|
||||
return await self._async_auth_request("GET", "api/auth/user")
|
||||
|
||||
async def async_get_devices(self) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Retrieve the current user's devices from the cloud API.
|
||||
|
||||
Returns:
|
||||
list[dict[str, Any]]: A list of device objects as returned by the 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]]:
|
||||
"""
|
||||
Retrieve playlists from the Oasis cloud, optionally limited to the authenticated user's personal playlists.
|
||||
|
||||
The result is cached and will be refreshed according to PLAYLISTS_REFRESH_LIMITER to avoid frequent network requests.
|
||||
|
||||
Parameters:
|
||||
personal_only (bool): If True, return only playlists owned by the authenticated user; otherwise return all available playlists.
|
||||
|
||||
Returns:
|
||||
list[dict[str, Any]]: A list of playlist objects represented as dictionaries; an empty list if no playlists are available.
|
||||
"""
|
||||
now_dt = now()
|
||||
|
||||
def _is_cache_valid() -> bool:
|
||||
"""
|
||||
Determine whether the playlists cache is still valid.
|
||||
|
||||
Returns:
|
||||
`true` if the playlists cache contains data and the next refresh timestamp is later than the current time, `false` otherwise.
|
||||
"""
|
||||
cache = self._playlists_cache[personal_only]
|
||||
next_refresh = self._playlists_next_refresh[personal_only]
|
||||
return bool(cache) and next_refresh > now_dt
|
||||
|
||||
if _is_cache_valid():
|
||||
return self._playlists_cache[personal_only]
|
||||
|
||||
async with self._playlists_lock:
|
||||
# Double-check in case another task just refreshed it
|
||||
now_dt = now()
|
||||
if _is_cache_valid():
|
||||
return self._playlists_cache[personal_only]
|
||||
|
||||
params = {"my_playlists": str(personal_only).lower()}
|
||||
playlists = await self._async_auth_request(
|
||||
"GET", "api/playlist", params=params
|
||||
)
|
||||
|
||||
if not isinstance(playlists, list):
|
||||
playlists = []
|
||||
|
||||
self._playlists_cache[personal_only] = playlists
|
||||
self._playlists_next_refresh[personal_only] = (
|
||||
now_dt + PLAYLISTS_REFRESH_LIMITER
|
||||
)
|
||||
|
||||
return playlists
|
||||
|
||||
async def async_get_track_info(self, track_id: int) -> dict[str, Any] | None:
|
||||
"""
|
||||
Retrieve information for a single track from the cloud.
|
||||
|
||||
Returns:
|
||||
dict: Track detail dictionary. If the track is not found (HTTP 404), returns a dict with keys `id` and `name` where `name` is "Unknown Title (#{id})". Returns `None` on other failures.
|
||||
"""
|
||||
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})"}
|
||||
raise
|
||||
except UnauthenticatedError:
|
||||
raise
|
||||
except Exception:
|
||||
_LOGGER.exception("Error fetching track %s", track_id)
|
||||
return None
|
||||
|
||||
async def async_get_tracks(
|
||||
self, tracks: list[int] | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Retrieve track details for the given track IDs, following pagination until all pages are fetched.
|
||||
|
||||
Parameters:
|
||||
tracks (list[int] | None): Optional list of track IDs to request. If omitted or None, an empty list is sent to the API.
|
||||
|
||||
Returns:
|
||||
list[dict[str, Any]]: A list of track detail dictionaries returned by the cloud, aggregated across all pages (may be empty).
|
||||
"""
|
||||
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, *, force_refresh: bool = False
|
||||
) -> dict[str, int | str] | None:
|
||||
"""
|
||||
Retrieve the latest software metadata from the cloud, using an internal cache to limit requests.
|
||||
|
||||
Parameters:
|
||||
force_refresh (bool): If True, bypass the cache and fetch fresh metadata from the cloud.
|
||||
|
||||
Returns:
|
||||
details (dict[str, int | str] | None): A mapping of software metadata keys to integer or string values, or `None` if no metadata is available.
|
||||
"""
|
||||
now_dt = now()
|
||||
|
||||
def _is_cache_valid() -> bool:
|
||||
"""
|
||||
Determine whether the cached software metadata should be used instead of fetching fresh data.
|
||||
|
||||
Returns:
|
||||
True if the software cache exists, has not expired, and a force refresh was not requested; False otherwise.
|
||||
"""
|
||||
return (
|
||||
not force_refresh
|
||||
and self._software_details is not None
|
||||
and self._software_next_refresh > now_dt
|
||||
)
|
||||
|
||||
if _is_cache_valid():
|
||||
return self._software_details
|
||||
|
||||
async with self._software_lock:
|
||||
# Double-check in case another task just refreshed it
|
||||
now_dt = now()
|
||||
if _is_cache_valid():
|
||||
return self._software_details
|
||||
|
||||
details = await self._async_auth_request("GET", "api/software/last-version")
|
||||
|
||||
if not isinstance(details, dict):
|
||||
details = {}
|
||||
|
||||
self._software_details = details
|
||||
self._software_next_refresh = now_dt + SOFTWARE_REFRESH_LIMITER
|
||||
|
||||
return self._software_details
|
||||
|
||||
async def _async_auth_request(self, method: str, url: str, **kwargs: Any) -> Any:
|
||||
"""
|
||||
Perform a cloud API request using the stored access token.
|
||||
|
||||
If `url` is relative it will be joined with the module `BASE_URL`. The method will
|
||||
inject an `Authorization: Bearer <token>` header into the request.
|
||||
|
||||
Parameters:
|
||||
method (str): HTTP method (e.g., "GET", "POST").
|
||||
url (str): Absolute URL or path relative to `BASE_URL`.
|
||||
**kwargs: Passed through to the underlying request helper.
|
||||
|
||||
Returns:
|
||||
The parsed response value (JSON object, text, or None) as returned by the underlying request helper.
|
||||
|
||||
Raises:
|
||||
UnauthenticatedError: If no access token is set.
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
Perform a single HTTP request and return a normalized response value.
|
||||
|
||||
Performs the request using the client's session and:
|
||||
- If the response status is 200:
|
||||
- returns parsed JSON for `application/json`.
|
||||
- returns text for `text/plain`.
|
||||
- if `text/html` and the URL targets the cloud base URL and contains a login page, raises UnauthenticatedError.
|
||||
- returns `None` for other content types.
|
||||
- If the response status is 401, raises UnauthenticatedError.
|
||||
- For other non-200 statuses, re-raises the response's HTTP error.
|
||||
|
||||
Parameters:
|
||||
method: HTTP method to use (e.g., "GET", "POST").
|
||||
url: Request URL or path.
|
||||
**kwargs: Passed through to the session request (e.g., `params`, `json`, `headers`).
|
||||
|
||||
Returns:
|
||||
The parsed JSON object, response text, or `None` depending on the response content type.
|
||||
|
||||
Raises:
|
||||
UnauthenticatedError: when the server indicates the client is unauthenticated (401) or a cloud login page is returned.
|
||||
aiohttp.ClientResponseError: for other non-success HTTP statuses raised by `response.raise_for_status()`.
|
||||
"""
|
||||
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,349 @@
|
||||
"""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:
|
||||
"""
|
||||
Initialize the HTTP client for a specific device host.
|
||||
|
||||
Parameters:
|
||||
host (str): Hostname or IP address of the target device (used to build the base HTTP URL).
|
||||
session (ClientSession | None): Optional aiohttp ClientSession to reuse for requests. If omitted, a new session will be created and owned by this client.
|
||||
"""
|
||||
self._host = host
|
||||
self._session: ClientSession | None = session
|
||||
self._owns_session: bool = session is None
|
||||
|
||||
@property
|
||||
def session(self) -> ClientSession:
|
||||
"""
|
||||
Ensure and return a usable aiohttp ClientSession for this client.
|
||||
|
||||
If no session exists or the existing session is closed, a new ClientSession is created and the client records ownership of that session so it can be closed later.
|
||||
|
||||
Returns:
|
||||
An active aiohttp ClientSession instance associated with this client.
|
||||
"""
|
||||
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 the client's owned HTTP session if one exists and is open.
|
||||
|
||||
Does nothing when there is no session, the session is already closed, or the client does not own the 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
|
||||
"""
|
||||
Base HTTP URL for the target device.
|
||||
|
||||
Returns:
|
||||
The device base URL using plain HTTP (no TLS), including a trailing slash (e.g. "http://{host}/").
|
||||
"""
|
||||
return f"http://{self._host}/"
|
||||
|
||||
async def _async_request(self, method: str, url: str, **kwargs: Any) -> Any:
|
||||
"""
|
||||
Perform an HTTP request using the client's session and decode the response.
|
||||
|
||||
Logs the request URL and query parameters. If the response status is 200, returns the response body as a string for `text/plain`, the parsed JSON for `application/json`, or `None` for other content types. On non-200 responses, raises the client response error.
|
||||
|
||||
Returns:
|
||||
The response body as `str` for `text/plain`, the parsed JSON value for `application/json`, or `None` for other content types.
|
||||
|
||||
Raises:
|
||||
aiohttp.ClientResponseError: If the response status is not 200.
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
Perform a GET request to the client's base URL using the provided request keyword arguments.
|
||||
|
||||
Parameters:
|
||||
**kwargs: Additional request keyword arguments forwarded to the underlying request (for example `params`, `headers`, `timeout`).
|
||||
|
||||
Returns:
|
||||
`str` response text when the server responds with `text/plain`, `None` otherwise.
|
||||
"""
|
||||
return await self._async_request("GET", self.url, **kwargs)
|
||||
|
||||
async def _async_command(self, **kwargs: Any) -> str | None:
|
||||
"""
|
||||
Execute a device command by issuing a GET request with the provided query parameters and return the parsed response.
|
||||
|
||||
Parameters:
|
||||
**kwargs: Mapping of query parameter names to values sent with the GET request.
|
||||
|
||||
Returns:
|
||||
str | None: The device response as a string, a parsed JSON value, or None when the response has an unsupported content type.
|
||||
"""
|
||||
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 the device MAC address using the device's HTTP GETMAC endpoint.
|
||||
|
||||
Returns:
|
||||
str: The MAC address with surrounding whitespace removed, or `None` if it could not be retrieved.
|
||||
"""
|
||||
try:
|
||||
mac = await self._async_get(params={"GETMAC": ""})
|
||||
if isinstance(mac, str):
|
||||
return mac.strip()
|
||||
except Exception:
|
||||
_LOGGER.exception(
|
||||
"Failed to get MAC address via HTTP for %s", device.serial_number
|
||||
)
|
||||
return None
|
||||
|
||||
async def async_send_auto_clean_command(
|
||||
self,
|
||||
device: OasisDevice,
|
||||
auto_clean: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Enable or disable the device's auto-clean mode.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): The target Oasis device to send the command to.
|
||||
auto_clean (bool): `True` to enable auto-clean mode, `False` to disable it.
|
||||
"""
|
||||
await self._async_command(
|
||||
params={"WRIAUTOCLEAN": 1 if auto_clean else 0},
|
||||
)
|
||||
|
||||
async def async_send_ball_speed_command(
|
||||
self,
|
||||
device: OasisDevice,
|
||||
speed: int,
|
||||
) -> None:
|
||||
"""
|
||||
Send a ball speed command to the specified device.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): Target device for the command.
|
||||
speed (int): Speed value to set for the device's ball mechanism.
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
Send an LED control command to the device.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): Target device to receive the command.
|
||||
led_effect (str): Effect name or identifier to apply to the LEDs.
|
||||
color (str): Color value recognized by the device (e.g., hex code or device color name).
|
||||
led_speed (int): Animation speed value; larger values increase animation speed.
|
||||
brightness (int): Brightness level for the LEDs.
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
Send a sleep command to the device.
|
||||
|
||||
Requests the device to enter sleep mode.
|
||||
"""
|
||||
await self._async_command(params={"CMDSLEEP": ""})
|
||||
|
||||
async def async_send_move_job_command(
|
||||
self,
|
||||
device: OasisDevice,
|
||||
from_index: int,
|
||||
to_index: int,
|
||||
) -> None:
|
||||
"""
|
||||
Move a job in the device's playlist from one index to another.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): Target device whose job list will be modified.
|
||||
from_index (int): Zero-based index of the job to move.
|
||||
to_index (int): Zero-based destination index where the job will be placed.
|
||||
"""
|
||||
await self._async_command(params={"MOVEJOB": f"{from_index};{to_index}"})
|
||||
|
||||
async def async_send_change_track_command(
|
||||
self,
|
||||
device: OasisDevice,
|
||||
index: int,
|
||||
) -> None:
|
||||
"""
|
||||
Change the device's current track to the specified track index.
|
||||
|
||||
Parameters:
|
||||
index (int): Zero-based index of the track to select.
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
Send an "add joblist" command to the device with a list of track indices.
|
||||
|
||||
The provided track indices are serialized as a comma-separated string and sent to the device using the `ADDJOBLIST` parameter.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): Target device to receive the command.
|
||||
tracks (list[int]): Track indices to add; these are sent as a CSV string (e.g., [1,2,3] -> "1,2,3").
|
||||
"""
|
||||
await self._async_command(params={"ADDJOBLIST": ",".join(map(str, tracks))})
|
||||
|
||||
async def async_send_set_playlist_command(
|
||||
self,
|
||||
device: OasisDevice,
|
||||
playlist: list[int],
|
||||
) -> None:
|
||||
"""
|
||||
Set the device's playlist on the target device and optimistically update the local device state.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): Target device to receive the playlist command; its state will be updated optimistically.
|
||||
playlist (list[int]): Ordered list of track indices to set as the device's playlist.
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
Set the device's playlist repeat flag.
|
||||
|
||||
Parameters:
|
||||
repeat (bool): `True` to enable playlist repeat, `False` to disable it.
|
||||
"""
|
||||
await self._async_command(params={"WRIREPEATJOB": 1 if repeat else 0})
|
||||
|
||||
async def async_send_set_autoplay_command(
|
||||
self,
|
||||
device: OasisDevice,
|
||||
option: str,
|
||||
) -> None:
|
||||
"""
|
||||
Set the device's autoplay (wait-after) option.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): Target device whose autoplay option will be updated.
|
||||
option (str): The value for the device's wait-after/autoplay setting as expected by the device firmware.
|
||||
"""
|
||||
await self._async_command(params={"WRIWAITAFTER": option})
|
||||
|
||||
async def async_send_upgrade_command(
|
||||
self,
|
||||
device: OasisDevice,
|
||||
beta: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Send a firmware upgrade command to the specified device.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): Target device to receive the upgrade command.
|
||||
beta (bool): If True, request the beta firmware; if False, request the stable firmware.
|
||||
"""
|
||||
await self._async_command(params={"CMDUPGRADE": 1 if beta else 0})
|
||||
|
||||
async def async_send_play_command(self, device: OasisDevice) -> None:
|
||||
"""
|
||||
Send the play command to the device.
|
||||
"""
|
||||
await self._async_command(params={"CMDPLAY": ""})
|
||||
|
||||
async def async_send_pause_command(self, device: OasisDevice) -> None:
|
||||
"""
|
||||
Send a pause command to the device.
|
||||
"""
|
||||
await self._async_command(params={"CMDPAUSE": ""})
|
||||
|
||||
async def async_send_stop_command(self, device: OasisDevice) -> None:
|
||||
"""
|
||||
Sends the device stop command to halt playback or activity.
|
||||
|
||||
Sends an HTTP command to request the device stop its current operation.
|
||||
"""
|
||||
await self._async_command(params={"CMDSTOP": ""})
|
||||
|
||||
async def async_send_reboot_command(self, device: OasisDevice) -> None:
|
||||
"""
|
||||
Send a reboot command to the device.
|
||||
|
||||
Sends a reboot request to the target device using the CMDBOOT control parameter.
|
||||
"""
|
||||
await self._async_command(params={"CMDBOOT": ""})
|
||||
|
||||
async def async_get_status(self, device: OasisDevice) -> None:
|
||||
"""
|
||||
Retrieve the device status from the device and apply it to the given OasisDevice.
|
||||
|
||||
If the device does not return a status, the device object is not modified.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): Device instance to update with the fetched status.
|
||||
"""
|
||||
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,834 @@
|
||||
"""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, Iterable
|
||||
|
||||
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
|
||||
"""
|
||||
Initialize internal state for the MQTT transport client.
|
||||
|
||||
Sets up connection state, per-device registries and events, subscription bookkeeping, and a bounded pending command queue capped by MAX_PENDING_COMMANDS.
|
||||
|
||||
Attributes:
|
||||
_client: Active aiomqtt client or None.
|
||||
_loop_task: Background MQTT loop task or None.
|
||||
_connected_at: Timestamp of last successful connection or None.
|
||||
_connected_event: Event signaled when a connection is established.
|
||||
_stop_event: Event signaled to request the loop to stop.
|
||||
_devices: Mapping of device serial to OasisDevice instances.
|
||||
_first_status_events: Per-serial events signaled on receiving the first STATUS message.
|
||||
_mac_events: Per-serial events signaled when a device MAC address is received.
|
||||
_subscribed_serials: Set of serials currently subscribed to STATUS topics.
|
||||
_subscription_lock: Lock protecting subscribe/unsubscribe operations.
|
||||
_command_queue: Bounded queue of pending (serial, payload) commands.
|
||||
"""
|
||||
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 an OasisDevice so MQTT messages for its serial are routed to that device.
|
||||
|
||||
Ensures the device has a serial_number (raises ValueError if not), stores the device in the client's registry, creates per-device asyncio.Events for first-status and MAC-address arrival, attaches this client to the device if it has no client, and schedules a subscription for the device's STATUS topics if the MQTT client is currently connected.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): The device instance to register.
|
||||
|
||||
Raises:
|
||||
ValueError: If `device.serial_number` is not set.
|
||||
"""
|
||||
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 register_devices(self, devices: Iterable[OasisDevice]) -> None:
|
||||
"""
|
||||
Register multiple OasisDevice instances with the client.
|
||||
|
||||
Parameters:
|
||||
devices (Iterable[OasisDevice]): Iterable of devices to register.
|
||||
"""
|
||||
for device in devices:
|
||||
self.register_device(device)
|
||||
|
||||
def unregister_device(self, device: OasisDevice) -> None:
|
||||
"""
|
||||
Unregisters a device from MQTT routing and cleans up related per-device state.
|
||||
|
||||
Removes the device's registration, first-status and MAC events. If there is an active MQTT client and the device's serial is currently subscribed, schedules an asynchronous unsubscription task. If the device has no serial_number, the call is a no-op.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): The device to unregister; must have `serial_number` set.
|
||||
"""
|
||||
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 the device's STATUS topic pattern and mark the device as subscribed.
|
||||
|
||||
Subscribes to "<serial>/STATUS/#" with QoS 1 and records the subscription; does nothing if the MQTT client is not connected or the serial is already subscribed.
|
||||
"""
|
||||
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 the device's STATUS topic and update subscription state.
|
||||
|
||||
If there is no active MQTT client or the serial is not currently subscribed, this is a no-op.
|
||||
Parameters:
|
||||
serial (str): Device serial used to build the topic "<serial>/STATUS/#".
|
||||
"""
|
||||
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 the MQTT client and clean up resources.
|
||||
|
||||
Signals the background MQTT loop to stop, cancels the loop task, disconnects the MQTT client if connected, and clears any pending commands from the internal command queue.
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
Block until the MQTT client is connected and the device has emitted at least one STATUS message.
|
||||
|
||||
If `request_status` is True, a status request is sent after the client is connected to prompt the device to report its state.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): The device to wait for; must have `serial_number` set.
|
||||
timeout (float): Maximum seconds to wait for connection and for the first STATUS message.
|
||||
request_status (bool): If True, issue a status refresh after connection to encourage a STATUS update.
|
||||
|
||||
Returns:
|
||||
bool: `True` if the device's first STATUS message was observed within the timeout, `False` otherwise.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the provided device does not have a `serial_number`.
|
||||
"""
|
||||
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: # noqa: BLE001
|
||||
_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:
|
||||
"""
|
||||
Request a device's MAC address via an MQTT STATUS refresh and return it if available.
|
||||
|
||||
If the device already has a MAC address, it is returned immediately. Otherwise the function requests a status update (which causes the device to publish MAC_ADDRESS) and waits up to 3 seconds for the MAC to arrive.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): The device whose MAC address will be requested.
|
||||
|
||||
Returns:
|
||||
str | None: The device MAC address if obtained, `None` if the wait timed out and no MAC was received.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the provided device has no serial_number set.
|
||||
"""
|
||||
# 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_auto_clean_command(
|
||||
self, device: OasisDevice, auto_clean: bool
|
||||
) -> None:
|
||||
"""
|
||||
Set the device's automatic cleaning mode.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): Target Oasis device to send the command to.
|
||||
auto_clean (bool): True to enable automatic cleaning, False to disable.
|
||||
"""
|
||||
payload = f"WRIAUTOCLEAN={1 if auto_clean else 0}"
|
||||
await self._publish_command(device, payload)
|
||||
|
||||
async def async_send_ball_speed_command(
|
||||
self,
|
||||
device: OasisDevice,
|
||||
speed: int,
|
||||
) -> None:
|
||||
"""
|
||||
Set the device's ball speed.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): Target device.
|
||||
speed (int): Speed value to apply.
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
Send an LED configuration command to the device.
|
||||
|
||||
If `brightness` is greater than zero, the device is woken before sending the command.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): Target device (must have a serial number).
|
||||
led_effect (str): LED effect identifier to apply.
|
||||
color (str): Color value for the LED effect (format expected by device).
|
||||
led_speed (int): Speed/tempo for the LED effect.
|
||||
brightness (int): Brightness level to set; also used to determine wake behavior.
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
Send the sleep command to the specified Oasis device.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): Target device; must have a valid serial_number. If the MQTT client is not connected, the command may be queued for delivery when a connection is available.
|
||||
"""
|
||||
await self._publish_command(device, "CMDSLEEP")
|
||||
|
||||
async def async_send_move_job_command(
|
||||
self,
|
||||
device: OasisDevice,
|
||||
from_index: int,
|
||||
to_index: int,
|
||||
) -> None:
|
||||
"""
|
||||
Move a job in the device's playlist from one index to another.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): Target device to receive the command.
|
||||
from_index (int): Source index of the job in the playlist.
|
||||
to_index (int): Destination index where the job should be placed.
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
Change the device's current track to the specified track index.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): Target Oasis device.
|
||||
index (int): Track index to switch to (zero-based).
|
||||
"""
|
||||
payload = f"CMDCHANGETRACK={index}"
|
||||
await self._publish_command(device, payload)
|
||||
|
||||
async def async_send_add_joblist_command(
|
||||
self,
|
||||
device: OasisDevice,
|
||||
tracks: list[int],
|
||||
) -> None:
|
||||
"""
|
||||
Send an ADDJOBLIST command to add multiple tracks to the device's job list.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): Target device to receive the command.
|
||||
tracks (list[int]): List of track indices to add; elements will be joined as a comma-separated list in the command payload.
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
Set the device's playlist to the specified ordered list of track indices.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): Target Oasis device to receive the playlist command.
|
||||
playlist (list[int]): Ordered list of track indices to apply as the device's playlist.
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
Send a command to enable or disable repeating the device's playlist.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): Target device; must have a serial number.
|
||||
repeat (bool): True to enable playlist repeat, False to disable it.
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
Set the device's wait-after-job / autoplay option.
|
||||
|
||||
Publishes a "WRIWAITAFTER=<option>" command for the specified device to configure how long the device waits after a job or to adjust autoplay behavior.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): Target device (must have a serial_number).
|
||||
option (str): Value accepted by the device firmware for the wait-after-job/autoplay setting (typically a numeric string or predefined option token).
|
||||
"""
|
||||
payload = f"WRIWAITAFTER={option}"
|
||||
await self._publish_command(device, payload)
|
||||
|
||||
async def async_send_upgrade_command(
|
||||
self,
|
||||
device: OasisDevice,
|
||||
beta: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Request a firmware upgrade for the given device.
|
||||
|
||||
Sends an upgrade command to the device and selects the beta channel when requested.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): Target device.
|
||||
beta (bool): If `True`, request a beta firmware upgrade; if `False`, request the stable firmware.
|
||||
"""
|
||||
payload = f"CMDUPGRADE={1 if beta else 0}"
|
||||
await self._publish_command(device, payload)
|
||||
|
||||
async def async_send_play_command(self, device: OasisDevice) -> None:
|
||||
"""
|
||||
Send a "play" command to the given device and wake it if the device is sleeping.
|
||||
"""
|
||||
await self._publish_command(device, "CMDPLAY", True)
|
||||
|
||||
async def async_send_pause_command(self, device: OasisDevice) -> None:
|
||||
"""
|
||||
Sends a pause command to the specified Oasis device.
|
||||
|
||||
Publishes the "CMDPAUSE" command to the device's command topic.
|
||||
"""
|
||||
await self._publish_command(device, "CMDPAUSE")
|
||||
|
||||
async def async_send_stop_command(self, device: OasisDevice) -> None:
|
||||
"""
|
||||
Send the "stop" command to the given Oasis device.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): Target device to receive the stop command; must be registered with a valid serial number.
|
||||
"""
|
||||
await self._publish_command(device, "CMDSTOP")
|
||||
|
||||
async def async_send_reboot_command(self, device: OasisDevice) -> None:
|
||||
"""
|
||||
Send a reboot command to the specified Oasis device.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): Target device to receive the reboot command; must have a valid serial_number.
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
Request the device to publish its current 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:
|
||||
"""
|
||||
Flush queued commands by publishing them to each device's COMMAND/CMD topic.
|
||||
|
||||
This consumes all entries from the internal command queue, skipping entries for devices that are no longer registered, publishing each payload to "<serial>/COMMAND/CMD" with QoS 1, and marking queue tasks done. If a publish fails, the failed command is re-queued and flushing stops so remaining queued commands will be retried on the next reconnect.
|
||||
"""
|
||||
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: # noqa: BLE001
|
||||
_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:
|
||||
"""
|
||||
Publish a command payload to the device's MQTT COMMAND topic, queueing it if the client is not connected.
|
||||
|
||||
If `wake` is True and the device reports it is sleeping, requests a full status refresh before publishing. If the MQTT client is not connected or publish fails, the command is enqueued for later delivery.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): Target device; must have a valid `serial_number`.
|
||||
payload (str): Command payload to send to the device.
|
||||
wake (bool): If True, refresh the device state when the device is sleeping before sending the command.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the provided device has no serial number set.
|
||||
"""
|
||||
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: # noqa: BLE001
|
||||
_LOGGER.debug(
|
||||
"MQTT publish failed, queueing command for %s: %s", serial, payload
|
||||
)
|
||||
await self._enqueue_command(serial, payload)
|
||||
|
||||
async def _mqtt_loop(self) -> None:
|
||||
"""
|
||||
Run the MQTT WebSocket connection loop that maintains connection, subscriptions, and message handling.
|
||||
|
||||
This background coroutine establishes a persistent WSS MQTT connection to the configured broker, sets connection state on successful connect, resubscribes to known device STATUS topics, flushes any queued outbound commands, and dispatches incoming MQTT messages to the status handler. On disconnect or error it clears connection state and subscription tracking, and retries connecting after the configured backoff interval until the client is stopped.
|
||||
"""
|
||||
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: # noqa: BLE001
|
||||
_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 an incoming MQTT STATUS message to an OasisDevice state update.
|
||||
|
||||
Expects msg.topic in the form "<serial>/STATUS/<STATUS_NAME>" and decodes msg.payload as text.
|
||||
If the topic corresponds to a registered device, extracts the relevant status field and calls
|
||||
the device's update_from_status_dict with a mapping of the parsed values. For the "MAC_ADDRESS"
|
||||
status, sets the per-device MAC event to signal arrival of the MAC address. Always sets the
|
||||
per-device first-status event once any status is processed for that serial.
|
||||
|
||||
Parameters:
|
||||
msg (aiomqtt.Message): Incoming MQTT message; topic identifies device serial and status.
|
||||
"""
|
||||
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:
|
||||
_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()
|
||||
222
custom_components/oasis_mini/pyoasiscontrol/clients/transport.py
Normal file
222
custom_components/oasis_mini/pyoasiscontrol/clients/transport.py
Normal file
@@ -0,0 +1,222 @@
|
||||
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:
|
||||
"""
|
||||
Retrieve the MAC address of the specified Oasis device.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): The target device to query.
|
||||
|
||||
Returns:
|
||||
str | None: The device's MAC address as a string, or `None` if the MAC address is unavailable.
|
||||
"""
|
||||
|
||||
async def async_send_auto_clean_command(
|
||||
self, device: OasisDevice, auto_clean: bool
|
||||
) -> None:
|
||||
"""
|
||||
Enable or disable the device's auto-clean mode.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): The target Oasis device to send the command to.
|
||||
auto_clean (bool): `True` to enable auto-clean mode, `False` to disable it.
|
||||
"""
|
||||
|
||||
async def async_send_ball_speed_command(
|
||||
self,
|
||||
device: OasisDevice,
|
||||
speed: int,
|
||||
) -> None:
|
||||
"""
|
||||
Set the device's ball speed to the specified value.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): Target Oasis device to send the command to.
|
||||
speed (int): Desired ball speed value for the device.
|
||||
"""
|
||||
|
||||
async def async_send_led_command(
|
||||
self,
|
||||
device: OasisDevice,
|
||||
led_effect: str,
|
||||
color: str,
|
||||
led_speed: int,
|
||||
brightness: int,
|
||||
) -> None:
|
||||
"""
|
||||
Configure the device's LED effect, color, speed, and brightness.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): Target Oasis device to receive the LED command.
|
||||
led_effect (str): Name or identifier of the LED effect to apply.
|
||||
color (str): Color for the LED effect (format depends on implementation, e.g., hex code or color name).
|
||||
led_speed (int): Effect speed; larger values increase the animation speed.
|
||||
brightness (int): Brightness level as a percentage from 0 to 100.
|
||||
"""
|
||||
|
||||
async def async_send_sleep_command(self, device: OasisDevice) -> None:
|
||||
"""
|
||||
Put the specified Oasis device into sleep mode.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): The target Oasis device to send the sleep command to.
|
||||
"""
|
||||
|
||||
async def async_send_move_job_command(
|
||||
self,
|
||||
device: OasisDevice,
|
||||
from_index: int,
|
||||
to_index: int,
|
||||
) -> None:
|
||||
"""
|
||||
Move a job within the device's job list from one index to another.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): Target Oasis device.
|
||||
from_index (int): Source index of the job in the device's job list.
|
||||
to_index (int): Destination index to move the job to.
|
||||
"""
|
||||
|
||||
async def async_send_change_track_command(
|
||||
self,
|
||||
device: OasisDevice,
|
||||
index: int,
|
||||
) -> None:
|
||||
"""
|
||||
Change the device's current track to the specified track index.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): The target Oasis device to receive the command.
|
||||
index (int): The index of the track to select on the device.
|
||||
"""
|
||||
|
||||
async def async_send_add_joblist_command(
|
||||
self,
|
||||
device: OasisDevice,
|
||||
tracks: list[int],
|
||||
) -> None:
|
||||
"""
|
||||
Add the given sequence of track indices to the device's job list.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): Target Oasis device to receive the new jobs.
|
||||
tracks (list[int]): Ordered list of track indices to append to the device's job list.
|
||||
"""
|
||||
|
||||
async def async_send_set_playlist_command(
|
||||
self,
|
||||
device: OasisDevice,
|
||||
playlist: list[int],
|
||||
) -> None:
|
||||
"""
|
||||
Set the device's current playlist to the provided sequence of track indices.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): The target Oasis device to receive the playlist.
|
||||
playlist (list[int]): Sequence of track indices in the desired playback order.
|
||||
"""
|
||||
|
||||
async def async_send_set_repeat_playlist_command(
|
||||
self,
|
||||
device: OasisDevice,
|
||||
repeat: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Set whether the device should repeat the current playlist.
|
||||
|
||||
Parameters:
|
||||
repeat (bool): True to enable repeating the current playlist, False to disable it.
|
||||
"""
|
||||
|
||||
async def async_send_set_autoplay_command(
|
||||
self,
|
||||
device: OasisDevice,
|
||||
option: str,
|
||||
) -> None:
|
||||
"""
|
||||
Send a command to configure the device's autoplay behavior.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): Target Oasis device to receive the command.
|
||||
option (str): Autoplay option to set (e.g., "on", "off", "shuffle", or other device-supported mode).
|
||||
"""
|
||||
|
||||
async def async_send_upgrade_command(
|
||||
self,
|
||||
device: OasisDevice,
|
||||
beta: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Initiates a firmware upgrade on the given Oasis device.
|
||||
|
||||
If `beta` is True, requests the device to use the beta upgrade channel; otherwise requests the stable channel.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): Target device to upgrade.
|
||||
beta (bool): Whether to use the beta upgrade channel (`True`) or the stable channel (`False`).
|
||||
"""
|
||||
|
||||
async def async_send_play_command(self, device: OasisDevice) -> None:
|
||||
"""
|
||||
Send a play command to the specified Oasis device.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): The target device to instruct to start playback.
|
||||
"""
|
||||
|
||||
async def async_send_pause_command(self, device: OasisDevice) -> None:
|
||||
"""
|
||||
Pause playback on the specified Oasis device.
|
||||
|
||||
This sends a pause command to the device so it stops current playback.
|
||||
"""
|
||||
|
||||
async def async_send_stop_command(self, device: OasisDevice) -> None:
|
||||
"""
|
||||
Send a stop command to the specified Oasis device to halt playback.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): The target Oasis device to receive the stop command.
|
||||
"""
|
||||
|
||||
async def async_send_reboot_command(self, device: OasisDevice) -> None:
|
||||
"""
|
||||
Send a reboot command to the specified Oasis device.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): The target Oasis device to reboot.
|
||||
"""
|
||||
|
||||
async def async_get_all(self, device: OasisDevice) -> None:
|
||||
"""
|
||||
Fetch comprehensive device data for the specified Oasis device.
|
||||
|
||||
This method triggers retrieval of all relevant information (configuration, status, and runtime data) for the given device so the client's representation of that device can be refreshed.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): Target device whose data should be fetched and refreshed.
|
||||
"""
|
||||
|
||||
async def async_get_status(self, device: OasisDevice) -> None:
|
||||
"""
|
||||
Retrieve the current runtime status for the specified Oasis device.
|
||||
|
||||
Implementations should query the device for its current state (for example: playback, LED settings, job/track lists, and connectivity) and update any client-side representation or caches as needed.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): The target device to query.
|
||||
"""
|
||||
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",
|
||||
}
|
||||
762
custom_components/oasis_mini/pyoasiscontrol/device.py
Normal file
762
custom_components/oasis_mini/pyoasiscontrol/device.py
Normal file
@@ -0,0 +1,762 @@
|
||||
"""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
|
||||
BRIGHTNESS_DEFAULT: 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,
|
||||
name: str | None = None,
|
||||
ssid: str | None = None,
|
||||
ip_address: str | None = None,
|
||||
cloud: OasisCloudClient | None = None,
|
||||
client: OasisClientProtocol | None = None,
|
||||
) -> None:
|
||||
# Transport
|
||||
"""
|
||||
Initialize an OasisDevice with identification, network, transport references, and default state fields.
|
||||
|
||||
Parameters:
|
||||
model (str | None): Device model identifier.
|
||||
serial_number (str | None): Device serial number.
|
||||
name (str | None): Human-readable device name; if omitted, defaults to "<model> <serial_number>".
|
||||
ssid (str | None): Last-known Wi-Fi SSID for the device.
|
||||
ip_address (str | None): Last-known IP address for the device.
|
||||
cloud (OasisCloudClient | None): Optional cloud client used to fetch track metadata and remote data.
|
||||
client (OasisClientProtocol | None): Optional transport client used to send commands to the device.
|
||||
|
||||
Notes:
|
||||
- Creates an internal listener list for state-change callbacks.
|
||||
- Initializes status fields (brightness, playlist, playback state, networking, etc.) with sensible defaults.
|
||||
- Initializes a track metadata cache and a placeholder for a background refresh task.
|
||||
"""
|
||||
self._cloud = cloud
|
||||
self._client = client
|
||||
self._listeners: list[Callable[[], None]] = []
|
||||
|
||||
# Details
|
||||
self.model = model
|
||||
self.serial_number = serial_number
|
||||
self.name = name if name else f"{model} {serial_number}"
|
||||
self.ssid = ssid
|
||||
self.ip_address = 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 = BRIGHTNESS_DEFAULT
|
||||
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:
|
||||
"""
|
||||
Current display brightness adjusted for the device sleep state.
|
||||
|
||||
Returns:
|
||||
int: 0 when the device is sleeping, otherwise the stored brightness value.
|
||||
"""
|
||||
return 0 if self.is_sleeping else self._brightness
|
||||
|
||||
@brightness.setter
|
||||
def brightness(self, value: int) -> None:
|
||||
"""
|
||||
Set the device brightness and update brightness_on when non-zero.
|
||||
|
||||
Parameters:
|
||||
value (int): Brightness level to apply; if non-zero, also stored in `brightness_on`.
|
||||
"""
|
||||
self._brightness = value
|
||||
if value:
|
||||
self.brightness_on = value
|
||||
|
||||
@property
|
||||
def is_sleeping(self) -> bool:
|
||||
"""
|
||||
Indicates whether the device is currently in the sleeping status.
|
||||
|
||||
Returns:
|
||||
`true` if the device is sleeping, `false` otherwise.
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
Get the attached transport client, or `None` if no client is attached.
|
||||
|
||||
Returns:
|
||||
The attached transport client, or `None` if not attached.
|
||||
"""
|
||||
return self._client
|
||||
|
||||
def _require_client(self) -> OasisClientProtocol:
|
||||
"""
|
||||
Get the attached transport client for this device.
|
||||
|
||||
Returns:
|
||||
OasisClientProtocol: The attached transport client.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If no client/transport is attached to the device.
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
Update an attribute on the device if the new value differs from the current value.
|
||||
|
||||
Sets the instance attribute named `name` to `value` and logs a debug message when a change occurs.
|
||||
|
||||
Parameters:
|
||||
name (str): The attribute name to update.
|
||||
value (Any): The new value to assign to the attribute.
|
||||
|
||||
Returns:
|
||||
bool: True if the attribute was changed, False otherwise.
|
||||
"""
|
||||
old = getattr(self, name, None)
|
||||
if old != value:
|
||||
_LOGGER.debug(
|
||||
"%s %s changed: '%s' -> '%s'",
|
||||
self.serial_number,
|
||||
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 the device's attributes from a status dictionary.
|
||||
|
||||
Expects a mapping of attribute names to values; known attributes are applied to the device,
|
||||
unknown keys are logged and ignored. If `playlist` or `playlist_index` change, a track
|
||||
refresh is scheduled. If any attribute changed, registered update listeners are notified.
|
||||
"""
|
||||
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 device status string into a structured state dictionary.
|
||||
|
||||
Expects a semicolon-separated string containing at least 18 fields (device status format returned by the device: e.g., HTTP GETSTATUS or MQTT FULLSTATUS). Returns None for empty input or if the string cannot be parsed into the expected fields.
|
||||
|
||||
Parameters:
|
||||
raw_status (str): Semicolon-separated status string from the device.
|
||||
|
||||
Returns:
|
||||
dict[str, Any] | None: A dictionary with these keys on success:
|
||||
- `status_code`, `error`, `ball_speed`, `playlist` (list[int]), `playlist_index`,
|
||||
`progress`, `led_effect`, `led_color_id`, `led_speed`, `brightness`, `color`,
|
||||
`busy`, `download_progress`, `brightness_max`, `wifi_connected`, `repeat_playlist`,
|
||||
`autoplay`, `auto_clean`
|
||||
- `software_version` (str) is included if an additional trailing field is present.
|
||||
Returns `None` if the input is empty or parsing fails.
|
||||
"""
|
||||
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:
|
||||
_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 a semicolon-separated device status string and apply the resulting fields to the device state.
|
||||
|
||||
If the string cannot be parsed into a valid status dictionary, no state is changed.
|
||||
|
||||
Parameters:
|
||||
raw_status (str): Raw status payload received from the device (semicolon-separated fields).
|
||||
"""
|
||||
if status := self.parse_status_string(raw_status):
|
||||
self.update_from_status_dict(status)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Return a mapping of the device's core state fields to their current values.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: A dictionary whose keys are the core state field names (as defined in _STATE_FIELDS)
|
||||
and whose values are the current values for those fields.
|
||||
"""
|
||||
return {field: getattr(self, field) for field in _STATE_FIELDS}
|
||||
|
||||
@property
|
||||
def error_message(self) -> str | None:
|
||||
"""
|
||||
Get the human-readable error message for the current device error code.
|
||||
|
||||
Returns:
|
||||
str: The mapped error message when the device status indicates an error (status code 9); `None` otherwise.
|
||||
"""
|
||||
if self.status_code == 9:
|
||||
return ERROR_CODE_MAP.get(self.error, f"Unknown ({self.error})")
|
||||
return None
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
"""
|
||||
Get a human-readable status description for the current status_code.
|
||||
|
||||
Returns:
|
||||
str: Human-readable status corresponding to the device's status_code, or "Unknown (<code>)" when the code is not recognized.
|
||||
"""
|
||||
return STATUS_CODE_MAP.get(self.status_code, f"Unknown ({self.status_code})")
|
||||
|
||||
@property
|
||||
def track(self) -> dict | None:
|
||||
"""
|
||||
Return the cached track metadata when it corresponds to the current track, otherwise retrieve the built-in track metadata.
|
||||
|
||||
Returns:
|
||||
dict | None: The track metadata dictionary for the current `track_id`, or `None` if no matching track is available.
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
Determine the current track id from the active playlist.
|
||||
|
||||
If the playlist index is beyond the end of the playlist, the first track id is returned.
|
||||
|
||||
Returns:
|
||||
int | None: The current track id, or `None` if there is no playlist.
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
Get the full HTTPS URL for the current track's image if available.
|
||||
|
||||
Returns:
|
||||
str: Full URL to the track image (https://app.grounded.so/uploads/<image>), or `None` if no image is available.
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
Get the current track's display name.
|
||||
|
||||
If the current track has no explicit name, returns "Unknown Title (#{track_id})". If there is no current track, returns None.
|
||||
|
||||
Returns:
|
||||
str | None: The track name, or `None` if no current track is available.
|
||||
"""
|
||||
if track := self.track:
|
||||
return track.get("name", f"Unknown Title (#{self.track_id})")
|
||||
return None
|
||||
|
||||
@property
|
||||
def drawing_progress(self) -> float | None:
|
||||
"""
|
||||
Compute drawing progress percentage for the current track.
|
||||
|
||||
If the current track or its SVG content is unavailable, returns None.
|
||||
|
||||
Returns:
|
||||
progress_percent (float | None): Percentage of the drawing completed (0-100), clamped to 100; `None` if no track or SVG content is available.
|
||||
"""
|
||||
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]]:
|
||||
"""
|
||||
Build a mapping of track IDs in the current playlist to their detail dictionaries, preferring the device's cached/current track data and falling back to built-in TRACKS.
|
||||
|
||||
Returns:
|
||||
dict[int, dict[str, str]]: A mapping from track ID to a details dictionary (contains at least a `'name'` key). If track metadata is available from the device cache or built-in TRACKS it is used; otherwise a fallback `{"name": "Unknown Title (#<id>)"}` is provided.
|
||||
"""
|
||||
base = dict(TRACKS)
|
||||
if (current_id := self.track_id) is not None and self.track:
|
||||
base[current_id] = self.track
|
||||
return {
|
||||
track_id: base.get(track_id, {"name": f"Unknown Title (#{track_id})"})
|
||||
for track_id in self.playlist
|
||||
}
|
||||
|
||||
def create_svg(self) -> str | None:
|
||||
"""
|
||||
Generate an SVG representing the current track at the device's drawing progress.
|
||||
|
||||
Returns:
|
||||
svg (str | None): SVG content for the current track reflecting current progress, or None if track data is unavailable.
|
||||
"""
|
||||
return create_svg(self.track, self.progress)
|
||||
|
||||
def add_update_listener(self, listener: Callable[[], None]) -> Callable[[], None]:
|
||||
"""
|
||||
Register a callback to be invoked whenever the device state changes.
|
||||
|
||||
Parameters:
|
||||
listener (Callable[[], None]): A zero-argument callback that will be called on state updates.
|
||||
|
||||
Returns:
|
||||
Callable[[], None]: An unsubscribe function that removes the registered listener; calling the unsubscribe function multiple times is safe.
|
||||
"""
|
||||
self._listeners.append(listener)
|
||||
|
||||
def _unsub() -> None:
|
||||
"""
|
||||
Remove the previously registered listener from the device's listener list if it is present.
|
||||
|
||||
This function silently does nothing if the listener is not found.
|
||||
"""
|
||||
try:
|
||||
self._listeners.remove(listener)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return _unsub
|
||||
|
||||
def _notify_listeners(self) -> None:
|
||||
"""
|
||||
Invoke all registered update listeners in registration order.
|
||||
|
||||
Each listener is called synchronously; exceptions raised by a listener are caught and logged so other listeners still run.
|
||||
"""
|
||||
for listener in list(self._listeners):
|
||||
try:
|
||||
listener()
|
||||
except Exception:
|
||||
_LOGGER.exception("Error in update listener")
|
||||
|
||||
async def async_get_mac_address(self) -> str | None:
|
||||
"""
|
||||
Get the device MAC address, requesting it from the attached transport client if not already known.
|
||||
|
||||
Returns:
|
||||
mac (str | None): The device MAC address if available, otherwise `None`.
|
||||
"""
|
||||
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_auto_clean(self, auto_clean: bool) -> None:
|
||||
"""
|
||||
Set whether the device performs automatic cleaning.
|
||||
|
||||
Parameters:
|
||||
auto_clean (bool): `True` to enable automatic cleaning, `False` to disable it.
|
||||
"""
|
||||
client = self._require_client()
|
||||
await client.async_send_auto_clean_command(self, auto_clean)
|
||||
|
||||
async def async_set_ball_speed(self, speed: int) -> None:
|
||||
"""
|
||||
Set the device's ball speed.
|
||||
|
||||
Parameters:
|
||||
speed (int): Desired ball speed in the allowed range (BALL_SPEED_MIN to BALL_SPEED_MAX, inclusive).
|
||||
|
||||
Raises:
|
||||
ValueError: If `speed` is outside the allowed range.
|
||||
"""
|
||||
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 device LED effect, color, speed, and brightness.
|
||||
|
||||
Parameters:
|
||||
led_effect (str | None): LED effect name; if None, the device's current effect is used. Must be one of the supported LED effects.
|
||||
color (str | None): Hex color string (e.g. "#rrggbb"); if None, the device's current color is used or `#ffffff` if unset.
|
||||
led_speed (int | None): LED animation speed; if None, the device's current speed is used. Must be within the allowed LED speed range.
|
||||
brightness (int | None): Brightness level; if None, the device's current brightness is used. Must be between 0 and the device's `brightness_max`.
|
||||
|
||||
Raises:
|
||||
ValueError: If `led_effect` is not supported, or `led_speed` or `brightness` are outside their valid ranges.
|
||||
RuntimeError: If no transport client is attached to the device.
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
Put the device into sleep mode.
|
||||
|
||||
Sends a sleep command to the attached transport client.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If no client is attached.
|
||||
"""
|
||||
client = self._require_client()
|
||||
await client.async_send_sleep_command(self)
|
||||
|
||||
async def async_move_track(self, from_index: int, to_index: int) -> None:
|
||||
"""
|
||||
Move a track within the device's playlist from one index to another.
|
||||
|
||||
Parameters:
|
||||
from_index (int): Index of the track to move within the current playlist.
|
||||
to_index (int): Destination index where the track should be placed.
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
Change the device's current track to the track at the given playlist index.
|
||||
|
||||
Parameters:
|
||||
index (int): Zero-based index of the track in the device's current playlist.
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
Add one or more tracks to the device's playlist via the attached client.
|
||||
|
||||
Parameters:
|
||||
track (int | Iterable[int]): A single track id or an iterable of track ids to append to the playlist.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If no transport client is attached to the device.
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
Set the device's playlist to the provided track or tracks.
|
||||
|
||||
Accepts a single track ID or an iterable of track IDs and replaces the device's playlist by sending the corresponding command to the attached client.
|
||||
|
||||
Parameters:
|
||||
playlist (int | Iterable[int]): A single track ID or an iterable of track IDs to set as the new playlist.
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
Set whether the device's playlist should repeat.
|
||||
|
||||
Parameters:
|
||||
repeat (bool): True to enable repeating the playlist, False to disable it.
|
||||
"""
|
||||
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 the device's autoplay / wait-after option.
|
||||
|
||||
Parameters:
|
||||
option (bool | int | str): Desired autoplay/wait-after value. If a `bool` is provided, `True` is converted to `"0"` and `False` to `"1"`. Integer or string values are sent as their string representation.
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
Initiates a firmware upgrade on the device.
|
||||
|
||||
Parameters:
|
||||
beta (bool): If True, request a beta (pre-release) firmware; otherwise request the stable firmware.
|
||||
"""
|
||||
client = self._require_client()
|
||||
await client.async_send_upgrade_command(self, beta)
|
||||
|
||||
async def async_play(self) -> None:
|
||||
"""
|
||||
Send a play command to the device via the attached transport client.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If no transport client is attached.
|
||||
"""
|
||||
client = self._require_client()
|
||||
await client.async_send_play_command(self)
|
||||
|
||||
async def async_pause(self) -> None:
|
||||
"""
|
||||
Pause playback on the device.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If no transport client is attached.
|
||||
"""
|
||||
client = self._require_client()
|
||||
await client.async_send_pause_command(self)
|
||||
|
||||
async def async_stop(self) -> None:
|
||||
"""
|
||||
Stop playback on the device by sending a stop command through the attached transport client.
|
||||
|
||||
Raises:
|
||||
RuntimeError: if no transport client is attached to the device.
|
||||
"""
|
||||
client = self._require_client()
|
||||
await client.async_send_stop_command(self)
|
||||
|
||||
async def async_reboot(self) -> None:
|
||||
"""
|
||||
Reboots the device using the attached transport client.
|
||||
|
||||
Requests the attached client to send a reboot command to the device.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If no transport client is attached.
|
||||
"""
|
||||
client = self._require_client()
|
||||
await client.async_send_reboot_command(self)
|
||||
|
||||
def schedule_track_refresh(self) -> None:
|
||||
"""
|
||||
Schedule a background refresh of the current track metadata when the device's track may have changed.
|
||||
|
||||
Does nothing if no cloud client is attached or if there is no running event loop. If a previous refresh task is still pending, it is cancelled before a new background task is scheduled.
|
||||
"""
|
||||
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 cached information for the current track by fetching details from the attached cloud client and notify listeners when updated.
|
||||
|
||||
If no cloud client is attached, no current track exists, or the cached track already matches the current track id, the method returns without change. On successful fetch, updates the device's track cache and invokes registered update listeners.
|
||||
"""
|
||||
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:
|
||||
_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,11 +1,19 @@
|
||||
"""Oasis Mini utils."""
|
||||
"""Oasis control utils."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from datetime import UTC, datetime
|
||||
import logging
|
||||
import math
|
||||
from typing import Any
|
||||
from xml.etree.ElementTree import Element, SubElement, tostring
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
APP_KEY = "5joW8W4Usk4xUXu5bIIgGiHloQmzMZUMgz6NWQnNI04="
|
||||
|
||||
BACKGROUND_FILL = ("#CCC9C4", "#28292E")
|
||||
COLOR_DARK = ("#28292E", "#F4F5F8")
|
||||
@@ -20,13 +28,41 @@ def _bit_to_bool(val: str) -> bool:
|
||||
return val == "1"
|
||||
|
||||
|
||||
def draw_svg(track: dict, progress: int, model_id: str) -> str | None:
|
||||
"""Draw SVG."""
|
||||
def _parse_int(val: Any | None) -> int:
|
||||
"""
|
||||
Parse a string into an integer, falling back to 0 when conversion fails.
|
||||
|
||||
Parameters:
|
||||
val (Any | None): String potentially containing an integer value.
|
||||
|
||||
Returns:
|
||||
int: The parsed integer, or 0 if `val` cannot be converted.
|
||||
"""
|
||||
try:
|
||||
return int(val)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def create_svg(track: dict, progress: int) -> str | None:
|
||||
"""
|
||||
Create an SVG visualization of a track showing progress as a completed path and indicator.
|
||||
|
||||
Builds an SVG representation from the track's "svg_content" and the provided progress value. If progress is supplied, the function will decrypt the stored SVG content (if needed), compute which path segments are complete using the track's optional "reduced_svg_content_new" value or the number of path segments, and render a base arc, completed arc, track, completed track segment, background circle, and a ball indicator positioned at the current progress point. Returns None if input is missing or an error occurs.
|
||||
|
||||
Parameters:
|
||||
track (dict): Track data containing at minimum an "svg_content" entry and optionally "reduced_svg_content_new" to indicate total segments.
|
||||
progress (int): Current progress expressed as a count relative to the track's total segments.
|
||||
|
||||
Returns:
|
||||
str | None: Serialized SVG markup as a UTF-8 string when successful, otherwise `None`.
|
||||
"""
|
||||
if track and (svg_content := track.get("svg_content")):
|
||||
try:
|
||||
if progress is not None:
|
||||
svg_content = decrypt_svg_content(svg_content)
|
||||
paths = svg_content.split("L")
|
||||
total = track.get("reduced_svg_content", {}).get(model_id, len(paths))
|
||||
total = track.get("reduced_svg_content_new", 0) or len(paths)
|
||||
percent = min((100 * progress) / total, 100)
|
||||
progress = math.floor((percent / 100) * (len(paths) - 1))
|
||||
|
||||
@@ -134,6 +170,35 @@ def draw_svg(track: dict, progress: int, model_id: str) -> str | None:
|
||||
)
|
||||
|
||||
return tostring(svg).decode()
|
||||
except Exception as e:
|
||||
_LOGGER.exception(e)
|
||||
except Exception:
|
||||
_LOGGER.exception("Error creating svg")
|
||||
return None
|
||||
|
||||
|
||||
def decrypt_svg_content(svg_content: dict[str, str]):
|
||||
"""Decrypt SVG content using AES CBC mode."""
|
||||
if decrypted := svg_content.get("decrypted"):
|
||||
return decrypted
|
||||
|
||||
# decode base64-encoded data
|
||||
key = base64.b64decode(APP_KEY)
|
||||
iv = base64.b64decode(svg_content["iv"])
|
||||
ciphertext = base64.b64decode(svg_content["content"])
|
||||
|
||||
# create the cipher and decrypt the ciphertext
|
||||
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
|
||||
decryptor = cipher.decryptor()
|
||||
decrypted = decryptor.update(ciphertext) + decryptor.finalize()
|
||||
|
||||
# remove PKCS7 padding
|
||||
pad_len = decrypted[-1]
|
||||
decrypted = decrypted[:-pad_len].decode("utf-8")
|
||||
|
||||
# save decrypted data so we don't have to do this each time
|
||||
svg_content["decrypted"] = decrypted
|
||||
|
||||
return decrypted
|
||||
|
||||
|
||||
def now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
@@ -1,447 +0,0 @@
|
||||
"""Oasis Mini API client."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Awaitable, Final
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from aiohttp import ClientResponseError, ClientSession
|
||||
import async_timeout
|
||||
|
||||
from .const import TRACKS
|
||||
from .utils import _bit_to_bool
|
||||
|
||||
_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
|
||||
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) -> None:
|
||||
"""Add track to playlist."""
|
||||
if not track:
|
||||
return
|
||||
|
||||
if 0 in self.playlist:
|
||||
playlist = [t for t in self.playlist if t] + [track]
|
||||
return await self.async_set_playlist(playlist)
|
||||
|
||||
await self._async_command(params={"ADDJOBLIST": track})
|
||||
self.playlist.append(track)
|
||||
|
||||
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]) -> None:
|
||||
"""Set the playlist."""
|
||||
if is_playing := (self.status_code == 4):
|
||||
await self.async_stop()
|
||||
await self._async_command(params={"WRIJOBLIST": ",".join(map(str, playlist))})
|
||||
self.playlist = playlist
|
||||
if is_playing:
|
||||
await self.async_play()
|
||||
|
||||
async def async_set_repeat_playlist(self, repeat: bool) -> None:
|
||||
"""Set repeat playlist."""
|
||||
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,74 +1,69 @@
|
||||
"""Oasis Mini select entity."""
|
||||
"""Oasis device select entity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import OasisMiniConfigEntry
|
||||
from .coordinator import OasisMiniCoordinator
|
||||
from .entity import OasisMiniEntity
|
||||
from .pyoasismini import AUTOPLAY_MAP, OasisMini
|
||||
from .pyoasismini.const import TRACKS
|
||||
from . import OasisDeviceConfigEntry, setup_platform_from_coordinator
|
||||
from .coordinator import OasisDeviceCoordinator
|
||||
from .entity import OasisDeviceEntity
|
||||
from .pyoasiscontrol import OasisDevice
|
||||
from .pyoasiscontrol.const import AUTOPLAY_MAP, TRACKS
|
||||
|
||||
AUTOPLAY_MAP_LIST = list(AUTOPLAY_MAP)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class OasisMiniSelectEntityDescription(SelectEntityDescription):
|
||||
"""Oasis Mini select entity description."""
|
||||
def playlists_update_handler(entity: OasisDeviceSelectEntity) -> None:
|
||||
"""
|
||||
Update the playlists select options and current option from the device's cloud playlists.
|
||||
|
||||
current_value: Callable[[OasisMini], Any]
|
||||
select_fn: Callable[[OasisMini, int], Awaitable[None]]
|
||||
update_handler: Callable[[OasisMiniSelectEntity], None] | None = None
|
||||
Iterates the device's cloud playlists to build a display list of playlist names (appending " (N)" for duplicate names)
|
||||
and sets the entity's options to that list. If the device's current playlist matches a playlist's pattern IDs,
|
||||
sets the entity's current option to that playlist's display name; otherwise leaves it None.
|
||||
|
||||
|
||||
class OasisMiniSelectEntity(OasisMiniEntity, SelectEntity):
|
||||
"""Oasis Mini select entity."""
|
||||
|
||||
entity_description: OasisMiniSelectEntityDescription
|
||||
_current_value: Any | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: OasisMiniCoordinator,
|
||||
description: EntityDescription,
|
||||
) -> None:
|
||||
"""Construct an Oasis Mini select entity."""
|
||||
super().__init__(coordinator, description)
|
||||
self._handle_coordinator_update()
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Change the selected option."""
|
||||
await self.entity_description.select_fn(self.device, self.options.index(option))
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
new_value = self.entity_description.current_value(self.device)
|
||||
if self._current_value == new_value:
|
||||
return
|
||||
self._current_value = new_value
|
||||
if update_handler := self.entity_description.update_handler:
|
||||
update_handler(self)
|
||||
else:
|
||||
self._attr_current_option = getattr(
|
||||
self.device, self.entity_description.key
|
||||
)
|
||||
if self.hass:
|
||||
return super()._handle_coordinator_update()
|
||||
|
||||
|
||||
def playlist_update_handler(entity: OasisMiniSelectEntity) -> None:
|
||||
"""Handle playlist updates."""
|
||||
Parameters:
|
||||
entity (OasisDeviceSelectEntity): The select entity to update.
|
||||
"""
|
||||
# pylint: disable=protected-access
|
||||
device = entity.device
|
||||
options = [
|
||||
device._playlist.get(track, {}).get(
|
||||
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:
|
||||
"""
|
||||
Update the select options and current selection for the device's playback queue.
|
||||
|
||||
Populate the entity's options from the device's current playlist and playlist details, disambiguating duplicate track names by appending a counter (e.g., "Title (2)"). Set the entity's current option to the track at device.playlist_index (or None if the queue is empty).
|
||||
|
||||
Parameters:
|
||||
entity (OasisDeviceSelectEntity): The select entity whose options and current option will be updated.
|
||||
"""
|
||||
# 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",
|
||||
@@ -77,40 +72,141 @@ def playlist_update_handler(entity: OasisMiniSelectEntity) -> None:
|
||||
else str(track),
|
||||
),
|
||||
)
|
||||
for track in device.playlist
|
||||
]
|
||||
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, # noqa: ARG001
|
||||
entry: OasisDeviceConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""
|
||||
Set up select entities for each Oasis device from a config entry.
|
||||
|
||||
Creates OasisDeviceSelectEntity instances for every device and descriptor and registers them with Home Assistant via the platform setup.
|
||||
|
||||
Parameters:
|
||||
hass (HomeAssistant): Home Assistant core object.
|
||||
entry (OasisDeviceConfigEntry): Configuration entry containing runtime data and devices to expose.
|
||||
async_add_entities (AddEntitiesCallback): Callback to add created entities to Home Assistant.
|
||||
"""
|
||||
|
||||
def make_entities(new_devices: list[OasisDevice]):
|
||||
"""
|
||||
Create select entity instances for each provided Oasis device.
|
||||
|
||||
Parameters:
|
||||
new_devices (list[OasisDevice]): Devices to create select entities for.
|
||||
|
||||
Returns:
|
||||
list[OasisDeviceSelectEntity]: A flat list of OasisDeviceSelectEntity objects created for every combination of device and descriptor.
|
||||
"""
|
||||
return [
|
||||
OasisDeviceSelectEntity(entry.runtime_data, device, descriptor)
|
||||
for device in new_devices
|
||||
for descriptor in DESCRIPTORS
|
||||
]
|
||||
|
||||
setup_platform_from_coordinator(entry, async_add_entities, make_entities)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class OasisDeviceSelectEntityDescription(SelectEntityDescription):
|
||||
"""Oasis device select entity description."""
|
||||
|
||||
current_value: Callable[[OasisDevice], Any]
|
||||
select_fn: Callable[[OasisDevice, int], Awaitable[None]]
|
||||
update_handler: Callable[[OasisDeviceSelectEntity], None] | None = None
|
||||
|
||||
|
||||
DESCRIPTORS = (
|
||||
OasisMiniSelectEntityDescription(
|
||||
key="playlist",
|
||||
name="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,
|
||||
),
|
||||
OasisMiniSelectEntityDescription(
|
||||
OasisDeviceSelectEntityDescription(
|
||||
key="autoplay",
|
||||
name="Autoplay",
|
||||
options=list(AUTOPLAY_MAP.values()),
|
||||
current_value=lambda device: device.autoplay,
|
||||
select_fn=lambda device, option: device.async_set_autoplay(option),
|
||||
translation_key="autoplay",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
]
|
||||
)
|
||||
class OasisDeviceSelectEntity(OasisDeviceEntity, SelectEntity):
|
||||
"""Oasis device select entity."""
|
||||
|
||||
entity_description: OasisDeviceSelectEntityDescription
|
||||
_current_value: Any | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: OasisDeviceCoordinator,
|
||||
device: OasisDevice,
|
||||
description: EntityDescription,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the Oasis device select entity and perform an initial coordinator update.
|
||||
|
||||
Parameters:
|
||||
coordinator (OasisDeviceCoordinator): Coordinator that manages device updates.
|
||||
device (OasisDevice): The Oasis device this entity represents.
|
||||
description (EntityDescription): Metadata describing this select entity.
|
||||
"""
|
||||
super().__init__(coordinator, device, description)
|
||||
self._handle_coordinator_update()
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""
|
||||
Select and apply the option identified by its display string.
|
||||
|
||||
Parameters:
|
||||
option (str): The display string of the option to select; the option's index in the current options list is used to apply the selection.
|
||||
"""
|
||||
await self.entity_description.select_fn(self.device, self.options.index(option))
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""
|
||||
Update the entity's cached value and current option when coordinator data changes.
|
||||
|
||||
If the derived current value differs from the stored value, update the stored value.
|
||||
If the entity description provides an update_handler, call it with this entity; otherwise,
|
||||
set the entity's current option to the string form of the device attribute named by the
|
||||
description's key. If Home Assistant is available on the entity, delegate to the base
|
||||
class's _handle_coordinator_update to propagate the state change.
|
||||
"""
|
||||
new_value = self.entity_description.current_value(self.device)
|
||||
if self._current_value == new_value:
|
||||
return
|
||||
self._current_value = new_value
|
||||
if update_handler := self.entity_description.update_handler:
|
||||
update_handler(self)
|
||||
else:
|
||||
self._attr_current_option = str(
|
||||
getattr(self.device, self.entity_description.key)
|
||||
)
|
||||
if self.hass:
|
||||
return super()._handle_coordinator_update()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Oasis Mini sensor entity."""
|
||||
"""Oasis device sensor entity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -11,73 +11,84 @@ from homeassistant.const import PERCENTAGE, EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import OasisMiniConfigEntry
|
||||
from .coordinator import OasisMiniCoordinator
|
||||
from .entity import OasisMiniEntity
|
||||
from . import OasisDeviceConfigEntry, setup_platform_from_coordinator
|
||||
from .entity import OasisDeviceEntity
|
||||
from .pyoasiscontrol import OasisDevice
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: OasisMiniConfigEntry,
|
||||
hass: HomeAssistant, # noqa: ARG001
|
||||
entry: OasisDeviceConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Oasis Mini sensors using config entry."""
|
||||
coordinator: OasisMiniCoordinator = entry.runtime_data
|
||||
entities = [
|
||||
OasisMiniSensorEntity(coordinator, descriptor) for descriptor in DESCRIPTORS
|
||||
]
|
||||
if coordinator.device.access_token:
|
||||
entities.extend(
|
||||
[
|
||||
OasisMiniSensorEntity(coordinator, descriptor)
|
||||
for descriptor in CLOUD_DESCRIPTORS
|
||||
]
|
||||
)
|
||||
async_add_entities(entities)
|
||||
"""
|
||||
Set up and register sensor entities for each Oasis device in the config entry.
|
||||
|
||||
Creates sensor entities for every Oasis device available on the provided config entry and adds them to Home Assistant via the provided add-entities callback.
|
||||
|
||||
Parameters:
|
||||
hass (HomeAssistant): Home Assistant core object.
|
||||
entry (OasisDeviceConfigEntry): Configuration entry containing runtime data and devices to expose.
|
||||
async_add_entities (AddEntitiesCallback): Callback to add created entities to Home Assistant.
|
||||
"""
|
||||
|
||||
def make_entities(new_devices: list[OasisDevice]):
|
||||
"""
|
||||
Create sensor entity instances for each Oasis device and each sensor descriptor.
|
||||
|
||||
Parameters:
|
||||
new_devices (list[OasisDevice]): Devices to create sensor entities for.
|
||||
|
||||
Returns:
|
||||
list[OasisDeviceSensorEntity]: A list containing one sensor entity per combination of device and descriptor from DESCRIPTORS.
|
||||
"""
|
||||
return [
|
||||
OasisDeviceSensorEntity(entry.runtime_data, device, descriptor)
|
||||
for device in new_devices
|
||||
for descriptor in DESCRIPTORS
|
||||
]
|
||||
|
||||
setup_platform_from_coordinator(entry, async_add_entities, make_entities)
|
||||
|
||||
|
||||
DESCRIPTORS = {
|
||||
DESCRIPTORS = [
|
||||
SensorEntityDescription(
|
||||
key="download_progress",
|
||||
translation_key="download_progress",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
name="Download progress",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
} | {
|
||||
SensorEntityDescription(
|
||||
key=key,
|
||||
name=key.replace("_", " ").capitalize(),
|
||||
translation_key=key,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
)
|
||||
for key in (
|
||||
"busy",
|
||||
"error",
|
||||
"led_color_id",
|
||||
"status",
|
||||
"wifi_connected",
|
||||
)
|
||||
}
|
||||
|
||||
CLOUD_DESCRIPTORS = (
|
||||
SensorEntityDescription(
|
||||
key="drawing_progress",
|
||||
translation_key="drawing_progress",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
name="Drawing progress",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
]
|
||||
DESCRIPTORS.extend(
|
||||
SensorEntityDescription(
|
||||
key=key,
|
||||
translation_key=key,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
)
|
||||
for key in ("error", "led_color_id", "status")
|
||||
)
|
||||
|
||||
|
||||
class OasisMiniSensorEntity(OasisMiniEntity, SensorEntity):
|
||||
"""Oasis Mini sensor entity."""
|
||||
class OasisDeviceSensorEntity(OasisDeviceEntity, SensorEntity):
|
||||
"""Oasis device sensor entity."""
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | None:
|
||||
"""Return the value reported by the sensor."""
|
||||
"""
|
||||
Provide the current sensor value from the underlying device.
|
||||
|
||||
Returns:
|
||||
`str` with the sensor's current value, or `None` if the attribute is not present or has no value. The value is taken from the device attribute named by the entity description's `key`.
|
||||
"""
|
||||
return getattr(self.device, self.entity_description.key)
|
||||
|
||||
@@ -3,24 +3,29 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
"email": "[%key:common::config_flow::data::email%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
"email": "[%key:common::config_flow::data::email%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"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%]",
|
||||
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"wrong_account": "Account used for the integration should not change"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
@@ -38,7 +43,91 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"button": {
|
||||
"random_track": {
|
||||
"name": "Play random track"
|
||||
},
|
||||
"sleep": {
|
||||
"name": "Sleep"
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
"busy": {
|
||||
"name": "Busy"
|
||||
},
|
||||
"wifi_status": {
|
||||
"name": "Wi-Fi status"
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
"led": {
|
||||
"name": "LED"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"ball_speed": {
|
||||
"name": "Ball speed"
|
||||
},
|
||||
"led_speed": {
|
||||
"name": "LED speed"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"autoplay": {
|
||||
"name": "Autoplay",
|
||||
"state": {
|
||||
"0": "on",
|
||||
"1": "off",
|
||||
"2": "5 minutes",
|
||||
"3": "10 minutes",
|
||||
"4": "30 minutes",
|
||||
"6": "1 hour",
|
||||
"7": "6 hours",
|
||||
"8": "12 hours",
|
||||
"5": "24 hours"
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
"name": "Playlist"
|
||||
},
|
||||
"queue": {
|
||||
"name": "Queue"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"download_progress": {
|
||||
"name": "Download progress"
|
||||
},
|
||||
"drawing_progress": {
|
||||
"name": "Drawing progress"
|
||||
},
|
||||
"error": {
|
||||
"name": "Error",
|
||||
"state": {
|
||||
"0": "None",
|
||||
"1": "Error has occurred while reading the flash memory",
|
||||
"2": "Error while starting the Wifi",
|
||||
"3": "Error when starting DNS settings for your machine",
|
||||
"4": "Failed to open the file to write",
|
||||
"5": "Not enough memory to perform the upgrade",
|
||||
"6": "Error while trying to upgrade your system",
|
||||
"7": "Error while trying to download the new version of the software",
|
||||
"8": "Error while reading the upgrading file",
|
||||
"9": "Failed to start downloading the upgrade file",
|
||||
"10": "Error while starting downloading the job file",
|
||||
"11": "Error while opening the file folder",
|
||||
"12": "Failed to delete a file",
|
||||
"13": "Error while opening the job file",
|
||||
"14": "You have wrong power adapter",
|
||||
"15": "Failed to update the device IP on Oasis Server",
|
||||
"16": "Your device failed centering itself",
|
||||
"17": "There appears to be an issue with your Oasis Device",
|
||||
"18": "Error while downloading the job file"
|
||||
}
|
||||
},
|
||||
"led_color_id": {
|
||||
"name": "LED color ID"
|
||||
},
|
||||
"status": {
|
||||
"name": "Status",
|
||||
"state": {
|
||||
@@ -47,12 +136,30 @@
|
||||
"centering": "Centering",
|
||||
"playing": "Playing",
|
||||
"paused": "Paused",
|
||||
"sleeping": "Sleeping",
|
||||
"error": "Error",
|
||||
"updating": "Updating",
|
||||
"downloading": "Downloading",
|
||||
"busy": "Busy",
|
||||
"live": "Live drawing"
|
||||
}
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"auto_clean": {
|
||||
"name": "Auto-clean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"device_busy": {
|
||||
"message": "{name} is currently busy and cannot be modified"
|
||||
},
|
||||
"invalid_media": {
|
||||
"message": "Invalid media: {media}"
|
||||
},
|
||||
"playlists_unsupported": {
|
||||
"message": "Playlists are not currently supported"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +1,86 @@
|
||||
# """Oasis Mini switch entity."""
|
||||
"""Oasis device switch entity."""
|
||||
|
||||
# from __future__ import annotations
|
||||
from __future__ import annotations
|
||||
|
||||
# from typing import Any
|
||||
from typing import Any
|
||||
|
||||
# from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
# from homeassistant.core import HomeAssistant
|
||||
# from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
# from . import OasisMiniConfigEntry
|
||||
# from .entity import OasisMiniEntity
|
||||
from . import OasisDeviceConfigEntry, setup_platform_from_coordinator
|
||||
from .entity import OasisDeviceEntity
|
||||
from .pyoasiscontrol import OasisDevice
|
||||
|
||||
|
||||
# async def async_setup_entry(
|
||||
# hass: HomeAssistant,
|
||||
# entry: OasisMiniConfigEntry,
|
||||
# async_add_entities: AddEntitiesCallback,
|
||||
# ) -> None:
|
||||
# """Set up Oasis Mini switchs using config entry."""
|
||||
# async_add_entities(
|
||||
# [
|
||||
# OasisMiniSwitchEntity(entry.runtime_data, descriptor)
|
||||
# for descriptor in DESCRIPTORS
|
||||
# ]
|
||||
# )
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, # noqa: ARG001
|
||||
entry: OasisDeviceConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""
|
||||
Set up Oasis device switch entities for a config entry.
|
||||
|
||||
Creates an OasisDeviceSwitchEntity for each OasisDevice associated with the given config entry (one entity per descriptor in DESCRIPTORS) and registers them with Home Assistant via the coordinator helper.
|
||||
|
||||
Parameters:
|
||||
hass (HomeAssistant): Home Assistant core instance.
|
||||
entry (OasisDeviceConfigEntry): Config entry containing runtime data used to create device update entities.
|
||||
async_add_entities (AddEntitiesCallback): Callback to add created entities to Home Assistant.
|
||||
"""
|
||||
|
||||
def make_entities(new_devices: list[OasisDevice]):
|
||||
"""
|
||||
Create OasisDeviceSwitchEntity instances for each device and descriptor.
|
||||
|
||||
Parameters:
|
||||
new_devices (list[OasisDevice]): Devices to wrap as switch entities.
|
||||
|
||||
Returns:
|
||||
list[OasisDeviceSwitchEntity]: A list containing one switch entity per device per descriptor from DESCRIPTORS.
|
||||
"""
|
||||
return [
|
||||
OasisDeviceSwitchEntity(entry.runtime_data, device, descriptor)
|
||||
for device in new_devices
|
||||
for descriptor in DESCRIPTORS
|
||||
]
|
||||
|
||||
setup_platform_from_coordinator(entry, async_add_entities, make_entities)
|
||||
|
||||
|
||||
# class OasisMiniSwitchEntity(OasisMiniEntity, SwitchEntity):
|
||||
# """Oasis Mini switch entity."""
|
||||
|
||||
# @property
|
||||
# def is_on(self) -> bool:
|
||||
# """Return True if entity is on."""
|
||||
# return int(getattr(self.device, self.entity_description.key))
|
||||
|
||||
# async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
# """Turn the entity off."""
|
||||
# await self.device.async_set_repeat_playlist(False)
|
||||
# await self.coordinator.async_request_refresh()
|
||||
|
||||
# async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
# """Turn the entity on."""
|
||||
# await self.device.async_set_repeat_playlist(True)
|
||||
# await self.coordinator.async_request_refresh()
|
||||
DESCRIPTORS = (
|
||||
SwitchEntityDescription(
|
||||
key="auto_clean",
|
||||
translation_key="auto_clean",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# DESCRIPTORS = {
|
||||
# SwitchEntityDescription(
|
||||
# key="repeat_playlist",
|
||||
# name="Repeat playlist",
|
||||
# ),
|
||||
# }
|
||||
class OasisDeviceSwitchEntity(OasisDeviceEntity, SwitchEntity):
|
||||
"""Oasis device switch entity."""
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""
|
||||
Determine whether the switch entity is currently on.
|
||||
|
||||
Returns:
|
||||
bool: `True` if the underlying device attribute named by this entity's description key is truthy, `False` otherwise.
|
||||
"""
|
||||
return bool(getattr(self.device, self.entity_description.key))
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""
|
||||
Disable the device's automatic cleaning mode.
|
||||
|
||||
Sets the device's auto_clean setting to off.
|
||||
"""
|
||||
await self.device.async_set_auto_clean(False)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""
|
||||
Enable the device's auto-clean feature.
|
||||
"""
|
||||
await self.device.async_set_auto_clean(True)
|
||||
|
||||
@@ -3,24 +3,29 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "Host"
|
||||
"email": "Email",
|
||||
"password": "Password"
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"host": "Host"
|
||||
"email": "Email",
|
||||
"password": "Password"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect",
|
||||
"invalid_auth": "Invalid authentication",
|
||||
"invalid_host": "Invalid hostname or IP address",
|
||||
"timeout_connect": "Timeout establishing connection",
|
||||
"unknown": "Unexpected error"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Device is already configured",
|
||||
"reconfigure_successful": "Re-configuration was successful"
|
||||
"already_configured": "Account is already configured",
|
||||
"reauth_successful": "Re-authentication was successful",
|
||||
"reconfigure_successful": "Re-configuration was successful",
|
||||
"wrong_account": "Account used for the integration should not change"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
@@ -38,7 +43,91 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"button": {
|
||||
"random_track": {
|
||||
"name": "Play random track"
|
||||
},
|
||||
"sleep": {
|
||||
"name": "Sleep"
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
"busy": {
|
||||
"name": "Busy"
|
||||
},
|
||||
"wifi_status": {
|
||||
"name": "Wi-Fi status"
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
"led": {
|
||||
"name": "LED"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"ball_speed": {
|
||||
"name": "Ball speed"
|
||||
},
|
||||
"led_speed": {
|
||||
"name": "LED speed"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"autoplay": {
|
||||
"name": "Autoplay",
|
||||
"state": {
|
||||
"0": "on",
|
||||
"1": "off",
|
||||
"2": "5 minutes",
|
||||
"3": "10 minutes",
|
||||
"4": "30 minutes",
|
||||
"6": "1 hour",
|
||||
"7": "6 hours",
|
||||
"8": "12 hours",
|
||||
"5": "24 hours"
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
"name": "Playlist"
|
||||
},
|
||||
"queue": {
|
||||
"name": "Queue"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"download_progress": {
|
||||
"name": "Download progress"
|
||||
},
|
||||
"drawing_progress": {
|
||||
"name": "Drawing progress"
|
||||
},
|
||||
"error": {
|
||||
"name": "Error",
|
||||
"state": {
|
||||
"0": "None",
|
||||
"1": "Error has occurred while reading the flash memory",
|
||||
"2": "Error while starting the Wifi",
|
||||
"3": "Error when starting DNS settings for your machine",
|
||||
"4": "Failed to open the file to write",
|
||||
"5": "Not enough memory to perform the upgrade",
|
||||
"6": "Error while trying to upgrade your system",
|
||||
"7": "Error while trying to download the new version of the software",
|
||||
"8": "Error while reading the upgrading file",
|
||||
"9": "Failed to start downloading the upgrade file",
|
||||
"10": "Error while starting downloading the job file",
|
||||
"11": "Error while opening the file folder",
|
||||
"12": "Failed to delete a file",
|
||||
"13": "Error while opening the job file",
|
||||
"14": "You have wrong power adapter",
|
||||
"15": "Failed to update the device IP on Oasis Server",
|
||||
"16": "Your device failed centering itself",
|
||||
"17": "There appears to be an issue with your Oasis Device",
|
||||
"18": "Error while downloading the job file"
|
||||
}
|
||||
},
|
||||
"led_color_id": {
|
||||
"name": "LED color ID"
|
||||
},
|
||||
"status": {
|
||||
"name": "Status",
|
||||
"state": {
|
||||
@@ -47,12 +136,30 @@
|
||||
"centering": "Centering",
|
||||
"playing": "Playing",
|
||||
"paused": "Paused",
|
||||
"sleeping": "Sleeping",
|
||||
"error": "Error",
|
||||
"updating": "Updating",
|
||||
"downloading": "Downloading",
|
||||
"busy": "Busy",
|
||||
"live": "Live drawing"
|
||||
}
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"auto_clean": {
|
||||
"name": "Auto-clean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"device_busy": {
|
||||
"message": "{name} is currently busy and cannot be modified"
|
||||
},
|
||||
"invalid_media": {
|
||||
"message": "Invalid media: {media}"
|
||||
},
|
||||
"playlists_unsupported": {
|
||||
"message": "Playlists are not currently supported"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Oasis Mini update entity."""
|
||||
"""Oasis device update entity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -15,9 +15,9 @@ from homeassistant.components.update import (
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import OasisMiniConfigEntry
|
||||
from .coordinator import OasisMiniCoordinator
|
||||
from .entity import OasisMiniEntity
|
||||
from . import OasisDeviceConfigEntry, setup_platform_from_coordinator
|
||||
from .entity import OasisDeviceEntity
|
||||
from .pyoasiscontrol import OasisDevice
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,14 +25,35 @@ SCAN_INTERVAL = timedelta(hours=6)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: OasisMiniConfigEntry,
|
||||
hass: HomeAssistant, # noqa: ARG001
|
||||
entry: OasisDeviceConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Oasis Mini updates using config entry."""
|
||||
coordinator: OasisMiniCoordinator = entry.runtime_data
|
||||
if coordinator.device.access_token:
|
||||
async_add_entities([OasisMiniUpdateEntity(coordinator, DESCRIPTOR)], True)
|
||||
"""
|
||||
Set up update entities for Oasis devices from a configuration entry.
|
||||
|
||||
Parameters:
|
||||
hass (HomeAssistant): Home Assistant core instance.
|
||||
entry (OasisDeviceConfigEntry): Config entry containing runtime data used to create device update entities.
|
||||
async_add_entities (AddEntitiesCallback): Callback to add created entities to Home Assistant.
|
||||
"""
|
||||
|
||||
def make_entities(new_devices: list[OasisDevice]):
|
||||
"""
|
||||
Create update entities for the given Oasis devices.
|
||||
|
||||
Parameters:
|
||||
new_devices (list[OasisDevice]): Devices to create update entities for.
|
||||
|
||||
Returns:
|
||||
list: A list of OasisDeviceUpdateEntity instances corresponding to each device.
|
||||
"""
|
||||
return [
|
||||
OasisDeviceUpdateEntity(entry.runtime_data, device, DESCRIPTOR)
|
||||
for device in new_devices
|
||||
]
|
||||
|
||||
setup_platform_from_coordinator(entry, async_add_entities, make_entities, True)
|
||||
|
||||
|
||||
DESCRIPTOR = UpdateEntityDescription(
|
||||
@@ -40,8 +61,8 @@ DESCRIPTOR = UpdateEntityDescription(
|
||||
)
|
||||
|
||||
|
||||
class OasisMiniUpdateEntity(OasisMiniEntity, UpdateEntity):
|
||||
"""Oasis Mini update entity."""
|
||||
class OasisDeviceUpdateEntity(OasisDeviceEntity, UpdateEntity):
|
||||
"""Oasis device update entity."""
|
||||
|
||||
_attr_supported_features = (
|
||||
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
|
||||
@@ -67,17 +88,30 @@ class OasisMiniUpdateEntity(OasisMiniEntity, UpdateEntity):
|
||||
async def async_install(
|
||||
self, version: str | None, backup: bool, **kwargs: Any
|
||||
) -> None:
|
||||
"""Install an update."""
|
||||
version = await self.device.async_get_software_version()
|
||||
if version == self.latest_version:
|
||||
"""
|
||||
Trigger installation of the latest available update on the device.
|
||||
|
||||
If the latest available version matches the device's currently installed software version, no action is taken. Otherwise an upgrade is started on the device.
|
||||
|
||||
Parameters:
|
||||
version (str | None): Ignored by this implementation; the entity uses its known latest version.
|
||||
backup (bool): Ignored by this implementation.
|
||||
**kwargs: Additional keyword arguments are ignored.
|
||||
"""
|
||||
if self.latest_version == self.device.software_version:
|
||||
return
|
||||
await self.device.async_upgrade()
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update the entity."""
|
||||
await self.device.async_get_software_version()
|
||||
software = await self.device.async_cloud_get_latest_software_details()
|
||||
if not software:
|
||||
"""
|
||||
Refreshes this entity's latest software metadata.
|
||||
|
||||
Fetches the latest software details from the coordinator's cloud client and updates
|
||||
the entity's `latest_version`, `release_summary`, and `release_url` attributes.
|
||||
If no software details are returned, the entity's attributes are left unchanged.
|
||||
"""
|
||||
client = self.coordinator.cloud_client
|
||||
if not (software := await client.async_get_latest_software_details()):
|
||||
_LOGGER.warning("Unable to get latest software details")
|
||||
return
|
||||
self._attr_latest_version = software["version"]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "Oasis Mini",
|
||||
"homeassistant": "2024.4.0",
|
||||
"name": "Oasis Control",
|
||||
"homeassistant": "2024.5.0",
|
||||
"render_readme": true,
|
||||
"zip_release": true,
|
||||
"filename": "oasis_mini.zip"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
[tool.ruff.flake8-unused-arguments]
|
||||
ignore-variadic-names = true
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
force-sort-within-sections = true
|
||||
known-first-party = ["homeassistant", "tests"]
|
||||
@@ -7,7 +10,4 @@ split-on-trailing-comma = false
|
||||
|
||||
[tool.pylint."MESSAGES CONTROL"]
|
||||
# abstract-method - with intro of async there are always methods missing
|
||||
disable = [
|
||||
"abstract-method",
|
||||
"unexpected-keyword-arg",
|
||||
]
|
||||
disable = ["abstract-method", "unexpected-keyword-arg"]
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
# Home Assistant
|
||||
homeassistant>=2024.4
|
||||
homeassistant>=2025.1
|
||||
numpy
|
||||
PyTurboJPEG
|
||||
|
||||
# Integration
|
||||
aiohttp
|
||||
aiohttp # should already be installed with Home Assistant
|
||||
aiomqtt # asyncio MQTT client
|
||||
cryptography # should already be installed with Home Assistant
|
||||
|
||||
# Development
|
||||
colorlog
|
||||
pip>=21.0
|
||||
pre-commit
|
||||
ruff
|
||||
@@ -1,9 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
sudo apt-get update && sudo apt-get install libturbojpeg0 libpcap0.8 -y
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
python3 -m pip install --requirement requirements.txt --upgrade
|
||||
|
||||
pre-commit install
|
||||
|
||||
mkdir -p config
|
||||
@@ -7,49 +7,74 @@ import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from custom_components.oasis_mini.pyoasismini import OasisMini
|
||||
from custom_components.oasis_mini.pyoasismini.const import TRACKS
|
||||
from custom_components.oasis_mini.pyoasiscontrol import OasisCloudClient
|
||||
from custom_components.oasis_mini.pyoasiscontrol.const import TRACKS
|
||||
|
||||
ACCESS_TOKEN = os.getenv("GROUNDED_TOKEN")
|
||||
|
||||
|
||||
def get_author_name(data: dict) -> str:
|
||||
"""
|
||||
Extracts the author's display name from a nested track data dictionary.
|
||||
|
||||
Parameters:
|
||||
data (dict): A mapping representing track/result data. Expected shape is
|
||||
{"author": {"user": {"name": ..., "nickname": ...}}}.
|
||||
|
||||
Returns:
|
||||
str: The author's `name` if present, otherwise the author's `nickname`, otherwise "Kinetic Oasis".
|
||||
"""
|
||||
author = (data.get("author") or {}).get("user") or {}
|
||||
return author.get("name") or author.get("nickname") or "Kinetic Oasis"
|
||||
|
||||
|
||||
async def update_tracks() -> None:
|
||||
"""Update tracks."""
|
||||
client = OasisMini("", ACCESS_TOKEN)
|
||||
"""
|
||||
Fetch tracks from the Grounded Labs cloud, detect new or changed public tracks
|
||||
compared to the local TRACKS mapping, augment changed entries with author and
|
||||
reduced SVG content, and persist the merged, sorted track list to
|
||||
`custom_components/oasis_mini/pyoasiscontrol/tracks.json`.
|
||||
|
||||
Side effects:
|
||||
- May print error or status messages to stdout.
|
||||
- Writes the updated tracks JSON file.
|
||||
- Ensures the OasisCloudClient session is closed and returns early on errors or
|
||||
unexpected data.
|
||||
"""
|
||||
client = OasisCloudClient(access_token=ACCESS_TOKEN)
|
||||
try:
|
||||
data = await client.async_cloud_get_tracks()
|
||||
except Exception as ex:
|
||||
print(type(ex).__name__, ex)
|
||||
await client.session.close()
|
||||
return
|
||||
try:
|
||||
data = await client.async_get_tracks()
|
||||
except Exception as ex:
|
||||
print(type(ex).__name__, ex)
|
||||
return
|
||||
|
||||
if not isinstance(data, list):
|
||||
print("Unexpected result:", data)
|
||||
return
|
||||
if not isinstance(data, list):
|
||||
print("Unexpected result:", data)
|
||||
return
|
||||
|
||||
updated_tracks: dict[int, dict[str, Any]] = {}
|
||||
for result in filter(lambda d: d["public"], data):
|
||||
if (
|
||||
(track_id := result["id"]) not in TRACKS
|
||||
or result["name"] != TRACKS[track_id].get("name")
|
||||
or result["image"] != TRACKS[track_id].get("image")
|
||||
):
|
||||
print(f"Updating track {track_id}: {result["name"]}")
|
||||
track_info = await client.async_cloud_get_track_info(int(track_id))
|
||||
if not track_info:
|
||||
print("No track info")
|
||||
break
|
||||
author = (result.get("author") or {}).get("user") or {}
|
||||
updated_tracks[track_id] = {
|
||||
"id": track_id,
|
||||
"name": result["name"],
|
||||
"author": author.get("name") or author.get("nickname") or "Oasis Mini",
|
||||
"image": result["image"],
|
||||
"clean_pattern": track_info.get("cleanPattern", {}).get("id"),
|
||||
"reduced_svg_content": track_info.get("reduced_svg_content"),
|
||||
}
|
||||
await client.session.close()
|
||||
updated_tracks: dict[int, dict[str, Any]] = {}
|
||||
for result in filter(lambda d: d["public"], data):
|
||||
if (
|
||||
(track_id := result["id"]) not in TRACKS
|
||||
or any(
|
||||
result[field] != TRACKS[track_id].get(field)
|
||||
for field in ("name", "image", "png_image")
|
||||
)
|
||||
or TRACKS[track_id].get("author") != get_author_name(result)
|
||||
):
|
||||
print(f"Updating track {track_id}: {result['name']}")
|
||||
track_info = await client.async_get_track_info(int(track_id))
|
||||
if not track_info:
|
||||
print("No track info")
|
||||
break
|
||||
result["author"] = get_author_name(result)
|
||||
result["reduced_svg_content_new"] = track_info.get(
|
||||
"reduced_svg_content_new"
|
||||
)
|
||||
updated_tracks[track_id] = result
|
||||
finally:
|
||||
await client.async_close()
|
||||
|
||||
if not updated_tracks:
|
||||
print("No updated tracks")
|
||||
@@ -60,7 +85,7 @@ async def update_tracks() -> None:
|
||||
tracks = dict(sorted(tracks.items(), key=lambda t: t[1]["name"].lower()))
|
||||
|
||||
with open(
|
||||
"custom_components/oasis_mini/pyoasismini/tracks.json", "w", encoding="utf8"
|
||||
"custom_components/oasis_mini/pyoasiscontrol/tracks.json", "w", encoding="utf8"
|
||||
) as file:
|
||||
json.dump(tracks, file, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user