mirror of
https://github.com/natekspencer/hacs-oasis_mini.git
synced 2025-11-13 15:43:52 -05:00
Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cab687cef | ||
|
|
581f41c517 | ||
|
|
7705d61a4f | ||
|
|
3a8e274d26 | ||
|
|
6c6ce70932 | ||
|
|
8a72aba294 | ||
|
|
9949241c84 | ||
|
|
b07fc68b21 | ||
|
|
91d03f11a8 | ||
|
|
4d2c7a0199 | ||
|
|
7c650949d8 | ||
|
|
2d37fb691f | ||
|
|
21fd8a63ba | ||
|
|
552339665f | ||
|
|
85449a5363 | ||
|
|
d2bc89bdd7 | ||
|
|
06008e8f4c | ||
|
|
9fdfd8129f | ||
|
|
f9237927d9 | ||
|
|
dcd8db52f5 | ||
|
|
86cf060af0 | ||
|
|
d7a803abc7 | ||
|
|
a1bb4c78fb | ||
|
|
b5b3e691e2 | ||
|
|
52b741fb71 | ||
|
|
dc9f21b332 | ||
|
|
002898de97 | ||
|
|
1296b309d4 | ||
|
|
9cb8b6d398 | ||
|
|
a6022df49d | ||
|
|
839ba6ff35 | ||
|
|
39b333be8e | ||
|
|
2afb8acf0e | ||
|
|
50f7b270f2 | ||
|
|
802ce0f9a8 | ||
|
|
2f25218df5 | ||
|
|
de36b6ea67 | ||
|
|
4e370d441c | ||
|
|
cf8e744fa4 | ||
|
|
f04438cac8 | ||
|
|
8fbf7664b1 | ||
|
|
5d7176ebaa | ||
|
|
005a621816 | ||
|
|
2feba20b76 | ||
|
|
e2f5727669 | ||
|
|
8650fd597a | ||
|
|
7bef2cbe3b | ||
|
|
5ea472821b | ||
|
|
ab09bde752 | ||
|
|
f49b8ce1d2 | ||
|
|
cbbe8bc10d | ||
|
|
c2c62bb875 | ||
|
|
108b1850b7 | ||
|
|
ffc74a9dcb | ||
|
|
f67aee166a | ||
|
|
4ed6b1701d | ||
|
|
ade3e7c666 | ||
|
|
4c112f2b06 |
@@ -1,8 +1,8 @@
|
|||||||
// See https://aka.ms/vscode-remote/devcontainer.json for format details.
|
// See https://aka.ms/vscode-remote/devcontainer.json for format details.
|
||||||
{
|
{
|
||||||
"name": "Home Assistant integration development",
|
"name": "Home Assistant integration development",
|
||||||
"image": "mcr.microsoft.com/devcontainers/python:1-3.13-bullseye",
|
"image": "mcr.microsoft.com/devcontainers/python:1-3.13-bookworm",
|
||||||
"postCreateCommand": "sudo apt-get update && sudo apt-get install libturbojpeg0 libpcap0.8 -y",
|
"postCreateCommand": "scripts/setup",
|
||||||
"postAttachCommand": "scripts/setup",
|
"postAttachCommand": "scripts/setup",
|
||||||
"forwardPorts": [8123],
|
"forwardPorts": [8123],
|
||||||
"customizations": {
|
"customizations": {
|
||||||
@@ -26,7 +26,10 @@
|
|||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.organizeImports": "always"
|
"source.organizeImports": "always"
|
||||||
},
|
},
|
||||||
"files.trimTrailingWhitespace": true
|
"files.trimTrailingWhitespace": true,
|
||||||
|
"[python]": {
|
||||||
|
"editor.defaultFormatter": "charliermarsh.ruff"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
2
.github/workflows/update-tracks.yml
vendored
2
.github/workflows/update-tracks.yml
vendored
@@ -1,7 +1,7 @@
|
|||||||
name: Update tracks
|
name: Update tracks
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "0 19 * * *"
|
- cron: "0 19 * * 1"
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|||||||
10
.pre-commit-config.yaml
Normal file
10
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
repos:
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
# Ruff version.
|
||||||
|
rev: v0.9.10
|
||||||
|
hooks:
|
||||||
|
# Run the linter.
|
||||||
|
- id: ruff
|
||||||
|
args: [--fix]
|
||||||
|
# Run the formatter.
|
||||||
|
- id: ruff-format
|
||||||
@@ -3,12 +3,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
|
||||||
import homeassistant.helpers.device_registry as dr
|
import homeassistant.helpers.device_registry as dr
|
||||||
|
import homeassistant.helpers.entity_registry as er
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .coordinator import OasisMiniCoordinator
|
from .coordinator import OasisMiniCoordinator
|
||||||
@@ -89,3 +91,33 @@ async def async_remove_entry(hass: HomeAssistant, entry: OasisMiniConfigEntry) -
|
|||||||
async def update_listener(hass: HomeAssistant, entry: OasisMiniConfigEntry) -> None:
|
async def update_listener(hass: HomeAssistant, entry: OasisMiniConfigEntry) -> None:
|
||||||
"""Handle options update."""
|
"""Handle options update."""
|
||||||
await hass.config_entries.async_reload(entry.entry_id)
|
await hass.config_entries.async_reload(entry.entry_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry):
|
||||||
|
"""Migrate old entry."""
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Migrating configuration from version %s.%s", entry.version, entry.minor_version
|
||||||
|
)
|
||||||
|
|
||||||
|
if entry.version == 1 and entry.minor_version == 1:
|
||||||
|
# Need to update previous playlist select entity to queue
|
||||||
|
@callback
|
||||||
|
def migrate_unique_id(entity_entry: er.RegistryEntry) -> dict[str, Any] | None:
|
||||||
|
"""Migrate the playlist unique ID to queue."""
|
||||||
|
if entity_entry.domain == "select" and entity_entry.unique_id.endswith(
|
||||||
|
"-playlist"
|
||||||
|
):
|
||||||
|
unique_id = entity_entry.unique_id.replace("-playlist", "-queue")
|
||||||
|
return {"new_unique_id": unique_id}
|
||||||
|
return None
|
||||||
|
|
||||||
|
await er.async_migrate_entries(hass, entry.entry_id, migrate_unique_id)
|
||||||
|
hass.config_entries.async_update_entry(entry, minor_version=2, version=1)
|
||||||
|
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Migration to configuration version %s.%s successful",
|
||||||
|
entry.version,
|
||||||
|
entry.minor_version,
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ DESCRIPTORS = (
|
|||||||
translation_key="random_track",
|
translation_key="random_track",
|
||||||
press_fn=play_random_track,
|
press_fn=play_random_track,
|
||||||
),
|
),
|
||||||
|
OasisMiniButtonEntityDescription(
|
||||||
|
key="sleep",
|
||||||
|
translation_key="sleep",
|
||||||
|
press_fn=lambda device: device.async_sleep(),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
"""Handle a config flow for Oasis Mini."""
|
"""Handle a config flow for Oasis Mini."""
|
||||||
|
|
||||||
VERSION = 1
|
VERSION = 1
|
||||||
|
MINOR_VERSION = 2
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@callback
|
@callback
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ class OasisMiniCoordinator(DataUpdateCoordinator[str]):
|
|||||||
self.attempt = 0
|
self.attempt = 0
|
||||||
await self.device.async_get_current_track_details()
|
await self.device.async_get_current_track_details()
|
||||||
await self.device.async_get_playlist_details()
|
await self.device.async_get_playlist_details()
|
||||||
|
await self.device.async_cloud_get_playlists()
|
||||||
except Exception as ex: # pylint:disable=broad-except
|
except Exception as ex: # pylint:disable=broad-except
|
||||||
if self.attempt > 2 or not (data or self.data):
|
if self.attempt > 2 or not (data or self.data):
|
||||||
raise UpdateFailed(
|
raise UpdateFailed(
|
||||||
|
|||||||
@@ -24,12 +24,14 @@
|
|||||||
"status": {
|
"status": {
|
||||||
"state": {
|
"state": {
|
||||||
"booting": "mdi:loading",
|
"booting": "mdi:loading",
|
||||||
|
"busy": "mdi:progress-clock",
|
||||||
"centering": "mdi:record-circle-outline",
|
"centering": "mdi:record-circle-outline",
|
||||||
"downloading": "mdi:progress-download",
|
"downloading": "mdi:progress-download",
|
||||||
"error": "mdi:alert-circle-outline",
|
"error": "mdi:alert-circle-outline",
|
||||||
"live": "mdi:pencil-circle-outline",
|
"live": "mdi:pencil-circle-outline",
|
||||||
"paused": "mdi:motion-pause-outline",
|
"paused": "mdi:motion-pause-outline",
|
||||||
"playing": "mdi:motion-play-outline",
|
"playing": "mdi:motion-play-outline",
|
||||||
|
"sleeping": "mdi:power-sleep",
|
||||||
"stopped": "mdi:stop-circle-outline",
|
"stopped": "mdi:stop-circle-outline",
|
||||||
"updating": "mdi:update"
|
"updating": "mdi:update"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
|
|||||||
@property
|
@property
|
||||||
def media_duration(self) -> int | None:
|
def media_duration(self) -> int | None:
|
||||||
"""Duration of current playing media in seconds."""
|
"""Duration of current playing media in seconds."""
|
||||||
if (track := self.device.track) and "reduced_svg_content" in track:
|
if (track := self.device.track) and "reduced_svg_content_new" in track:
|
||||||
return track["reduced_svg_content"].get("1")
|
return track["reduced_svg_content_new"]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from datetime import datetime, timedelta
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Awaitable, Final
|
from typing import Any, Awaitable, Final
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
@@ -10,28 +11,53 @@ from urllib.parse import urljoin
|
|||||||
from aiohttp import ClientResponseError, ClientSession
|
from aiohttp import ClientResponseError, ClientSession
|
||||||
|
|
||||||
from .const import TRACKS
|
from .const import TRACKS
|
||||||
from .utils import _bit_to_bool, decrypt_svg_content
|
from .utils import _bit_to_bool, _parse_int, decrypt_svg_content, now
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
STATUS_CODE_MAP = {
|
STATUS_CODE_MAP = {
|
||||||
0: "booting", # maybe?
|
0: "booting",
|
||||||
2: "stopped",
|
2: "stopped",
|
||||||
3: "centering",
|
3: "centering",
|
||||||
4: "playing",
|
4: "playing",
|
||||||
5: "paused",
|
5: "paused",
|
||||||
|
6: "sleeping",
|
||||||
9: "error",
|
9: "error",
|
||||||
11: "updating",
|
11: "updating",
|
||||||
13: "downloading",
|
13: "downloading",
|
||||||
|
14: "busy",
|
||||||
15: "live",
|
15: "live",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ERROR_CODE_MAP = {
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
|
||||||
AUTOPLAY_MAP = {
|
AUTOPLAY_MAP = {
|
||||||
"0": "on",
|
"0": "on",
|
||||||
"1": "off",
|
"1": "off",
|
||||||
"2": "5 minutes",
|
"2": "5 minutes",
|
||||||
"3": "10 minutes",
|
"3": "10 minutes",
|
||||||
"4": "30 minutes",
|
"4": "30 minutes",
|
||||||
|
"5": "24 hours",
|
||||||
}
|
}
|
||||||
|
|
||||||
LED_EFFECTS: Final[dict[str, str]] = {
|
LED_EFFECTS: Final[dict[str, str]] = {
|
||||||
@@ -50,15 +76,44 @@ LED_EFFECTS: Final[dict[str, str]] = {
|
|||||||
"12": "Follow Rainbow",
|
"12": "Follow Rainbow",
|
||||||
"13": "Chasing Comet",
|
"13": "Chasing Comet",
|
||||||
"14": "Gradient Follow",
|
"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",
|
||||||
}
|
}
|
||||||
|
|
||||||
CLOUD_BASE_URL = "https://app.grounded.so"
|
CLOUD_BASE_URL = "https://app.grounded.so"
|
||||||
|
|
||||||
BALL_SPEED_MAX: Final = 1000
|
BALL_SPEED_MAX: Final = 400
|
||||||
BALL_SPEED_MIN: Final = 200
|
BALL_SPEED_MIN: Final = 100
|
||||||
LED_SPEED_MAX: Final = 90
|
LED_SPEED_MAX: Final = 90
|
||||||
LED_SPEED_MIN: Final = -90
|
LED_SPEED_MIN: Final = -90
|
||||||
|
|
||||||
|
PLAYLISTS_REFRESH_LIMITER = timedelta(minutes=5)
|
||||||
|
|
||||||
|
|
||||||
class OasisMini:
|
class OasisMini:
|
||||||
"""Oasis Mini API client class."""
|
"""Oasis Mini API client class."""
|
||||||
@@ -86,6 +141,9 @@ class OasisMini:
|
|||||||
repeat_playlist: bool
|
repeat_playlist: bool
|
||||||
status_code: int
|
status_code: int
|
||||||
|
|
||||||
|
playlists: list[dict[str, Any]] = []
|
||||||
|
_playlists_next_refresh: datetime = now()
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
host: str,
|
host: str,
|
||||||
@@ -114,10 +172,17 @@ class OasisMini:
|
|||||||
return None
|
return None
|
||||||
svg_content = decrypt_svg_content(svg_content)
|
svg_content = decrypt_svg_content(svg_content)
|
||||||
paths = svg_content.split("L")
|
paths = svg_content.split("L")
|
||||||
total = self.track.get("reduced_svg_content", {}).get("1", len(paths))
|
total = self.track.get("reduced_svg_content_new", 0) or len(paths)
|
||||||
percent = (100 * self.progress) / total
|
percent = (100 * self.progress) / total
|
||||||
return percent
|
return percent
|
||||||
|
|
||||||
|
@property
|
||||||
|
def error_message(self) -> str | None:
|
||||||
|
"""Return the error message, if any."""
|
||||||
|
if self.status_code == 9:
|
||||||
|
return ERROR_CODE_MAP.get(self.error, f"Unknown ({self.error})")
|
||||||
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def serial_number(self) -> str | None:
|
def serial_number(self) -> str | None:
|
||||||
"""Return the serial number."""
|
"""Return the serial number."""
|
||||||
@@ -209,25 +274,29 @@ class OasisMini:
|
|||||||
raw_status = await self._async_get(params={"GETSTATUS": ""})
|
raw_status = await self._async_get(params={"GETSTATUS": ""})
|
||||||
_LOGGER.debug("Status: %s", raw_status)
|
_LOGGER.debug("Status: %s", raw_status)
|
||||||
values = raw_status.split(";")
|
values = raw_status.split(";")
|
||||||
playlist = [int(track) for track in values[3].split(",") if track]
|
playlist = [_parse_int(track) for track in values[3].split(",") if track]
|
||||||
|
shift = len(values) - 18 if len(values) > 17 else 0
|
||||||
status = {
|
status = {
|
||||||
"status_code": int(values[0]), # see status code map
|
"status_code": _parse_int(values[0]), # see status code map
|
||||||
"error": int(values[1]), # noqa: E501; error, 0 = none, and 10 = ?, 18 = can't download?
|
"error": _parse_int(values[1]),
|
||||||
"ball_speed": int(values[2]), # 200 - 1000
|
"ball_speed": _parse_int(values[2]), # 200 - 1000
|
||||||
"playlist": playlist,
|
"playlist": playlist,
|
||||||
"playlist_index": min(int(values[4]), len(playlist)), # index of above
|
"playlist_index": min(_parse_int(values[4]), len(playlist)), # noqa: E501; index of above
|
||||||
"progress": int(values[5]), # 0 - max svg path
|
"progress": _parse_int(values[5]), # 0 - max svg path
|
||||||
"led_effect": values[6], # led effect (code lookup)
|
"led_effect": values[6], # led effect (code lookup)
|
||||||
"led_color_id": values[7], # led color id?
|
"led_color_id": values[7], # led color id?
|
||||||
"led_speed": int(values[8]), # -90 - 90
|
"led_speed": _parse_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
|
"brightness": _parse_int(values[9]), # noqa: E501; 0 - 200 in app, but seems to be 0 (off) to 304 (max), then repeats
|
||||||
"color": values[10] or None, # hex color code
|
"color": values[10] if "#" in values[10] else None, # hex color code
|
||||||
"busy": _bit_to_bool(values[11]), # noqa: E501; device is busy (downloading track, centering, software update)?
|
"busy": _bit_to_bool(values[11 + shift]), # noqa: E501; device is busy (downloading track, centering, software update)?
|
||||||
"download_progress": int(values[12]),
|
"download_progress": _parse_int(values[12 + shift]),
|
||||||
"max_brightness": int(values[13]),
|
"max_brightness": _parse_int(values[13 + shift]),
|
||||||
"wifi_connected": _bit_to_bool(values[14]),
|
"wifi_connected": _bit_to_bool(values[14 + shift]),
|
||||||
"repeat_playlist": _bit_to_bool(values[15]),
|
"repeat_playlist": _bit_to_bool(values[15 + shift]),
|
||||||
"autoplay": AUTOPLAY_MAP.get(values[16]),
|
"autoplay": AUTOPLAY_MAP.get(value := values[16 + shift], value),
|
||||||
|
"autoclean": _bit_to_bool(values[17 + shift])
|
||||||
|
if len(values) > 17
|
||||||
|
else False,
|
||||||
}
|
}
|
||||||
for key, value in status.items():
|
for key, value in status.items():
|
||||||
if (old_value := getattr(self, key, None)) != value:
|
if (old_value := getattr(self, key, None)) != value:
|
||||||
@@ -326,6 +395,10 @@ class OasisMini:
|
|||||||
"""Set repeat playlist."""
|
"""Set repeat playlist."""
|
||||||
await self._async_command(params={"WRIREPEATJOB": 1 if repeat else 0})
|
await self._async_command(params={"WRIREPEATJOB": 1 if repeat else 0})
|
||||||
|
|
||||||
|
async def async_sleep(self) -> None:
|
||||||
|
"""Send sleep command."""
|
||||||
|
await self._async_command(params={"CMDSLEEP": ""})
|
||||||
|
|
||||||
async def async_stop(self) -> None:
|
async def async_stop(self) -> None:
|
||||||
"""Send stop command."""
|
"""Send stop command."""
|
||||||
await self._async_command(params={"CMDSTOP": ""})
|
await self._async_command(params={"CMDSTOP": ""})
|
||||||
@@ -347,6 +420,18 @@ class OasisMini:
|
|||||||
"""Login via the cloud."""
|
"""Login via the cloud."""
|
||||||
await self._async_cloud_request("GET", "api/auth/logout")
|
await self._async_cloud_request("GET", "api/auth/logout")
|
||||||
|
|
||||||
|
async def async_cloud_get_playlists(
|
||||||
|
self, personal_only: bool = False
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Get playlists from the cloud."""
|
||||||
|
if self._playlists_next_refresh <= now():
|
||||||
|
if playlists := await self._async_cloud_request(
|
||||||
|
"GET", "api/playlist", params={"my_playlists": str(personal_only)}
|
||||||
|
):
|
||||||
|
self.playlists = playlists
|
||||||
|
self._playlists_next_refresh = now() + PLAYLISTS_REFRESH_LIMITER
|
||||||
|
return self.playlists
|
||||||
|
|
||||||
async def async_cloud_get_track_info(self, track_id: int) -> dict[str, Any] | None:
|
async def async_cloud_get_track_info(self, track_id: int) -> dict[str, Any] | None:
|
||||||
"""Get cloud track info."""
|
"""Get cloud track info."""
|
||||||
try:
|
try:
|
||||||
@@ -366,7 +451,7 @@ class OasisMini:
|
|||||||
"GET", "api/track", params={"ids[]": tracks or []}
|
"GET", "api/track", params={"ids[]": tracks or []}
|
||||||
)
|
)
|
||||||
if not response:
|
if not response:
|
||||||
return None
|
return []
|
||||||
track_details = response.get("data", [])
|
track_details = response.get("data", [])
|
||||||
while next_page_url := response.get("next_page_url"):
|
while next_page_url := response.get("next_page_url"):
|
||||||
response = await self._async_cloud_request("GET", next_page_url)
|
response = await self._async_cloud_request("GET", next_page_url)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
from datetime import UTC, datetime
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
from xml.etree.ElementTree import Element, SubElement, tostring
|
from xml.etree.ElementTree import Element, SubElement, tostring
|
||||||
@@ -26,6 +27,14 @@ def _bit_to_bool(val: str) -> bool:
|
|||||||
return val == "1"
|
return val == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_int(val: str) -> int:
|
||||||
|
"""Convert an int string to int."""
|
||||||
|
try:
|
||||||
|
return int(val)
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def draw_svg(track: dict, progress: int, model_id: str) -> str | None:
|
def draw_svg(track: dict, progress: int, model_id: str) -> str | None:
|
||||||
"""Draw SVG."""
|
"""Draw SVG."""
|
||||||
if track and (svg_content := track.get("svg_content")):
|
if track and (svg_content := track.get("svg_content")):
|
||||||
@@ -33,7 +42,7 @@ def draw_svg(track: dict, progress: int, model_id: str) -> str | None:
|
|||||||
if progress is not None:
|
if progress is not None:
|
||||||
svg_content = decrypt_svg_content(svg_content)
|
svg_content = decrypt_svg_content(svg_content)
|
||||||
paths = svg_content.split("L")
|
paths = svg_content.split("L")
|
||||||
total = track.get("reduced_svg_content", {}).get(model_id, len(paths))
|
total = track.get("reduced_svg_content_new", 0) or len(paths)
|
||||||
percent = min((100 * progress) / total, 100)
|
percent = min((100 * progress) / total, 100)
|
||||||
progress = math.floor((percent / 100) * (len(paths) - 1))
|
progress = math.floor((percent / 100) * (len(paths) - 1))
|
||||||
|
|
||||||
@@ -169,3 +178,7 @@ def decrypt_svg_content(svg_content: dict[str, str]):
|
|||||||
svg_content["decrypted"] = decrypted
|
svg_content["decrypted"] = decrypted
|
||||||
|
|
||||||
return decrypted
|
return decrypted
|
||||||
|
|
||||||
|
|
||||||
|
def now() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Awaitable, Callable
|
from typing import Any, Awaitable, Callable
|
||||||
|
|
||||||
@@ -63,12 +64,33 @@ class OasisMiniSelectEntity(OasisMiniEntity, SelectEntity):
|
|||||||
return super()._handle_coordinator_update()
|
return super()._handle_coordinator_update()
|
||||||
|
|
||||||
|
|
||||||
def playlist_update_handler(entity: OasisMiniSelectEntity) -> None:
|
def playlists_update_handler(entity: OasisMiniSelectEntity) -> None:
|
||||||
"""Handle playlist updates."""
|
"""Handle playlists updates."""
|
||||||
# pylint: disable=protected-access
|
# pylint: disable=protected-access
|
||||||
device = entity.device
|
device = entity.device
|
||||||
options = [
|
counts = defaultdict(int)
|
||||||
device._playlist.get(track, {}).get(
|
options = []
|
||||||
|
current_option: str | None = None
|
||||||
|
for playlist in device.playlists:
|
||||||
|
name = playlist["name"]
|
||||||
|
counts[name] += 1
|
||||||
|
if counts[name] > 1:
|
||||||
|
name = f"{name} ({counts[name]})"
|
||||||
|
options.append(name)
|
||||||
|
if device.playlist == [pattern["id"] for pattern in playlist["patterns"]]:
|
||||||
|
current_option = name
|
||||||
|
entity._attr_options = options
|
||||||
|
entity._attr_current_option = current_option
|
||||||
|
|
||||||
|
|
||||||
|
def queue_update_handler(entity: OasisMiniSelectEntity) -> None:
|
||||||
|
"""Handle queue updates."""
|
||||||
|
# pylint: disable=protected-access
|
||||||
|
device = entity.device
|
||||||
|
counts = defaultdict(int)
|
||||||
|
options = []
|
||||||
|
for track in device.playlist:
|
||||||
|
name = device._playlist.get(track, {}).get(
|
||||||
"name",
|
"name",
|
||||||
TRACKS.get(track, {"id": track, "name": f"Unknown Title (#{track})"}).get(
|
TRACKS.get(track, {"id": track, "name": f"Unknown Title (#{track})"}).get(
|
||||||
"name",
|
"name",
|
||||||
@@ -77,8 +99,10 @@ def playlist_update_handler(entity: OasisMiniSelectEntity) -> None:
|
|||||||
else str(track),
|
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
|
entity._attr_options = options
|
||||||
index = min(device.playlist_index, len(options) - 1)
|
index = min(device.playlist_index, len(options) - 1)
|
||||||
entity._attr_current_option = options[index] if options else None
|
entity._attr_current_option = options[index] if options else None
|
||||||
@@ -93,11 +117,22 @@ DESCRIPTORS = (
|
|||||||
select_fn=lambda device, option: device.async_set_autoplay(option),
|
select_fn=lambda device, option: device.async_set_autoplay(option),
|
||||||
),
|
),
|
||||||
OasisMiniSelectEntityDescription(
|
OasisMiniSelectEntityDescription(
|
||||||
key="playlist",
|
key="queue",
|
||||||
translation_key="playlist",
|
translation_key="queue",
|
||||||
current_value=lambda device: (device.playlist.copy(), device.playlist_index),
|
current_value=lambda device: (device.playlist.copy(), device.playlist_index),
|
||||||
select_fn=lambda device, option: device.async_change_track(option),
|
select_fn=lambda device, option: device.async_change_track(option),
|
||||||
update_handler=playlist_update_handler,
|
update_handler=queue_update_handler,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
CLOUD_DESCRIPTORS = (
|
||||||
|
OasisMiniSelectEntityDescription(
|
||||||
|
key="playlists",
|
||||||
|
translation_key="playlist",
|
||||||
|
current_value=lambda device: (device.playlists, device.playlist.copy()),
|
||||||
|
select_fn=lambda device, option: device.async_set_playlist(
|
||||||
|
[pattern["id"] for pattern in device.playlists[option]["patterns"]]
|
||||||
|
),
|
||||||
|
update_handler=playlists_update_handler,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -108,9 +143,13 @@ async def async_setup_entry(
|
|||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up Oasis Mini select using config entry."""
|
"""Set up Oasis Mini select using config entry."""
|
||||||
async_add_entities(
|
coordinator: OasisMiniCoordinator = entry.runtime_data
|
||||||
[
|
entities = [
|
||||||
OasisMiniSelectEntity(entry.runtime_data, descriptor)
|
OasisMiniSelectEntity(coordinator, descriptor) for descriptor in DESCRIPTORS
|
||||||
for descriptor in DESCRIPTORS
|
]
|
||||||
]
|
if coordinator.device.access_token:
|
||||||
)
|
entities.extend(
|
||||||
|
OasisMiniSelectEntity(coordinator, descriptor)
|
||||||
|
for descriptor in CLOUD_DESCRIPTORS
|
||||||
|
)
|
||||||
|
async_add_entities(entities)
|
||||||
|
|||||||
@@ -28,10 +28,8 @@ async def async_setup_entry(
|
|||||||
]
|
]
|
||||||
if coordinator.device.access_token:
|
if coordinator.device.access_token:
|
||||||
entities.extend(
|
entities.extend(
|
||||||
[
|
OasisMiniSensorEntity(coordinator, descriptor)
|
||||||
OasisMiniSensorEntity(coordinator, descriptor)
|
for descriptor in CLOUD_DESCRIPTORS
|
||||||
for descriptor in CLOUD_DESCRIPTORS
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
async_add_entities(entities)
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,9 @@
|
|||||||
"button": {
|
"button": {
|
||||||
"random_track": {
|
"random_track": {
|
||||||
"name": "Play random track"
|
"name": "Play random track"
|
||||||
|
},
|
||||||
|
"sleep": {
|
||||||
|
"name": "Sleep"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binary_sensor": {
|
"binary_sensor": {
|
||||||
@@ -70,6 +73,9 @@
|
|||||||
},
|
},
|
||||||
"playlist": {
|
"playlist": {
|
||||||
"name": "Playlist"
|
"name": "Playlist"
|
||||||
|
},
|
||||||
|
"queue": {
|
||||||
|
"name": "Queue"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sensor": {
|
"sensor": {
|
||||||
@@ -80,7 +86,28 @@
|
|||||||
"name": "Drawing progress"
|
"name": "Drawing progress"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"name": "Error"
|
"name": "Error",
|
||||||
|
"state": {
|
||||||
|
"0": "None",
|
||||||
|
"1": "Error has occurred while reading the flash memory",
|
||||||
|
"2": "Error while starting the Wifi",
|
||||||
|
"3": "Error when starting DNS settings for your machine",
|
||||||
|
"4": "Failed to open the file to write",
|
||||||
|
"5": "Not enough memory to perform the upgrade",
|
||||||
|
"6": "Error while trying to upgrade your system",
|
||||||
|
"7": "Error while trying to download the new version of the software",
|
||||||
|
"8": "Error while reading the upgrading file",
|
||||||
|
"9": "Failed to start downloading the upgrade file",
|
||||||
|
"10": "Error while starting downloading the job file",
|
||||||
|
"11": "Error while opening the file folder",
|
||||||
|
"12": "Failed to delete a file",
|
||||||
|
"13": "Error while opening the job file",
|
||||||
|
"14": "You have wrong power adapter",
|
||||||
|
"15": "Failed to update the device IP on Oasis Server",
|
||||||
|
"16": "Your device failed centering itself",
|
||||||
|
"17": "There appears to be an issue with your Oasis Device",
|
||||||
|
"18": "Error while downloading the job file"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"led_color_id": {
|
"led_color_id": {
|
||||||
"name": "LED color ID"
|
"name": "LED color ID"
|
||||||
@@ -93,9 +120,11 @@
|
|||||||
"centering": "Centering",
|
"centering": "Centering",
|
||||||
"playing": "Playing",
|
"playing": "Playing",
|
||||||
"paused": "Paused",
|
"paused": "Paused",
|
||||||
|
"sleeping": "Sleeping",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"updating": "Updating",
|
"updating": "Updating",
|
||||||
"downloading": "Downloading",
|
"downloading": "Downloading",
|
||||||
|
"busy": "Busy",
|
||||||
"live": "Live drawing"
|
"live": "Live drawing"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,9 @@
|
|||||||
"button": {
|
"button": {
|
||||||
"random_track": {
|
"random_track": {
|
||||||
"name": "Play random track"
|
"name": "Play random track"
|
||||||
|
},
|
||||||
|
"sleep": {
|
||||||
|
"name": "Sleep"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binary_sensor": {
|
"binary_sensor": {
|
||||||
@@ -70,6 +73,9 @@
|
|||||||
},
|
},
|
||||||
"playlist": {
|
"playlist": {
|
||||||
"name": "Playlist"
|
"name": "Playlist"
|
||||||
|
},
|
||||||
|
"queue": {
|
||||||
|
"name": "Queue"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sensor": {
|
"sensor": {
|
||||||
@@ -80,7 +86,28 @@
|
|||||||
"name": "Drawing progress"
|
"name": "Drawing progress"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"name": "Error"
|
"name": "Error",
|
||||||
|
"state": {
|
||||||
|
"0": "None",
|
||||||
|
"1": "Error has occurred while reading the flash memory",
|
||||||
|
"2": "Error while starting the Wifi",
|
||||||
|
"3": "Error when starting DNS settings for your machine",
|
||||||
|
"4": "Failed to open the file to write",
|
||||||
|
"5": "Not enough memory to perform the upgrade",
|
||||||
|
"6": "Error while trying to upgrade your system",
|
||||||
|
"7": "Error while trying to download the new version of the software",
|
||||||
|
"8": "Error while reading the upgrading file",
|
||||||
|
"9": "Failed to start downloading the upgrade file",
|
||||||
|
"10": "Error while starting downloading the job file",
|
||||||
|
"11": "Error while opening the file folder",
|
||||||
|
"12": "Failed to delete a file",
|
||||||
|
"13": "Error while opening the job file",
|
||||||
|
"14": "You have wrong power adapter",
|
||||||
|
"15": "Failed to update the device IP on Oasis Server",
|
||||||
|
"16": "Your device failed centering itself",
|
||||||
|
"17": "There appears to be an issue with your Oasis Device",
|
||||||
|
"18": "Error while downloading the job file"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"led_color_id": {
|
"led_color_id": {
|
||||||
"name": "LED color ID"
|
"name": "LED color ID"
|
||||||
@@ -93,9 +120,11 @@
|
|||||||
"centering": "Centering",
|
"centering": "Centering",
|
||||||
"playing": "Playing",
|
"playing": "Playing",
|
||||||
"paused": "Paused",
|
"paused": "Paused",
|
||||||
|
"sleeping": "Sleeping",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"updating": "Updating",
|
"updating": "Updating",
|
||||||
"downloading": "Downloading",
|
"downloading": "Downloading",
|
||||||
|
"busy": "Busy",
|
||||||
"live": "Live drawing"
|
"live": "Live drawing"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,4 +10,5 @@ cryptography # should already be installed with Home Assistant
|
|||||||
# Development
|
# Development
|
||||||
colorlog
|
colorlog
|
||||||
pip>=21.0
|
pip>=21.0
|
||||||
|
pre-commit
|
||||||
ruff
|
ruff
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
sudo apt-get update && sudo apt-get install libturbojpeg0 libpcap0.8 -y
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
python3 -m pip install --requirement requirements.txt --upgrade
|
python3 -m pip install --requirement requirements.txt --upgrade
|
||||||
|
|
||||||
|
pre-commit install
|
||||||
|
|
||||||
mkdir -p config
|
mkdir -p config
|
||||||
@@ -13,6 +13,12 @@ from custom_components.oasis_mini.pyoasismini.const import TRACKS
|
|||||||
ACCESS_TOKEN = os.getenv("GROUNDED_TOKEN")
|
ACCESS_TOKEN = os.getenv("GROUNDED_TOKEN")
|
||||||
|
|
||||||
|
|
||||||
|
def get_author_name(data: dict) -> str:
|
||||||
|
"""Get author name from a dict."""
|
||||||
|
author = (data.get("author") or {}).get("user") or {}
|
||||||
|
return author.get("name") or author.get("nickname") or "Oasis Mini"
|
||||||
|
|
||||||
|
|
||||||
async def update_tracks() -> None:
|
async def update_tracks() -> None:
|
||||||
"""Update tracks."""
|
"""Update tracks."""
|
||||||
client = OasisMini("", ACCESS_TOKEN)
|
client = OasisMini("", ACCESS_TOKEN)
|
||||||
@@ -32,23 +38,22 @@ async def update_tracks() -> None:
|
|||||||
for result in filter(lambda d: d["public"], data):
|
for result in filter(lambda d: d["public"], data):
|
||||||
if (
|
if (
|
||||||
(track_id := result["id"]) not in TRACKS
|
(track_id := result["id"]) not in TRACKS
|
||||||
or result["name"] != TRACKS[track_id].get("name")
|
or any(
|
||||||
or result["image"] != TRACKS[track_id].get("image")
|
result[field] != TRACKS[track_id].get(field)
|
||||||
|
for field in ("name", "image", "png_image")
|
||||||
|
)
|
||||||
|
or TRACKS[track_id].get("author") != get_author_name(result)
|
||||||
):
|
):
|
||||||
print(f"Updating track {track_id}: {result["name"]}")
|
print(f"Updating track {track_id}: {result['name']}")
|
||||||
track_info = await client.async_cloud_get_track_info(int(track_id))
|
track_info = await client.async_cloud_get_track_info(int(track_id))
|
||||||
if not track_info:
|
if not track_info:
|
||||||
print("No track info")
|
print("No track info")
|
||||||
break
|
break
|
||||||
author = (result.get("author") or {}).get("user") or {}
|
result["author"] = get_author_name(result)
|
||||||
updated_tracks[track_id] = {
|
result["reduced_svg_content_new"] = track_info.get(
|
||||||
"id": track_id,
|
"reduced_svg_content_new"
|
||||||
"name": result["name"],
|
)
|
||||||
"author": author.get("name") or author.get("nickname") or "Oasis Mini",
|
updated_tracks[track_id] = result
|
||||||
"image": result["image"],
|
|
||||||
"clean_pattern": track_info.get("cleanPattern", {}).get("id"),
|
|
||||||
"reduced_svg_content": track_info.get("reduced_svg_content"),
|
|
||||||
}
|
|
||||||
await client.session.close()
|
await client.session.close()
|
||||||
|
|
||||||
if not updated_tracks:
|
if not updated_tracks:
|
||||||
|
|||||||
Reference in New Issue
Block a user