1
0
mirror of https://github.com/natekspencer/hacs-oasis_mini.git synced 2025-11-13 15:43:52 -05:00

58 Commits
1.0.1 ... 1.2.0

Author SHA1 Message Date
Nathan Spencer
0cab687cef Merge pull request #87 from natekspencer/error-translations
Add error translations
2025-08-02 08:23:18 -06:00
Nathan Spencer
581f41c517 Add error translations 2025-08-02 14:21:34 +00:00
Nathan Spencer
7705d61a4f Merge pull request #86 from natekspencer/status-icons
Update status icons for busy and sleeping
2025-08-02 07:55:38 -06:00
Nathan Spencer
3a8e274d26 Update status icons for busy and sleeping 2025-08-02 13:54:35 +00:00
Nathan Spencer
6c6ce70932 Merge pull request #85 from natekspencer/cloud-playlists
Add cloud playlists
2025-08-02 07:52:24 -06:00
Nathan Spencer
8a72aba294 Add cloud playlists 2025-08-02 13:48:58 +00:00
Nathan Spencer
9949241c84 Merge pull request #83 from natekspencer/natekspencer-patch-1
Change schedule for update-tracks workflow
2025-07-24 13:38:59 -06:00
Nathan Spencer
b07fc68b21 Change schedule for update-tracks workflow 2025-07-24 13:37:49 -06:00
Nathan Spencer
91d03f11a8 Merge pull request #82 from natekspencer/update-tracks
Update tracks
2025-07-24 13:35:53 -06:00
natekspencer
4d2c7a0199 Update tracks 2025-07-24 19:20:41 +00:00
Nathan Spencer
7c650949d8 Merge pull request #81 from natekspencer/update-tracks
Fix track info with new format
2025-07-23 13:52:47 -06:00
Nathan Spencer
2d37fb691f Fix track info with new format 2025-07-23 19:49:46 +00:00
Nathan Spencer
21fd8a63ba Merge pull request #80 from natekspencer/led-effects
Add additional led effects
2025-07-22 18:09:16 -06:00
Nathan Spencer
552339665f Add additional led effects 2025-07-23 00:06:10 +00:00
Nathan Spencer
85449a5363 Merge pull request #79 from natekspencer/add-sleep-button
Add sleep button
2025-07-22 17:37:52 -06:00
Nathan Spencer
d2bc89bdd7 Add sleep button 2025-07-22 23:36:33 +00:00
Nathan Spencer
06008e8f4c Merge pull request #78 from natekspencer/firmware-2.02-temp-fix
Add fix for firmware 2.02 led issue
2025-07-22 17:34:36 -06:00
Nathan Spencer
9fdfd8129f Merge pull request #76 from natekspencer/update-tracks
Update tracks
2025-07-22 17:31:03 -06:00
natekspencer
f9237927d9 Update tracks 2025-07-22 19:20:38 +00:00
Nathan Spencer
dcd8db52f5 Merge pull request #75 from natekspencer/update-tracks
Update tracks
2025-07-21 13:21:11 -06:00
natekspencer
86cf060af0 Update tracks 2025-07-21 19:20:16 +00:00
Nathan Spencer
d7a803abc7 Merge pull request #74 from natekspencer/update-tracks
Update tracks
2025-07-21 09:20:31 -06:00
natekspencer
a1bb4c78fb Update tracks 2025-07-18 19:19:31 +00:00
Nathan Spencer
b5b3e691e2 Merge pull request #73 from natekspencer/update-tracks
Update tracks
2025-06-30 09:37:32 -06:00
natekspencer
52b741fb71 Update tracks 2025-06-26 19:18:53 +00:00
Nathan Spencer
dc9f21b332 Merge pull request #70 from natekspencer/update-tracks
Update tracks
2025-06-10 14:01:11 -06:00
natekspencer
002898de97 Update tracks 2025-06-03 19:18:35 +00:00
Nathan Spencer
1296b309d4 Merge pull request #69 from natekspencer/update-tracks
Update tracks
2025-04-29 13:53:54 -06:00
natekspencer
9cb8b6d398 Update tracks 2025-04-29 19:18:38 +00:00
Nathan Spencer
a6022df49d Merge pull request #68 from natekspencer/update-tracks
Update tracks
2025-04-15 15:07:07 -06:00
natekspencer
839ba6ff35 Update tracks 2025-04-15 19:18:32 +00:00
Nathan Spencer
39b333be8e Merge pull request #67 from natekspencer/update-tracks
Update tracks
2025-03-26 13:19:18 -06:00
natekspencer
2afb8acf0e Update tracks 2025-03-26 19:17:57 +00:00
Nathan Spencer
50f7b270f2 Add temp fix for firmware 2.02 led issue 2025-03-26 17:40:26 +00:00
Nathan Spencer
802ce0f9a8 Merge pull request #66 from natekspencer/autoplay-options
Add 24 hours autoplay option
2025-03-26 11:37:08 -06:00
Nathan Spencer
2f25218df5 Merge pull request #64 from natekspencer/update-tracks
Update tracks
2025-03-26 11:34:15 -06:00
Nathan Spencer
de36b6ea67 Add 24 hours autoplay option 2025-03-26 17:33:22 +00:00
natekspencer
4e370d441c Update tracks 2025-03-25 19:17:16 +00:00
Nathan Spencer
cf8e744fa4 Merge pull request #63 from natekspencer/update-tracks
Update tracks
2025-03-18 13:22:09 -06:00
natekspencer
f04438cac8 Update tracks 2025-03-18 19:16:56 +00:00
Nathan Spencer
8fbf7664b1 Merge pull request #62 from natekspencer/update-tracks
Update tracks
2025-03-18 12:24:16 -06:00
natekspencer
5d7176ebaa Update tracks 2025-03-17 19:16:39 +00:00
Nathan Spencer
005a621816 Merge pull request #61 from natekspencer/update-tracks
Update tracks
2025-03-13 13:18:04 -06:00
natekspencer
2feba20b76 Update tracks 2025-03-13 19:16:39 +00:00
Nathan Spencer
e2f5727669 Merge pull request #59 from natekspencer/update-tracks
Update tracks
2025-03-12 22:31:45 -06:00
natekspencer
8650fd597a Update tracks 2025-03-12 19:16:35 +00:00
Nathan Spencer
7bef2cbe3b Merge pull request #58 from natekspencer/update-tracks
Update tracks
2025-03-11 15:17:35 -06:00
natekspencer
5ea472821b Update tracks 2025-03-11 19:17:53 +00:00
Nathan Spencer
ab09bde752 Merge pull request #57 from natekspencer/update-tracks
Update tracks
2025-03-09 13:13:57 -06:00
natekspencer
f49b8ce1d2 Update tracks 2025-03-09 19:12:34 +00:00
Nathan Spencer
cbbe8bc10d Merge pull request #56 from natekspencer/pre-commit
Add pre-commit
2025-03-09 11:11:21 -06:00
Nathan Spencer
c2c62bb875 Add pre-commit 2025-03-09 17:07:06 +00:00
Nathan Spencer
108b1850b7 Merge pull request #55 from natekspencer/devcontainer
Update devcontainer
2025-02-03 11:46:16 -07:00
Nathan Spencer
ffc74a9dcb Update devcontainer 2025-02-03 18:44:58 +00:00
Nathan Spencer
f67aee166a Merge pull request #54 from natekspencer/update-tracks
Update tracks
2025-02-02 12:15:58 -07:00
natekspencer
4ed6b1701d Update tracks 2025-02-02 19:15:01 +00:00
Nathan Spencer
ade3e7c666 Merge pull request #53 from natekspencer/update-tracks
Update tracks
2025-01-30 12:16:24 -07:00
natekspencer
4c112f2b06 Update tracks 2025-01-30 19:14:50 +00:00
19 changed files with 13121 additions and 2503 deletions

View File

@@ -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"
}
} }
} }
}, },

View File

@@ -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
View 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

View File

@@ -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

View File

@@ -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(),
),
) )

View File

@@ -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

View File

@@ -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(

View File

@@ -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"
} }

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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"
} }
} }

View File

@@ -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"
} }
} }

View File

@@ -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

View File

@@ -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

View File

@@ -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: