1
0
mirror of https://github.com/natekspencer/hacs-oasis_mini.git synced 2025-11-18 10:03:41 -05:00

Initial commit

This commit is contained in:
Nathan Spencer
2024-07-06 18:37:00 -06:00
parent 7b27fc0e8c
commit e3d8ac927b
27 changed files with 1728 additions and 0 deletions

View File

@@ -0,0 +1,272 @@
"""Oasis Mini API client."""
import logging
from typing import Any, Callable, Final
from urllib.parse import urljoin
from aiohttp import ClientSession
from .utils import _bit_to_bool
_LOGGER = logging.getLogger(__name__)
STATUS_CODE_MAP = {
2: "stopped",
3: "centering",
4: "running",
5: "paused",
9: "error",
13: "downloading",
}
ATTRIBUTES: Final[list[tuple[str, Callable[[str], Any]]]] = [
("status_code", int), # see status code map
("error", str), # error, 0 = none, and 10 = ?
("ball_speed", int), # 200 - 800
("playlist", lambda value: [int(track) for track in value.split(",")]), # noqa: E501 # comma separated track ids
("playlist_index", int), # index of above
("progress", int), # 0 - max svg path
("led_effect", str), # led effect (code lookup)
("led_color_id", str), # led color id?
("led_speed", int), # -90 - 90
("brightness", int), # noqa: E501 # 0 - 200 in app, but seems to be 0 (off) to 304 (max), then repeats
("color", str), # hex color code
("busy", _bit_to_bool), # noqa: E501 # device is busy (downloading track, centering, software update)?
("download_progress", int), # 0 - 100%
("max_brightness", int),
("wifi_connected", _bit_to_bool),
("repeat_playlist", _bit_to_bool),
("pause_between_tracks", _bit_to_bool),
]
LED_EFFECTS: Final[dict[str, str]] = {
"0": "Solid",
"1": "Rainbow",
"2": "Glitter",
"3": "Confetti",
"4": "Sinelon",
"5": "BPM",
"6": "Juggle",
"7": "Theater",
"8": "Color Wipe",
"9": "Sparkle",
"10": "Comet",
"11": "Follow Ball",
"12": "Follow Rainbow",
"13": "Chasing Comet",
"14": "Gradient Follow",
}
CLOUD_BASE_URL = "https://app.grounded.so"
CLOUD_API_URL = f"{CLOUD_BASE_URL}/api"
class OasisMini:
"""Oasis Mini API client class."""
_access_token: str | None = None
_current_track_details: dict | None = None
_serial_number: str | None = None
_software_version: str | None = None
brightness: int
color: str
led_effect: str
led_speed: int
max_brightness: int
playlist: list[int]
playlist_index: int
progress: int
status_code: int
def __init__(
self,
host: str,
access_token: str | None = None,
session: ClientSession | None = None,
) -> None:
"""Initialize the client."""
self._host = host
self._access_token = access_token
self._session = session if session else ClientSession()
@property
def access_token(self) -> str | None:
"""Return the access token, if any."""
return self._access_token
@property
def current_track_id(self) -> int:
"""Return the current track."""
i = self.playlist_index
return self.playlist[0] if i >= len(self.playlist) else self.playlist[i]
@property
def serial_number(self) -> str | None:
"""Return the serial number."""
return self._serial_number
@property
def session(self) -> ClientSession:
"""Return the session."""
return self._session
@property
def software_version(self) -> str | None:
"""Return the software version."""
return self._software_version
@property
def status(self) -> str:
"""Return the status."""
return STATUS_CODE_MAP.get(self.status_code, f"Unknown ({self.status_code})")
@property
def url(self) -> str:
"""Return the url."""
return f"http://{self._host}/"
async def async_change_track(self, index: int) -> None:
"""Change the track."""
if index >= len(self.playlist):
raise ValueError("Invalid selection")
await self._async_command(params={"CMDCHANGETRACK": index})
async def async_get_serial_number(self) -> str | None:
"""Get the serial number."""
self._serial_number = await self._async_get(params={"GETOASISID": ""})
_LOGGER.debug("Serial number: %s", self._serial_number)
return self._serial_number
async def async_get_software_version(self) -> str | None:
"""Get the software version."""
self._software_version = await self._async_get(params={"GETSOFTWAREVER": ""})
_LOGGER.debug("Software version: %s", self._software_version)
return self._software_version
async def async_get_status(self) -> None:
"""Get the status from the device."""
status = await self._async_get(params={"GETSTATUS": ""})
_LOGGER.debug("Status: %s", status)
for index, value in enumerate(status.split(";")):
attr, func = ATTRIBUTES[index]
if (old_value := getattr(self, attr, None)) != (value := func(value)):
_LOGGER.debug("%s changed: '%s' -> '%s'", attr, old_value, value)
setattr(self, attr, value)
return status
async def async_pause(self) -> None:
"""Send pause command."""
await self._async_command(params={"CMDPAUSE": ""})
async def async_play(self) -> None:
"""Send play command."""
await self._async_command(params={"CMDPLAY": ""})
async def async_set_ball_speed(self, speed: int) -> None:
"""Set the Oasis Mini ball speed."""
if not 200 <= speed <= 800:
raise Exception("Invalid speed specified")
await self._async_command(params={"WRIOASISSPEED": speed})
async def async_set_led(
self,
*,
led_effect: str | None = None,
color: str | None = None,
led_speed: int | None = None,
brightness: int | None = None,
) -> None:
"""Set the Oasis Mini led."""
if led_effect is None:
led_effect = self.led_effect
if color is None:
color = self.color
if led_speed is None:
led_speed = self.led_speed
if brightness is None:
brightness = self.brightness
if led_effect not in LED_EFFECTS:
raise Exception("Invalid led effect specified")
if not -90 <= led_speed <= 90:
raise Exception("Invalid led speed specified")
if not 0 <= brightness <= 200:
raise Exception("Invalid brightness specified")
await self._async_command(
params={"WRILED": f"{led_effect};0;{color};{led_speed};{brightness}"}
)
async def async_set_pause_between_tracks(self, pause: bool) -> None:
"""Set the Oasis Mini pause between tracks."""
await self._async_command(params={"WRIWAITAFTER": 1 if pause else 0})
async def async_set_repeat_playlist(self, repeat: bool) -> None:
"""Set the Oasis Mini repeat playlist."""
await self._async_command(params={"WRIREPEATJOB": 1 if repeat else 0})
async def _async_command(self, **kwargs: Any) -> str | None:
"""Send a command request."""
result = await self._async_get(**kwargs)
_LOGGER.debug("Result: %s", result)
async def _async_get(self, **kwargs: Any) -> str | None:
"""Perform a GET request."""
response = await self._session.get(self.url, **kwargs)
if response.status == 200:
text = await response.text()
return text
return None
async def async_cloud_login(self, email: str, password: str) -> None:
"""Login via the cloud."""
response = await self._async_request(
"POST",
urljoin(CLOUD_BASE_URL, "api/auth/login"),
json={"email": email, "password": password},
)
self._access_token = response.get("access_token")
async def async_cloud_logout(self) -> None:
"""Login via the cloud."""
if not self.access_token:
return
await self._async_request(
"GET",
urljoin(CLOUD_BASE_URL, "api/auth/logout"),
headers={"Authorization": f"Bearer {self.access_token}"},
)
async def async_cloud_get_track_info(self, track_id: int) -> None:
"""Get cloud track info."""
if not self.access_token:
return
response = await self._async_request(
"GET",
urljoin(CLOUD_BASE_URL, f"api/track/{track_id}"),
headers={"Authorization": f"Bearer {self.access_token}"},
)
return response
async def _async_request(self, method: str, url: str, **kwargs) -> Any:
"""Login via the cloud."""
response = await self._session.request(method, url, **kwargs)
if response.status == 200:
if response.headers.get("Content-Type") == "application/json":
return await response.json()
return await response.text()
response.raise_for_status()
async def async_get_current_track_details(self) -> dict:
"""Get current track info, refreshing if needed."""
if (track_details := self._current_track_details) and track_details.get(
"id"
) == self.current_track_id:
return track_details
self._current_track_details = await self.async_cloud_get_track_info(
self.current_track_id
)

View File

@@ -0,0 +1,133 @@
"""Oasis Mini utils."""
import logging
import math
from xml.etree.ElementTree import Element, SubElement, tostring
# import re
_LOGGER = logging.getLogger(__name__)
COLOR_DARK = "#28292E"
COLOR_LIGHT = "#FFFFFF"
COLOR_LIGHT_SHADE = "#FFFFFF"
COLOR_MEDIUM_SHADE = "#E5E2DE"
COLOR_MEDIUM_TINT = "#B8B8B8"
FILL_SVG_STATUS = "#CCC9C4"
def _bit_to_bool(val: str) -> bool:
"""Convert a bit string to bool."""
return val == "1"
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:
paths = svg_content.split("L")
# paths=re.findall('([a-zA-Z][^a-zA-Z]+)',svg_content)
total = track.get("reduced_svg_content", {}).get(model_id, len(paths))
percent = (100 * progress) / total
progress = math.floor((percent / 100) * len(paths))
svg = Element(
"svg",
{
"title": "OasisStatus",
"version": "1.1",
"viewBox": "-25 -25 250 250",
"xmlns": "http://www.w3.org/2000/svg",
"class": "svg-status",
},
)
# style = SubElement(svg, "style")
# style.text = """
# .progress_arc_incomplete {
# stroke: #E5E2DE;
# }
# circle.circleClass {
# stroke: #006600;
# fill: #cc0000;
# }"""
group = SubElement(
svg,
"g",
{"stroke-linecap": "round", "fill": "none", "fill-rule": "evenodd"},
)
progress_arc = "M37.85,203.55L32.85,200.38L28.00,196.97L23.32,193.32L18.84,189.45L14.54,185.36L10.45,181.06L6.58,176.58L2.93,171.90L-0.48,167.05L-3.65,162.05L-6.57,156.89L-9.24,151.59L-11.64,146.17L-13.77,140.64L-15.63,135.01L-17.22,129.30L-18.51,123.51L-19.53,117.67L-20.25,111.79L-20.69,105.88L-20.84,99.95L-20.69,94.02L-20.25,88.11L-19.53,82.23L-18.51,76.39L-17.22,70.60L-15.63,64.89L-13.77,59.26L-11.64,53.73L-9.24,48.31L-6.57,43.01L-3.65,37.85L-0.48,32.85L2.93,28.00L6.58,23.32L10.45,18.84L14.54,14.54L18.84,10.45L23.32,6.58L28.00,2.93L32.85,-0.48L37.85,-3.65L43.01,-6.57L48.31,-9.24L53.73,-11.64L59.26,-13.77L64.89,-15.63L70.60,-17.22L76.39,-18.51L82.23,-19.53L88.11,-20.25L94.02,-20.69L99.95,-20.84L105.88,-20.69L111.79,-20.25L117.67,-19.53L123.51,-18.51L129.30,-17.22L135.01,-15.63L140.64,-13.77L146.17,-11.64L151.59,-9.24L156.89,-6.57L162.05,-3.65L167.05,-0.48L171.90,2.93L176.58,6.58L181.06,10.45L185.36,14.54L189.45,18.84L193.32,23.32L196.97,28.00L200.38,32.85L203.55,37.85L206.47,43.01L209.14,48.31L211.54,53.73L213.67,59.26L215.53,64.89L217.12,70.60L218.41,76.39L219.43,82.23L220.15,88.11L220.59,94.02L220.73,99.95L220.59,105.88L220.15,111.79L219.43,117.67L218.41,123.51L217.12,129.30L215.53,135.01L213.67,140.64L211.54,146.17L209.14,151.59L206.47,156.89L203.55,162.05L200.38,167.05L196.97,171.90L193.32,176.58L189.45,181.06L185.36,185.36L181.06,189.45L176.58,193.32L171.90,196.97L167.05,200.38"
SubElement(
group,
"path",
{
"class": "progress_arc_incomplete",
"stroke": COLOR_MEDIUM_SHADE,
"stroke-width": "2",
"d": progress_arc,
},
)
progress_arc_paths = progress_arc.split("L")
paths_to_draw = math.floor((percent * len(progress_arc_paths)) / 100)
SubElement(
group,
"path",
{
"stroke": COLOR_DARK,
"stroke-width": "4",
"d": "L".join(progress_arc_paths[:paths_to_draw]),
},
)
SubElement(
group,
"circle",
{
"r": "100",
"fill": FILL_SVG_STATUS,
"cx": "100",
"cy": "100",
"opacity": "0.3",
},
)
SubElement(
group,
"path",
{
"stroke": COLOR_LIGHT_SHADE,
"stroke-width": "1.4",
"d": svg_content,
},
)
SubElement(
group,
"path",
{
"stroke": COLOR_MEDIUM_TINT,
"stroke-width": "1.8",
"d": "L".join(paths[:progress]),
},
)
_cx, _cy = map(float, paths[progress].replace("M", "").split(","))
SubElement(
group,
"circle",
{
"stroke": COLOR_DARK,
"stroke-width": "1",
"fill": COLOR_LIGHT,
"cx": f"{_cx:.2f}",
"cy": f"{_cy:.2f}",
"r": "5",
},
)
return tostring(svg).decode()
except Exception as e:
_LOGGER.exception(e)