1
0
mirror of https://github.com/natekspencer/hacs-oasis_mini.git synced 2025-11-16 09:03:50 -05:00

60 Commits

Author SHA1 Message Date
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
Nathan Spencer
f850158a8e Merge pull request #52 from natekspencer/update-tracks
Some checks failed
Validate repo / Validate with hassfest (push) Has been cancelled
Validate repo / Validate with HACS (push) Has been cancelled
Update tracks / Search and update new tracks (push) Has been cancelled
Update tracks
2025-01-14 12:20:10 -07:00
natekspencer
8bb8cf9447 Update tracks 2025-01-14 19:15:13 +00:00
Nathan Spencer
1c8b2f052c Merge pull request #51 from natekspencer/image-update
Some checks failed
Validate repo / Validate with hassfest (push) Has been cancelled
Validate repo / Validate with HACS (push) Has been cancelled
Don't update image unless playing or image hasn't been cached yet
2025-01-10 15:20:41 -07:00
Nathan Spencer
73f96d8302 Don't update image unless playing or image hasn't been cached yet 2025-01-10 22:17:52 +00:00
Nathan Spencer
9cc1d6d314 Merge pull request #50 from natekspencer/binary-sensors
Switch busy and wifi_connected sensors to binary sensors
2025-01-10 15:17:02 -07:00
Nathan Spencer
4894e3549d Switch busy and wifi_connected sensors to binary sensors 2025-01-10 22:15:53 +00:00
Nathan Spencer
221f314dd6 Merge pull request #49 from natekspencer/translations
Update translations and add icons.json file
2025-01-10 15:14:26 -07:00
Nathan Spencer
595621652a Update translations and add icons.json file 2025-01-10 22:02:29 +00:00
Nathan Spencer
42040895e2 Merge pull request #48 from natekspencer/dev
Adjust media player to allow adding multiple tracks at a time
2025-01-10 14:49:38 -07:00
Nathan Spencer
51c4c8a6a2 Adjust media player to allow adding multiple tracks at a time 2025-01-10 21:48:31 +00:00
Nathan Spencer
ddabccc4a8 Merge pull request #47 from natekspencer/dev
Update dev environment
2025-01-10 12:31:31 -07:00
Nathan Spencer
94860106ea Update dev environment 2025-01-10 19:28:38 +00:00
Nathan Spencer
c4dd4f0499 Merge pull request #44 from natekspencer/svg-content
Some checks failed
Validate repo / Validate with hassfest (push) Has been cancelled
Validate repo / Validate with HACS (push) Has been cancelled
Fix parsing svg content
2024-12-27 17:14:25 -07:00
Nathan Spencer
2a5043298e Fix parsing svg content 2024-12-28 00:12:23 +00:00
24 changed files with 4418 additions and 100 deletions

View File

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

View File

@@ -12,10 +12,10 @@ 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

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

@@ -56,6 +56,20 @@ Alternatively:
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/pyoasismini/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

View File

@@ -4,6 +4,7 @@ automation:
dhcp:
frontend:
history:
isal:
logbook:
media_source:

View File

@@ -19,6 +19,7 @@ type OasisMiniConfigEntry = ConfigEntry[OasisMiniCoordinator]
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.IMAGE,
Platform.LIGHT,

View File

@@ -0,0 +1,55 @@
"""Oasis Mini 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 OasisMiniConfigEntry
from .coordinator import OasisMiniCoordinator
from .entity import OasisMiniEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: OasisMiniConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Oasis Mini sensors using config entry."""
coordinator: OasisMiniCoordinator = entry.runtime_data
async_add_entities(
OasisMiniBinarySensorEntity(coordinator, descriptor)
for descriptor in DESCRIPTORS
)
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 OasisMiniBinarySensorEntity(OasisMiniEntity, BinarySensorEntity):
"""Oasis Mini binary sensor entity."""
@property
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
return getattr(self.device, self.entity_description.key)

View File

@@ -58,9 +58,14 @@ DESCRIPTORS = (
),
OasisMiniButtonEntityDescription(
key="random_track",
name="Play random track",
translation_key="random_track",
press_fn=play_random_track,
),
OasisMiniButtonEntityDescription(
key="sleep",
translation_key="sleep",
press_fn=lambda device: device.async_sleep(),
),
)

View File

@@ -2,11 +2,14 @@
from __future__ import annotations
import logging
from typing import Any
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST
from .pyoasismini import OasisMini
from .pyoasismini import TRACKS, OasisMini
_LOGGER = logging.getLogger(__name__)
def create_client(data: dict[str, Any]) -> OasisMini:
@@ -27,3 +30,21 @@ async def add_and_play_track(device: OasisMini, track: int) -> None:
if device.status_code != 4:
await device.async_play()
def get_track_id(track: str) -> int | None:
"""Get a track id.
`track` can be either an id or title
"""
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

View File

@@ -0,0 +1,45 @@
{
"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",
"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",
"stopped": "mdi:stop-circle-outline",
"updating": "mdi:update"
}
},
"wifi_connected": {
"default": "mdi:wifi",
"state": {
"off": "mdi:wifi-off"
}
}
}
}
}

View File

@@ -42,9 +42,10 @@ class OasisMiniImageEntity(OasisMiniEntity, ImageEntity):
@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
):
if (
self._track_id != self.device.track_id
or (self._progress != self.device.progress and self.device.access_token)
) and (self.device.status == "playing" or self._cached_image is None):
self._attr_image_last_updated = self.coordinator.last_updated
self._track_id = self.device.track_id
self._progress = self.device.progress

View File

@@ -106,7 +106,7 @@ class OasisMiniLightEntity(OasisMiniEntity, LightEntity):
await self.coordinator.async_request_refresh()
DESCRIPTOR = LightEntityDescription(key="led", name="LED")
DESCRIPTOR = LightEntityDescription(key="led", translation_key="led")
async def async_setup_entry(

View File

@@ -19,7 +19,9 @@ from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import OasisMiniConfigEntry
from .const import DOMAIN
from .entity import OasisMiniEntity
from .helpers import get_track_id
from .pyoasismini.const import TRACKS
@@ -102,18 +104,30 @@ 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."""
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."""
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."""
self.abort_if_busy()
await self.device.async_stop()
await self.coordinator.async_request_refresh()
@@ -127,6 +141,7 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
async def async_media_previous_track(self) -> None:
"""Send previous track command."""
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)
@@ -134,6 +149,7 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
async def async_media_next_track(self) -> None:
"""Send next track command."""
self.abort_if_busy()
if (index := self.device.playlist_index + 1) >= len(self.device.playlist):
index = 0
await self.device.async_change_track(index)
@@ -147,32 +163,35 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
**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,
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:
@@ -188,6 +207,7 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
async def async_clear_playlist(self) -> None:
"""Clear players playlist."""
self.abort_if_busy()
await self.device.async_clear_playlist()
await self.coordinator.async_request_refresh()

View File

@@ -35,14 +35,14 @@ class OasisMiniNumberEntity(OasisMiniEntity, NumberEntity):
DESCRIPTORS = {
NumberEntityDescription(
key="ball_speed",
name="Ball speed",
translation_key="ball_speed",
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",
mode=NumberMode.SLIDER,
native_max_value=LED_SPEED_MAX,
native_min_value=LED_SPEED_MIN,

View File

@@ -1,15 +1,16 @@
"""Oasis Mini API client."""
from __future__ import annotations
import asyncio
import logging
from typing import Any, Awaitable, Final
from urllib.parse import urljoin
from aiohttp import ClientResponseError, ClientSession
import async_timeout
from .const import TRACKS
from .utils import _bit_to_bool
from .utils import _bit_to_bool, _parse_int, decrypt_svg_content
_LOGGER = logging.getLogger(__name__)
@@ -19,9 +20,11 @@ STATUS_CODE_MAP = {
3: "centering",
4: "playing",
5: "paused",
6: "sleeping",
9: "error",
11: "updating",
13: "downloading",
14: "busy",
15: "live",
}
@@ -31,9 +34,9 @@ AUTOPLAY_MAP = {
"2": "5 minutes",
"3": "10 minutes",
"4": "30 minutes",
"5": "24 hours",
}
LED_EFFECTS: Final[dict[str, str]] = {
"0": "Solid",
"1": "Rainbow",
@@ -50,12 +53,39 @@ LED_EFFECTS: Final[dict[str, str]] = {
"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",
}
CLOUD_BASE_URL = "https://app.grounded.so"
BALL_SPEED_MAX: Final = 1000
BALL_SPEED_MIN: Final = 200
BALL_SPEED_MAX: Final = 400
BALL_SPEED_MIN: Final = 100
LED_SPEED_MAX: Final = 90
LED_SPEED_MIN: Final = -90
@@ -112,6 +142,7 @@ class OasisMini:
"""Return the drawing progress percent."""
if not (self.track and (svg_content := self.track.get("svg_content"))):
return None
svg_content = decrypt_svg_content(svg_content)
paths = svg_content.split("L")
total = self.track.get("reduced_svg_content", {}).get("1", len(paths))
percent = (100 * self.progress) / total
@@ -157,17 +188,17 @@ class OasisMini:
"""Return the url."""
return f"http://{self._host}/"
async def async_add_track_to_playlist(self, track: int) -> None:
async def async_add_track_to_playlist(self, track: int | list[int]) -> None:
"""Add track to playlist."""
if not track:
return
if isinstance(track, int):
track = [track]
if 0 in self.playlist:
playlist = [t for t in self.playlist if t] + [track]
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)
self.playlist.extend(track)
async def async_change_track(self, index: int) -> None:
"""Change the track."""
@@ -208,25 +239,29 @@ class OasisMini:
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]
playlist = [_parse_int(track) for track in values[3].split(",") if track]
shift = len(values) - 18 if len(values) > 17 else 0
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
"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?
"ball_speed": _parse_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
"playlist_index": min(_parse_int(values[4]), len(playlist)), # noqa: E501; index of above
"progress": _parse_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]),
"led_speed": _parse_int(values[8]), # -90 - 90
"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] if "#" in values[10] else None, # hex color code
"busy": _bit_to_bool(values[11 + shift]), # noqa: E501; device is busy (downloading track, centering, software update)?
"download_progress": _parse_int(values[12 + shift]),
"max_brightness": _parse_int(values[13 + shift]),
"wifi_connected": _bit_to_bool(values[14 + shift]),
"repeat_playlist": _bit_to_bool(values[15 + shift]),
"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():
if (old_value := getattr(self, key, None)) != value:
@@ -310,8 +345,10 @@ class OasisMini:
raise ValueError("Invalid pause option specified")
await self._async_command(params={"WRIWAITAFTER": option})
async def async_set_playlist(self, playlist: list[int]) -> None:
async def async_set_playlist(self, playlist: list[int] | int) -> None:
"""Set the playlist."""
if isinstance(playlist, int):
playlist = [playlist]
if is_playing := (self.status_code == 4):
await self.async_stop()
await self._async_command(params={"WRIJOBLIST": ",".join(map(str, playlist))})
@@ -323,6 +360,10 @@ class OasisMini:
"""Set repeat playlist."""
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:
"""Send stop command."""
await self._async_command(params={"CMDSTOP": ""})

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,17 @@
"""Oasis Mini utils."""
from __future__ import annotations
import base64
import logging
import math
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,11 +26,20 @@ def _bit_to_bool(val: str) -> bool:
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:
"""Draw SVG."""
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))
percent = min((100 * progress) / total, 100)
@@ -137,3 +152,28 @@ def draw_svg(track: dict, progress: int, model_id: str) -> str | None:
except Exception as e:
_LOGGER.exception(e)
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

View File

@@ -85,20 +85,20 @@ def playlist_update_handler(entity: OasisMiniSelectEntity) -> 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(
key="autoplay",
name="Autoplay",
translation_key="autoplay",
options=list(AUTOPLAY_MAP.values()),
current_value=lambda device: device.autoplay,
select_fn=lambda device, option: device.async_set_autoplay(option),
),
OasisMiniSelectEntityDescription(
key="playlist",
translation_key="playlist",
current_value=lambda device: (device.playlist.copy(), device.playlist_index),
select_fn=lambda device, option: device.async_change_track(option),
update_handler=playlist_update_handler,
),
)

View File

@@ -39,34 +39,27 @@ async def async_setup_entry(
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",
)
for key in ("error", "led_color_id", "status")
}
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,

View File

@@ -38,7 +38,56 @@
}
},
"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"
},
"playlist": {
"name": "Playlist"
}
},
"sensor": {
"download_progress": {
"name": "Download progress"
},
"drawing_progress": {
"name": "Drawing progress"
},
"error": {
"name": "Error"
},
"led_color_id": {
"name": "LED color ID"
},
"status": {
"name": "Status",
"state": {
@@ -47,12 +96,25 @@
"centering": "Centering",
"playing": "Playing",
"paused": "Paused",
"sleeping": "Sleeping",
"error": "Error",
"updating": "Updating",
"downloading": "Downloading",
"busy": "Busy",
"live": "Live drawing"
}
}
}
},
"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"
}
}
}

View File

@@ -38,7 +38,56 @@
}
},
"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"
},
"playlist": {
"name": "Playlist"
}
},
"sensor": {
"download_progress": {
"name": "Download progress"
},
"drawing_progress": {
"name": "Drawing progress"
},
"error": {
"name": "Error"
},
"led_color_id": {
"name": "LED color ID"
},
"status": {
"name": "Status",
"state": {
@@ -47,12 +96,25 @@
"centering": "Centering",
"playing": "Playing",
"paused": "Paused",
"sleeping": "Sleeping",
"error": "Error",
"updating": "Updating",
"downloading": "Downloading",
"busy": "Busy",
"live": "Live drawing"
}
}
}
},
"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"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "Oasis Mini",
"homeassistant": "2024.4.0",
"homeassistant": "2024.5.0",
"render_readme": true,
"zip_release": true,
"filename": "oasis_mini.zip"

View File

@@ -1,12 +1,14 @@
# Home Assistant
homeassistant>=2024.4
homeassistant>=2025.1
numpy
PyTurboJPEG
# Integration
aiohttp
aiohttp # should already be installed with Home Assistant
cryptography # should already be installed with Home Assistant
# Development
colorlog
pip>=21.0
pre-commit
ruff

View File

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