1
0
mirror of https://github.com/natekspencer/hacs-oasis_mini.git synced 2025-11-13 07:33:51 -05:00

10 Commits
1.1.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
12 changed files with 237 additions and 28 deletions

View File

@@ -1,7 +1,7 @@
name: Update tracks
on:
schedule:
- cron: "0 19 * * *"
- cron: "0 19 * * 1"
permissions:
contents: write
pull-requests: write

View File

@@ -3,12 +3,14 @@
from __future__ import annotations
import logging
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
import homeassistant.helpers.device_registry as dr
import homeassistant.helpers.entity_registry as er
from .const import DOMAIN
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:
"""Handle options update."""
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

@@ -62,6 +62,7 @@ class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Oasis Mini."""
VERSION = 1
MINOR_VERSION = 2
@staticmethod
@callback

View File

@@ -50,6 +50,7 @@ class OasisMiniCoordinator(DataUpdateCoordinator[str]):
self.attempt = 0
await self.device.async_get_current_track_details()
await self.device.async_get_playlist_details()
await self.device.async_cloud_get_playlists()
except Exception as ex: # pylint:disable=broad-except
if self.attempt > 2 or not (data or self.data):
raise UpdateFailed(

View File

@@ -24,12 +24,14 @@
"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"
}

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
from datetime import datetime, timedelta
import logging
from typing import Any, Awaitable, Final
from urllib.parse import urljoin
@@ -10,12 +11,12 @@ from urllib.parse import urljoin
from aiohttp import ClientResponseError, ClientSession
from .const import TRACKS
from .utils import _bit_to_bool, _parse_int, decrypt_svg_content
from .utils import _bit_to_bool, _parse_int, decrypt_svg_content, now
_LOGGER = logging.getLogger(__name__)
STATUS_CODE_MAP = {
0: "booting", # maybe?
0: "booting",
2: "stopped",
3: "centering",
4: "playing",
@@ -28,6 +29,28 @@ STATUS_CODE_MAP = {
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 = {
"0": "on",
"1": "off",
@@ -89,6 +112,8 @@ BALL_SPEED_MIN: Final = 100
LED_SPEED_MAX: Final = 90
LED_SPEED_MIN: Final = -90
PLAYLISTS_REFRESH_LIMITER = timedelta(minutes=5)
class OasisMini:
"""Oasis Mini API client class."""
@@ -116,6 +141,9 @@ class OasisMini:
repeat_playlist: bool
status_code: int
playlists: list[dict[str, Any]] = []
_playlists_next_refresh: datetime = now()
def __init__(
self,
host: str,
@@ -148,6 +176,13 @@ class OasisMini:
percent = (100 * self.progress) / total
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
def serial_number(self) -> str | None:
"""Return the serial number."""
@@ -243,7 +278,7 @@ class OasisMini:
shift = len(values) - 18 if len(values) > 17 else 0
status = {
"status_code": _parse_int(values[0]), # see status code map
"error": _parse_int(values[1]), # noqa: E501; error, 0 = none, and 10 = ?, 18 = can't download?
"error": _parse_int(values[1]),
"ball_speed": _parse_int(values[2]), # 200 - 1000
"playlist": playlist,
"playlist_index": min(_parse_int(values[4]), len(playlist)), # noqa: E501; index of above
@@ -385,6 +420,18 @@ class OasisMini:
"""Login via the cloud."""
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:
"""Get cloud track info."""
try:
@@ -404,7 +451,7 @@ class OasisMini:
"GET", "api/track", params={"ids[]": tracks or []}
)
if not response:
return None
return []
track_details = response.get("data", [])
while next_page_url := response.get("next_page_url"):
response = await self._async_cloud_request("GET", next_page_url)

View File

@@ -10943,6 +10943,24 @@
"author": "Oasis Mini",
"reduced_svg_content_new": 6509
},
"8559": {
"id": 8559,
"name": "Spiral In to Out Wiper 3 r_70mm",
"code": "spiral-in-to-out-wiper-3-r-70mm",
"description": null,
"image": "2025/07/05900e271a792ec191777da861fb8feb.svg",
"category_id": null,
"tags": "",
"public": 1,
"author_id": 51308,
"release": 1,
"is_cleaner": 0,
"pattern_id": null,
"clean_type": "clean_id",
"png_image": "2025/07/01e1ef15474ac5b3b3f076be28a501f0.png",
"author": "rob",
"reduced_svg_content_new": 8037
},
"4896": {
"id": 4896,
"name": "Spiral Me - loses reference. Duff",
@@ -10979,6 +10997,24 @@
"author": "Oasis Mini",
"reduced_svg_content_new": 6628
},
"8557": {
"id": 8557,
"name": "Spiral Out to In Wiper 2 r_100mm",
"code": "spiral-out-to-in-wiper-2-r-100mm",
"description": null,
"image": "2025/07/7cd75b68b2fffa06bb796326ac314ba1.svg",
"category_id": null,
"tags": "",
"public": 1,
"author_id": 51308,
"release": 1,
"is_cleaner": 0,
"pattern_id": null,
"clean_type": "clean_out",
"png_image": "2025/07/02b57b8e29aa4afdf6cac8aceea85741.png",
"author": "rob",
"reduced_svg_content_new": 24662
},
"2388": {
"id": 2388,
"name": "Spiral Pentagon",

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import base64
from datetime import UTC, datetime
import logging
import math
from xml.etree.ElementTree import Element, SubElement, tostring
@@ -177,3 +178,7 @@ def decrypt_svg_content(svg_content: dict[str, str]):
svg_content["decrypted"] = decrypted
return decrypted
def now() -> datetime:
return datetime.now(UTC)

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass
from typing import Any, Awaitable, Callable
@@ -63,12 +64,33 @@ class OasisMiniSelectEntity(OasisMiniEntity, SelectEntity):
return super()._handle_coordinator_update()
def playlist_update_handler(entity: OasisMiniSelectEntity) -> None:
"""Handle playlist updates."""
def playlists_update_handler(entity: OasisMiniSelectEntity) -> None:
"""Handle playlists updates."""
# 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.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",
TRACKS.get(track, {"id": track, "name": f"Unknown Title (#{track})"}).get(
"name",
@@ -77,8 +99,10 @@ 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
@@ -93,11 +117,22 @@ DESCRIPTORS = (
select_fn=lambda device, option: device.async_set_autoplay(option),
),
OasisMiniSelectEntityDescription(
key="playlist",
translation_key="playlist",
key="queue",
translation_key="queue",
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,
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,
) -> None:
"""Set up Oasis Mini select using config entry."""
async_add_entities(
[
OasisMiniSelectEntity(entry.runtime_data, descriptor)
for descriptor in DESCRIPTORS
]
)
coordinator: OasisMiniCoordinator = entry.runtime_data
entities = [
OasisMiniSelectEntity(coordinator, descriptor) 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:
entities.extend(
[
OasisMiniSensorEntity(coordinator, descriptor)
for descriptor in CLOUD_DESCRIPTORS
]
OasisMiniSensorEntity(coordinator, descriptor)
for descriptor in CLOUD_DESCRIPTORS
)
async_add_entities(entities)

View File

@@ -73,6 +73,9 @@
},
"playlist": {
"name": "Playlist"
},
"queue": {
"name": "Queue"
}
},
"sensor": {
@@ -83,7 +86,28 @@
"name": "Drawing progress"
},
"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": {
"name": "LED color ID"

View File

@@ -73,6 +73,9 @@
},
"playlist": {
"name": "Playlist"
},
"queue": {
"name": "Queue"
}
},
"sensor": {
@@ -83,7 +86,28 @@
"name": "Drawing progress"
},
"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": {
"name": "LED color ID"