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

12 Commits
0.1.0 ... 0.3.0

Author SHA1 Message Date
Nathan Spencer
36da0249b7 Merge pull request #6 from natekspencer/dev
Code cleanup
2024-07-11 12:37:40 -06:00
Nathan Spencer
bcc8547e3e Code cleanup 2024-07-11 11:55:02 -06:00
Nathan Spencer
e678b20990 Merge pull request #5 from natekspencer/dev
Add ability to reconfigure integration to handle updated host ip
2024-07-11 11:54:12 -06:00
Nathan Spencer
cda435070d Add ability to reconfigure integration to handle updated host ip 2024-07-11 11:51:23 -06:00
Nathan Spencer
9b85d939c4 Merge pull request #4 from natekspencer/dev
Set HACS minimum HA version to 2024.4.0
2024-07-11 11:43:30 -06:00
Nathan Spencer
4eb86c5541 Set minimum HA version to 2024.4.0 2024-07-11 11:41:37 -06:00
Nathan Spencer
e35ae0d4fa Merge pull request #3 from natekspencer/dev
Add button to play a random track
2024-07-11 11:03:39 -06:00
Nathan Spencer
21105e497a Add button to play a random track 2024-07-11 11:00:01 -06:00
Nathan Spencer
c14e882dc8 Merge pull request #2 from natekspencer/dev
Updated functionality
2024-07-09 00:01:25 -06:00
Nathan Spencer
10fcfb8a9f Updates 2024-07-08 23:58:14 -06:00
Nathan Spencer
33faf66109 Merge pull request #1 from natekspencer/dev
Add validate GHA
2024-07-08 10:44:45 -06:00
Nathan Spencer
e5c979fab4 Add validate GHA 2024-07-08 10:35:00 -06:00
20 changed files with 1186 additions and 114 deletions

View File

@@ -3,7 +3,7 @@
"name": "Home Assistant integration development", "name": "Home Assistant integration development",
"image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye", "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye",
"postCreateCommand": "sudo apt-get update && sudo apt-get install libturbojpeg0", "postCreateCommand": "sudo apt-get update && sudo apt-get install libturbojpeg0",
"postAttachCommand": ".devcontainer/setup", "postAttachCommand": "scripts/setup",
"forwardPorts": [8123], "forwardPorts": [8123],
"customizations": { "customizations": {
"vscode": { "vscode": {

22
.github/workflows/validate.yaml vendored Normal file
View File

@@ -0,0 +1,22 @@
name: Validate repo
on:
push:
pull_request:
schedule:
- cron: "0 0 * * *"
jobs:
hassfest:
name: Validate with hassfest
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v4"
- uses: "home-assistant/actions/hassfest@master"
hacs:
name: Validate with HACS
runs-on: "ubuntu-latest"
steps:
- uses: "hacs/action@main"
with:
category: "integration"

View File

@@ -16,10 +16,12 @@ from .helpers import create_client
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
PLATFORMS = [ PLATFORMS = [
Platform.BUTTON,
Platform.IMAGE, Platform.IMAGE,
Platform.LIGHT, Platform.LIGHT,
Platform.MEDIA_PLAYER, Platform.MEDIA_PLAYER,
Platform.NUMBER, Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR, Platform.SENSOR,
Platform.SWITCH, Platform.SWITCH,
] ]
@@ -30,9 +32,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data.setdefault(DOMAIN, {}) hass.data.setdefault(DOMAIN, {})
client = create_client(entry.data | entry.options) client = create_client(entry.data | entry.options)
coordinator = OasisMiniCoordinator(hass, client) coordinator = OasisMiniCoordinator(hass, client)
await coordinator.async_config_entry_first_refresh()
try:
await coordinator.async_config_entry_first_refresh()
except Exception as ex:
_LOGGER.exception(ex)
if not coordinator.data: if not coordinator.data:
await client.session.close()
raise ConfigEntryNotReady raise ConfigEntryNotReady
hass.data[DOMAIN][entry.entry_id] = coordinator hass.data[DOMAIN][entry.entry_id] = coordinator

View File

@@ -0,0 +1,80 @@
"""Oasis Mini button entity."""
from __future__ import annotations
from dataclasses import dataclass
import random
from typing import Awaitable, Callable
from homeassistant.components.button import (
ButtonDeviceClass,
ButtonEntity,
ButtonEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .coordinator import OasisMiniCoordinator
from .entity import OasisMiniEntity
from .pyoasismini import OasisMini
from .pyoasismini.const import TRACKS
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up Oasis Mini button using config entry."""
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
[
OasisMiniButtonEntity(coordinator, entry, descriptor)
for descriptor in DESCRIPTORS
]
)
async def play_random_track(device: OasisMini) -> None:
"""Play random track."""
track = int(random.choice(list(TRACKS)))
if track not in device.playlist:
await device.async_add_track_to_playlist(track)
# Move track to next item in the playlist and then select it
if (idx := device.playlist.index(track)) != (next_idx := device.playlist_index + 1):
await device.async_move_track(idx, next_idx)
await device.async_change_track(next_idx)
await device.async_play()
@dataclass(frozen=True, kw_only=True)
class OasisMiniButtonEntityDescription(ButtonEntityDescription):
"""Oasis Mini button entity description."""
press_fn: Callable[[OasisMini], Awaitable[None]]
DESCRIPTORS = (
OasisMiniButtonEntityDescription(
key="reboot",
device_class=ButtonDeviceClass.RESTART,
press_fn=lambda device: device.async_reboot(),
),
OasisMiniButtonEntityDescription(
key="random_track",
name="Play random track",
press_fn=play_random_track,
),
)
class OasisMiniButtonEntity(OasisMiniEntity, ButtonEntity):
"""Oasis Mini button entity."""
entity_description: OasisMiniButtonEntityDescription
async def async_press(self) -> None:
"""Press the button."""
await self.entity_description.press_fn(self.device)
await self.coordinator.async_request_refresh()

View File

@@ -10,10 +10,9 @@ from aiohttp import ClientConnectorError
from httpx import ConnectError, HTTPStatusError from httpx import ConnectError, HTTPStatusError
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_HOST, CONF_PASSWORD from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_HOST, CONF_PASSWORD
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.schema_config_entry_flow import ( from homeassistant.helpers.schema_config_entry_flow import (
SchemaCommonFlowHandler, SchemaCommonFlowHandler,
SchemaFlowError, SchemaFlowError,
@@ -30,16 +29,14 @@ _LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
OPTIONS_SCHEMA = vol.Schema( OPTIONS_SCHEMA = vol.Schema(
{ {vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str}
vol.Optional(CONF_EMAIL): str,
vol.Optional(CONF_PASSWORD): str,
}
) )
async def cloud_login( async def cloud_login(
handler: SchemaCommonFlowHandler, user_input: dict[str, Any] handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Cloud login."""
coordinator: OasisMiniCoordinator = handler.parent_handler.hass.data[DOMAIN][ coordinator: OasisMiniCoordinator = handler.parent_handler.hass.data[DOMAIN][
handler.parent_handler.config_entry.entry_id handler.parent_handler.config_entry.entry_id
] ]
@@ -49,8 +46,8 @@ async def cloud_login(
email=user_input[CONF_EMAIL], password=user_input[CONF_PASSWORD] email=user_input[CONF_EMAIL], password=user_input[CONF_PASSWORD]
) )
user_input[CONF_ACCESS_TOKEN] = coordinator.device.access_token user_input[CONF_ACCESS_TOKEN] = coordinator.device.access_token
except: except Exception as ex:
raise SchemaFlowError("invalid_auth") raise SchemaFlowError("invalid_auth") from ex
del user_input[CONF_PASSWORD] del user_input[CONF_PASSWORD]
return user_input return user_input
@@ -75,7 +72,7 @@ class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN):
"""Get the options flow for this handler.""" """Get the options flow for this handler."""
return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW)
# async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: # async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> ConfigFlowResult:
# """Handle dhcp discovery.""" # """Handle dhcp discovery."""
# self.host = discovery_info.ip # self.host = discovery_info.ip
# self.name = discovery_info.hostname # self.name = discovery_info.hostname
@@ -85,13 +82,29 @@ class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> FlowResult: ) -> ConfigFlowResult:
"""Handle the initial step.""" """Handle the initial step."""
return await self._async_step("user", STEP_USER_DATA_SCHEMA, user_input) return await self._async_step("user", STEP_USER_DATA_SCHEMA, user_input)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration."""
entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
assert entry
suggested_values = user_input or entry.data
return await self._async_step(
"reconfigure", STEP_USER_DATA_SCHEMA, user_input, suggested_values
)
async def _async_step( async def _async_step(
self, step_id: str, schema: vol.Schema, user_input: dict[str, Any] | None = None self,
) -> FlowResult: step_id: str,
schema: vol.Schema,
user_input: dict[str, Any] | None = None,
suggested_values: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Handle step setup.""" """Handle step setup."""
if abort := self._abort_if_configured(user_input): if abort := self._abort_if_configured(user_input):
return abort return abort
@@ -108,21 +121,26 @@ class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN):
existing_entry, data=data existing_entry, data=data
) )
await self.hass.config_entries.async_reload(existing_entry.entry_id) await self.hass.config_entries.async_reload(existing_entry.entry_id)
return self.async_abort(reason="reauth_successful") return self.async_abort(reason="reconfigure_successful")
return self.async_create_entry( return self.async_create_entry(
title=f"Oasis Mini {self.serial_number}", title=f"Oasis Mini {self.serial_number}",
data=data, data=data,
) )
return self.async_show_form(step_id=step_id, data_schema=schema, errors=errors) return self.async_show_form(
step_id=step_id,
data_schema=self.add_suggested_values_to_schema(schema, suggested_values),
errors=errors,
)
async def validate_client(self, user_input: dict[str, Any]) -> dict[str, str]: async def validate_client(self, user_input: dict[str, Any]) -> dict[str, str]:
"""Validate client setup.""" """Validate client setup."""
errors = {} errors = {}
try: try:
client = create_client({"host": self.host} | user_input) async with asyncio.timeout(10):
self.serial_number = await client.async_get_serial_number() client = create_client({"host": self.host} | user_input)
self.serial_number = await client.async_get_serial_number()
if not self.serial_number: if not self.serial_number:
errors["base"] = "invalid_host" errors["base"] = "invalid_host"
except asyncio.TimeoutError: except asyncio.TimeoutError:
@@ -143,7 +161,7 @@ class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN):
@callback @callback
def _abort_if_configured( def _abort_if_configured(
self, user_input: dict[str, Any] | None self, user_input: dict[str, Any] | None
) -> FlowResult | None: ) -> ConfigFlowResult | None:
"""Abort if configured.""" """Abort if configured."""
if self.host or user_input: if self.host or user_input:
data = {CONF_HOST: self.host, **(user_input or {})} data = {CONF_HOST: self.host, **(user_input or {})}

View File

@@ -39,7 +39,7 @@ class OasisMiniCoordinator(DataUpdateCoordinator[str]):
data = await self.device.async_get_status() data = await self.device.async_get_status()
await self.device.async_get_current_track_details() await self.device.async_get_current_track_details()
except Exception as ex: except Exception as ex:
raise UpdateFailed("Couldn't read oasis_mini") from ex raise UpdateFailed("Couldn't read from the Oasis Mini") from ex
if data is None: if data is None:
raise ConfigEntryAuthFailed raise ConfigEntryAuthFailed
if data != self.data: if data != self.data:

View File

@@ -26,7 +26,7 @@ class OasisMiniEntity(CoordinatorEntity[OasisMiniCoordinator]):
entry: ConfigEntry, entry: ConfigEntry,
description: EntityDescription, description: EntityDescription,
) -> None: ) -> None:
"""Construct a Oasis Mini entity.""" """Construct an Oasis Mini entity."""
super().__init__(coordinator) super().__init__(coordinator)
self.entity_description = description self.entity_description = description
serial_number = coordinator.device.serial_number serial_number = coordinator.device.serial_number

View File

@@ -44,15 +44,14 @@ class OasisMiniLightEntity(OasisMiniEntity, LightEntity):
@property @property
def color_mode(self) -> ColorMode: def color_mode(self) -> ColorMode:
"""Return the color mode of the light.""" """Return the color mode of the light."""
# if self.effect in ( if self.effect in (
# "Rainbow", "Rainbow",
# "Glitter", "Glitter",
# "Confetti", "Confetti",
# "BPM", "BPM",
# "Juggle", "Juggle",
# "Theater", ):
# ): return ColorMode.BRIGHTNESS
# return ColorMode.BRIGHTNESS
return ColorMode.RGB return ColorMode.RGB
@property @property

View File

@@ -20,6 +20,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN from .const import DOMAIN
from .coordinator import OasisMiniCoordinator from .coordinator import OasisMiniCoordinator
from .entity import OasisMiniEntity from .entity import OasisMiniEntity
from .pyoasismini.const import TRACKS
BRIGHTNESS_SCALE = (1, 200) BRIGHTNESS_SCALE = (1, 200)
@@ -44,18 +45,18 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
def media_duration(self) -> int: def media_duration(self) -> int:
"""Duration of current playing media in seconds.""" """Duration of current playing media in seconds."""
if ( if (
track_details := self.device._current_track_details track := self.device._current_track_details
) and "reduced_svg_content" in track_details: ) and "reduced_svg_content" in track:
return track_details["reduced_svg_content"].get("1") return track["reduced_svg_content"].get("1")
return math.ceil(self.media_position / 0.99) return math.ceil(self.media_position / 0.99)
@property @property
def media_image_url(self) -> str | None: def media_image_url(self) -> str | None:
"""Image url of current playing media.""" """Image url of current playing media."""
if ( if not (track := self.device._current_track_details):
track_details := self.device._current_track_details track = TRACKS.get(str(self.device.current_track_id))
) and "image" in track_details: if track and "image" in track:
return f"https://app.grounded.so/uploads/{track_details['image']}" return f"https://app.grounded.so/uploads/{track['image']}"
return None return None
@property @property
@@ -71,9 +72,9 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
@property @property
def media_title(self) -> str: def media_title(self) -> str:
"""Title of current playing media.""" """Title of current playing media."""
if track_details := self.device._current_track_details: if not (track := self.device._current_track_details):
return track_details.get("name", self.device.current_track_id) track = TRACKS.get(str(self.device.current_track_id), {})
return f"Unknown Title (#{self.device.current_track_id})" return track.get("name", f"Unknown Title (#{self.device.current_track_id})")
@property @property
def repeat(self) -> RepeatMode: def repeat(self) -> RepeatMode:
@@ -116,7 +117,8 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
"""Send next track command.""" """Send next track command."""
if (index := self.device.playlist_index + 1) >= len(self.device.playlist): if (index := self.device.playlist_index + 1) >= len(self.device.playlist):
index = 0 index = 0
return await self.device.async_change_track(index) await self.device.async_change_track(index)
await self.coordinator.async_request_refresh()
DESCRIPTOR = MediaPlayerEntityDescription(key="oasis_mini", name=None) DESCRIPTOR = MediaPlayerEntityDescription(key="oasis_mini", name=None)

View File

@@ -1,7 +1,8 @@
"""Oasis Mini API client.""" """Oasis Mini API client."""
import asyncio
import logging import logging
from typing import Any, Callable, Final from typing import Any, Awaitable, Callable, Final
from urllib.parse import urljoin from urllib.parse import urljoin
from aiohttp import ClientSession from aiohttp import ClientSession
@@ -21,7 +22,7 @@ STATUS_CODE_MAP = {
ATTRIBUTES: Final[list[tuple[str, Callable[[str], Any]]]] = [ ATTRIBUTES: Final[list[tuple[str, Callable[[str], Any]]]] = [
("status_code", int), # see status code map ("status_code", int), # see status code map
("error", str), # error, 0 = none, and 10 = ? ("error", str), # error, 0 = none, and 10 = ?, 18 = can't download?
("ball_speed", int), # 200 - 800 ("ball_speed", int), # 200 - 800
("playlist", lambda value: [int(track) for track in value.split(",")]), # noqa: E501 # comma separated track ids ("playlist", lambda value: [int(track) for track in value.split(",")]), # noqa: E501 # comma separated track ids
("playlist_index", int), # index of above ("playlist_index", int), # index of above
@@ -126,6 +127,11 @@ class OasisMini:
"""Return the url.""" """Return the url."""
return f"http://{self._host}/" return f"http://{self._host}/"
async def async_add_track_to_playlist(self, track: int) -> None:
"""Add track to playlist."""
await self._async_command(params={"ADDJOBLIST": track})
self.playlist.append(track)
async def async_change_track(self, index: int) -> None: async def async_change_track(self, index: int) -> None:
"""Change the track.""" """Change the track."""
if index >= len(self.playlist): if index >= len(self.playlist):
@@ -155,6 +161,10 @@ class OasisMini:
setattr(self, attr, value) setattr(self, attr, value)
return status return status
async def async_move_track(self, _from: int, _to: int) -> None:
"""Move a track in the playlist."""
await self._async_command(params={"MOVEJOB": f"{_from};{_to}"})
async def async_pause(self) -> None: async def async_pause(self) -> None:
"""Send pause command.""" """Send pause command."""
await self._async_command(params={"CMDPAUSE": ""}) await self._async_command(params={"CMDPAUSE": ""})
@@ -163,6 +173,18 @@ class OasisMini:
"""Send play command.""" """Send play command."""
await self._async_command(params={"CMDPLAY": ""}) await self._async_command(params={"CMDPLAY": ""})
async def async_reboot(self) -> None:
"""Send reboot command."""
async def _no_response_needed(coro: Awaitable) -> None:
try:
await coro
except Exception as ex:
_LOGGER.error(ex)
reboot = self._async_command(params={"CMDBOOT": ""})
asyncio.create_task(_no_response_needed(reboot))
async def async_set_ball_speed(self, speed: int) -> None: async def async_set_ball_speed(self, speed: int) -> None:
"""Set the Oasis Mini ball speed.""" """Set the Oasis Mini ball speed."""
if not 200 <= speed <= 800: if not 200 <= speed <= 800:
@@ -215,7 +237,7 @@ class OasisMini:
async def _async_get(self, **kwargs: Any) -> str | None: async def _async_get(self, **kwargs: Any) -> str | None:
"""Perform a GET request.""" """Perform a GET request."""
response = await self._session.get(self.url, **kwargs) response = await self._session.get(self.url, **kwargs)
if response.status == 200: if response.status == 200 and response.content_type == "text/plain":
text = await response.text() text = await response.text()
return text return text
return None return None
@@ -251,6 +273,19 @@ class OasisMini:
) )
return response return response
async def async_cloud_get_tracks(self, tracks: list[int]) -> None:
"""Get cloud tracks."""
if not self.access_token:
return
response = await self._async_request(
"GET",
urljoin(CLOUD_BASE_URL, "api/track"),
headers={"Authorization": f"Bearer {self.access_token}"},
params={"ids[]": tracks},
)
return response
async def _async_request(self, method: str, url: str, **kwargs) -> Any: async def _async_request(self, method: str, url: str, **kwargs) -> Any:
"""Login via the cloud.""" """Login via the cloud."""
response = await self._session.request(method, url, **kwargs) response = await self._session.request(method, url, **kwargs)
@@ -270,3 +305,7 @@ class OasisMini:
self._current_track_details = await self.async_cloud_get_track_info( self._current_track_details = await self.async_cloud_get_track_info(
self.current_track_id self.current_track_id
) )
async def async_get_playlist_details(self) -> dict:
"""Get playlist info."""
return await self.async_cloud_get_tracks(self.playlist)

View File

@@ -0,0 +1,11 @@
"""Constants."""
from __future__ import annotations
import json
import os
from typing import Final
__TRACKS_FILE = os.path.join(os.path.dirname(__file__), "tracks.json")
with open(__TRACKS_FILE, "r", encoding="utf8") as file:
TRACKS: Final[dict[str, dict[str, str]]] = json.load(file)

View File

@@ -0,0 +1,827 @@
{
"131": {
"name": "A Star",
"author": "Oasis Mini",
"image": "2024/02/b90cbedf5982c44e2b88096e3f35f019.svg"
},
"358": {
"name": "Alligator",
"author": "Camila Veiga",
"image": "2024/05/83a5cb2f63a9103d9ea506cf762dee42.svg"
},
"114": {
"name": "Ant",
"author": "Camila Veiga",
"image": "2024/02/2c0494bff772e525b2888c869618b624.svg"
},
"306": {
"name": "arc flower",
"author": "mike",
"image": "2024/05/8341f09979ab20f6512d8fd88ba68b92.svg"
},
"251": {
"name": "Aries Ram",
"author": "Camila Veiga",
"image": "2024/05/02fea95ff2c9e1ef4636505a78517351.svg"
},
"246": {
"name": "Armadillo",
"author": "Oasis Mini",
"image": "2024/05/9715de0b402cd6ee7fbd3a8f44fb7404.svg"
},
"174": {
"name": "Baby Hummingbird",
"author": "Camila Veiga",
"image": "2024/02/5d982c39ad7d7613a6f43a2862fc4202.svg"
},
"359": {
"name": "BaldEagle",
"author": "Camila Veiga",
"image": "2024/05/db7781a68eaf312d15d773ed926f4719.svg"
},
"196": {
"name": "Bambi",
"author": "Camila Veiga",
"image": "2024/03/77c49931602941ff050c672257d2a4c4.svg"
},
"194": {
"name": "Bass",
"author": "Camila Veiga",
"image": "2024/03/58e1083634becb3e2e06ae294fd4abcd.svg"
},
"48": {
"name": "Beatle01",
"author": "Camila Veiga",
"image": "2024/02/6cb8369a92fcd78b7dfe67639f2568c2.svg"
},
"45": {
"name": "Beatle2",
"author": "Camila Veiga",
"image": "2024/02/34954cfa79d491552ec5d085d18662a8.svg"
},
"59": {
"name": "Beatle3",
"author": "Camila Veiga",
"image": "2024/02/d3c759d4407b4bd9dce4af2aa02fb309.svg"
},
"168": {
"name": "Betta Fish",
"author": "Oasis Mini",
"image": "2024/02/eda69bb71c0a146f59e3d7aa5af5d033.svg"
},
"102": {
"name": "Big Fish",
"author": "Camila Veiga",
"image": "2024/02/223d81730511500d47dc9ce386b54e76.svg"
},
"56": {
"name": "Branch",
"author": "Camila Veiga",
"image": "2024/02/93da7a9a8901a7ee2cbaf687c1d4f6bd.svg"
},
"133": {
"name": "Bubbles",
"author": "Oasis Mini",
"image": "2024/03/0c68af1243b823a829a83c2bced9462d.svg"
},
"349": {
"name": "Buddah",
"author": "Otávio Bittencourt",
"image": "2024/05/0e22fae64a02d4e7fe1f4ada6b1f707f.svg"
},
"257": {
"name": "Buddhist Tree",
"author": "Otávio Bittencourt",
"image": "2024/05/71c59b439b4a4f66527b045e22beacf3.svg"
},
"621": {
"name": "Bufallo",
"author": "Otávio Bittencourt",
"image": "2024/07/5fac3aff67796b4365593d38bb83dc1f.svg"
},
"157": {
"name": "Butterfly",
"author": "Oasis Mini",
"image": "2024/03/060b7c7aee2db3cc7bbf41d6f260c347.svg"
},
"58": {
"name": "Camalion",
"author": "Camila Veiga",
"image": "2024/02/f8b7ec53c2ca63f30baeacdda30659bd.svg"
},
"178": {
"name": "Cardinal Bird",
"author": "Camila Veiga",
"image": "2024/02/ba057fd71a816dd15565583cf63ee2ab.svg"
},
"215": {
"name": "Cardiod",
"author": null,
"image": "2024/03/a24da534ded92bfff8b604a630b76edd.svg"
},
"113": {
"name": "Cat Face",
"author": "Camila Veiga",
"image": "2024/02/d45a368206f87e077739e48ca73a89c6.svg"
},
"49": {
"name": "Clam",
"author": "Camila Veiga",
"image": "2024/02/25355aa8111a77ec41d1396df9123fcb.svg"
},
"505": {
"name": "Coarse Hilbert Wiper",
"author": "Xilufer",
"image": "2024/06/cb2ad632c8d1c2ca69fa9a8f544bc0c7.svg"
},
"118": {
"name": "Coarse Spiral In to Out",
"author": "Oasis Mini",
"image": "2024/02/64a1c80bbb9b5b690ee08ae11e9c0e89.svg"
},
"501": {
"name": "Coarse Spiral Out to In",
"author": "Xilufer",
"image": "2024/06/a46ab9145f30d81856ceec69ca4b8378.svg"
},
"503": {
"name": "Coarse Wipe Bottom to Top",
"author": "Xilufer",
"image": "2024/06/798a562ecda1f6ae80143ce3e69e97e2.svg"
},
"499": {
"name": "Coarse Wipe Left to Right",
"author": "Xilufer",
"image": "2024/06/335f1704e84153fa4e8334fc6e3ede6f.svg"
},
"504": {
"name": "Coarse Wipe Right to Left",
"author": "Xilufer",
"image": "2024/06/ed92b6cc7935a4c9f8a691e3308f9b49.svg"
},
"497": {
"name": "Coarse Wipe Top to Bottom",
"author": "Xilufer",
"image": "2024/06/053798917cd862f58adfc8b52310d377.svg"
},
"264": {
"name": "Crab",
"author": "Camila Veiga",
"image": "2024/05/e11995a5855afbfa05f89ce39ba65740.svg"
},
"220": {
"name": "Crane Mini",
"author": null,
"image": "2024/03/e2b3f344d6a1407d8dd5d06a2dd4d10f.svg"
},
"104": {
"name": "Cricket",
"author": "Camila Veiga",
"image": "2024/02/de8399defab8eed0f2e5de564e423c78.svg"
},
"98": {
"name": "Crocodile",
"author": "Camila Veiga",
"image": "2024/02/71b8b959f6f5320b0778f4c25a74f105.svg"
},
"68": {
"name": "Cupid",
"author": "Camila Veiga",
"image": "2024/02/8db157d5e68d132eb3766e5325936b3a.svg"
},
"261": {
"name": "Cute Cat",
"author": "Otávio Bittencourt",
"image": "2024/05/caf48ea93bc21a7391cf8aa16388f500.svg"
},
"393": {
"name": "dither_tri4",
"author": "B Perry",
"image": "2024/06/b15f38d3a4ae4f8418c769ca024bc646.svg"
},
"146": {
"name": "Dithermaster Gears",
"author": "Oasis Mini",
"image": "2024/02/92ed5ddc81f3152558a62b90f9ab99bd.svg"
},
"145": {
"name": "Dithermaster Nautilus",
"author": "Oasis Mini",
"image": "2024/02/d3e546f47a5e328320f95471e6d06e8e.svg"
},
"144": {
"name": "Dithermaster Sierpinski",
"author": "Oasis Mini",
"image": "2024/02/b873df4b7f29d81f9577b7f3d9adb649.svg"
},
"142": {
"name": "Dithermaster Sunburst",
"author": "Oasis Mini",
"image": "2024/02/560135854581fcf00007644641f317c0.svg"
},
"140": {
"name": "Dithermaster Wormhole",
"author": "Oasis Mini",
"image": "2024/02/011ba7387ca10787302da62a9ab39ce7.svg"
},
"41": {
"name": "Dog Beatle",
"author": "Camila Veiga",
"image": "2024/02/420d5b52f9c39fbec5d0301c8d1917b4.svg"
},
"36": {
"name": "Dog Golden Retriever",
"author": "Camila Veiga",
"image": "2024/02/a7419fb8058506cfc4b97a1ad44b08a1.svg"
},
"40": {
"name": "Dog Pug",
"author": "Oasis Mini",
"image": "2024/02/c2e232717568ca7fba7c835d94ff14f3.svg"
},
"162": {
"name": "Dolphin",
"author": "Oasis Mini",
"image": "2024/03/10a84a5fd019316a24e90535894db3fe.svg"
},
"244": {
"name": "Doodle Dog",
"author": "Camila Veiga",
"image": "2024/05/430de6550a4affc068160c9a4c88e226.svg"
},
"195": {
"name": "Dragon",
"author": "Camila Veiga",
"image": "2024/03/077c020cce5abf70d49fe040ddd5b209.svg"
},
"193": {
"name": "Duck",
"author": "Camila Veiga",
"image": "2024/03/22ca351799853f1452a6905a94942d4b.svg"
},
"159": {
"name": "Elephant",
"author": "Oasis Mini",
"image": "2024/03/48a449db2bbb530e5d54b54cb711ce9f.svg"
},
"129": {
"name": "Engine Turn",
"author": "Oasis Mini",
"image": "2024/02/7cb25ab3fbea0fc33a013e05bfc7b393.svg"
},
"219": {
"name": "Face",
"author": null,
"image": "2024/03/20039d6b829edcf6db73d19f9e923f2f.svg"
},
"33": {
"name": "Fibonacci Shell",
"author": "Camila Veiga",
"image": "2024/02/aaac5e59aab118064638e273ee2da27a.svg"
},
"262": {
"name": "Fish Koi",
"author": "Otávio Bittencourt",
"image": "2024/05/ce8f6c7d5e89dac56cb4296d86cd7261.svg"
},
"38": {
"name": "Flamingo",
"author": "Camila Veiga",
"image": "2024/02/cc1b007041fa87e28601c757887631fa.svg"
},
"249": {
"name": "Flower Voyage",
"author": "Camila Veiga",
"image": "2024/05/85f7a4290e6b45f76ff7653d77db3322.svg"
},
"87": {
"name": "Flowers",
"author": "Camila Veiga",
"image": "2024/02/f27ab7850ca572a4e83d9606a3528fb5.svg"
},
"241": {
"name": "French Bulldog",
"author": "Camila Veiga",
"image": "2024/05/0992b12affcc14cf541baff6b1368fd9.svg"
},
"60": {
"name": "Frog",
"author": "Camila Veiga",
"image": "2024/02/1d37a8cd59f9222670949681f607f454.svg"
},
"252": {
"name": "Furry Moth",
"author": "Camila Veiga",
"image": "2024/05/fec69cc408643e629247c56648df6dee.svg"
},
"88": {
"name": "Geometric Hummingbird",
"author": "Camila Veiga",
"image": "2024/02/f2ddf3bd2d74b7674832d7ace5e54992.svg"
},
"81": {
"name": "Geometric Wolf",
"author": "Camila Veiga",
"image": "2024/02/a8cc546c3d9dfd1ade921817299a626a.svg"
},
"332": {
"name": "Giant Octopus",
"author": "Otávio Bittencourt",
"image": "2024/05/862ce4ee7aaba8b3832d136a9909c15d.svg"
},
"224": {
"name": "Happy Easter",
"author": "Oasis Mini",
"image": "2024/03/c0dfc0175a06768d06ef4a8863ddb5c6.svg"
},
"581": {
"name": "Happy4th",
"author": "zach8644",
"image": "2024/07/21574747a7892b04931bdd5135175d04.svg"
},
"356": {
"name": "Hedgehog",
"author": "Camila Veiga",
"image": "2024/05/cabfd2aa2b691af8db0d95bdfe0fd32e.svg"
},
"147": {
"name": "Hilbert",
"author": "Oasis Mini",
"image": "2024/03/18d8fab24afbacae8743b154ede27ac0.svg"
},
"496": {
"name": "Hilbert Wiper",
"author": "Xilufer",
"image": "2024/06/3ed2bf50e3aabdbc4f5de7d81c46fdeb.svg"
},
"192": {
"name": "Hippo",
"author": "Camila Veiga",
"image": "2024/03/cef030f36e1d9ee172603ca2ebf52045.svg"
},
"213": {
"name": "Honeybee",
"author": null,
"image": "2024/03/916fa92c31245f887bf6842dc0abf087.svg"
},
"100": {
"name": "Hummingbird",
"author": "Camila Veiga",
"image": "2024/02/6df68af94360e823a2888925fc935da4.svg"
},
"304": {
"name": "Iguana",
"author": "Otávio Bittencourt",
"image": "2024/05/24a538188acf7ae153746aff00e35743.svg"
},
"72": {
"name": "Iguana",
"author": "Camila Veiga",
"image": "2024/02/6a9c6db932fc00eacdde436bfad6affd.svg"
},
"139": {
"name": "Intersection",
"author": "Oasis Mini",
"image": "2024/02/a8e3c5d676faa430c0d6818ebff8044c.svg"
},
"238": {
"name": "Jack Russell Terrier",
"author": "Camila Veiga",
"image": "2024/05/d000c4bb896280d455dd6ac53ed8aa48.svg"
},
"170": {
"name": "Jellyfish",
"author": "Oasis Mini",
"image": "2024/03/2be69280b76eef7323d8f107afb6d142.svg"
},
"189": {
"name": "Kakapo Parrot Bird",
"author": "Camila Veiga",
"image": "2024/02/3482a2aafe5facaafff2b83531910512.svg"
},
"239": {
"name": "Kobra",
"author": "Camila Veiga",
"image": "2024/05/af42684b3f1ad0cc1926d28f6add3dec.svg"
},
"240": {
"name": "Labrador Retriever",
"author": "Camila Veiga",
"image": "2024/05/9241d0be1d61fa2b37681cb5d90d15be.svg"
},
"173": {
"name": "Light Bulb",
"author": "Oasis Mini",
"image": "2024/03/431d42927eca6b93a33c520a495d621d.svg"
},
"121": {
"name": "Line Wiper",
"author": "Zach",
"image": "2024/02/b406f9245e23ded2e3a781ccc5e5ca1f.svg"
},
"385": {
"name": "Lion",
"author": "Otávio Bittencourt",
"image": "2024/06/56ace3527391978ce17b65fc14f69ed3.svg"
},
"300": {
"name": "Little Heart",
"author": "Evan",
"image": "2024/05/8c68933d4b7e07ad9dc3496f7b82f106.svg"
},
"177": {
"name": "Lone Blue Jay Bird",
"author": "Camila Veiga",
"image": "2024/02/4c5b69c5fe436c8cbb5df697202dcaa5.svg"
},
"250": {
"name": "Long Tail Moth",
"author": "Camila Veiga",
"image": "2024/05/23c032dfc886d10c43b60fee7d1a5c92.svg"
},
"128": {
"name": "Loops",
"author": "Oasis Mini",
"image": "2024/02/5527235b74c3f9327728caddf73eda5b.svg"
},
"188": {
"name": "Macaw",
"author": "Camila Veiga",
"image": "2024/02/f36f92f355cbfc0bd1cfc779ec64d8fb.svg"
},
"64": {
"name": "Mandala",
"author": "Camila Veiga",
"image": "2024/02/21eb184da4fe1eeefdd7c220f209d3f1.svg"
},
"339": {
"name": "Marmoset Monkey",
"author": "Otávio Bittencourt",
"image": "2024/05/344128d7d7e468db26af2f04b2d7d088.svg"
},
"212": {
"name": "Medusa",
"author": null,
"image": "2024/03/5b8954e0d62998cdfd9fccbc8b63173e.svg"
},
"78": {
"name": "Mini Bouquet",
"author": "Camila Veiga",
"image": "2024/02/42a10229d228504945cde2dcab34e145.svg"
},
"179": {
"name": "Monkey",
"author": "Camila Veiga",
"image": "2024/02/8f3b78fecee6f47ea568c6a580a9a2fc.svg"
},
"155": {
"name": "Monstera",
"author": "Oasis Mini",
"image": "2024/02/c2b76034445415a1327cafe24781a2a8.svg"
},
"202": {
"name": "Moth",
"author": "Camila Veiga",
"image": "2024/03/374e12126f1618ee960b44a23c3229ca.svg"
},
"63": {
"name": "Mushroom",
"author": "Camila Veiga",
"image": "2024/02/63f18a5f611c9b798178110c885b7b7b.svg"
},
"101": {
"name": "Mushroom Forest",
"author": "Camila Veiga",
"image": "2024/02/df973d6848a4173b54ac6666847798d1.svg"
},
"138": {
"name": "Noise Curves",
"author": "Oasis Mini",
"image": "2024/03/24583f60b82a4198db5ba5c922aaa9da.svg"
},
"150": {
"name": "Noise Waves",
"author": "Oasis Mini",
"image": "2024/03/ef39021e727be220dab8962ff9077aca.svg"
},
"171": {
"name": "Octopus",
"author": "Camila Veiga",
"image": "2024/03/761741f1eabae8b3183e48e3a367fcfe.svg"
},
"431": {
"name": "Otter",
"author": "Otávio Bittencourt",
"image": "2024/06/af229556334619038aa62f913e36d455.svg"
},
"37": {
"name": "Owl",
"author": "Camila Veiga",
"image": "2024/02/eb45cee22c24225da3a79abf6f907765.svg"
},
"221": {
"name": "Pattern 3",
"author": null,
"image": "2024/03/419f74b031a6ea0cfd794985bb983960.svg"
},
"350": {
"name": "Pelican",
"author": "Otávio Bittencourt",
"image": "2024/05/678ca8eed19618dade7d4ed00e3ebdd9.svg"
},
"24": {
"name": "Penguin",
"author": "Camila Veiga",
"image": "2024/03/f3a718de2ff3fd37148fd16967113f87.svg"
},
"137": {
"name": "Pinwheel",
"author": "Oasis Mini",
"image": "2024/02/554b6e96961ce9c9459eaf9826c09c9a.svg"
},
"243": {
"name": "Pitbull",
"author": "Camila Veiga",
"image": "2024/05/6aa2e109b8f3eb068288bdbf652297a3.svg"
},
"103": {
"name": "Rabbit",
"author": "Camila Veiga",
"image": "2024/02/409d178f619d5b1dad43f34c380a8768.svg"
},
"211": {
"name": "Rabbit",
"author": null,
"image": "2024/03/7501a028530482e89bb726e425a6a8cb.svg"
},
"210": {
"name": "Rocket",
"author": null,
"image": "2024/03/7a60d9b004f546948fde90489e19f22a.svg"
},
"105": {
"name": "Rooster",
"author": "Camila Veiga",
"image": "2024/02/458f026c21efdaf85dd9483515c793ff.svg"
},
"156": {
"name": "Rose",
"author": "Oasis Mini",
"image": "2024/03/8a5ff9792afe8da301350dee4a8e4278.svg"
},
"123": {
"name": "Sawtooth",
"author": "Oasis Mini",
"image": "2024/02/4fe77ed20684244e6c83784f199c752e.svg"
},
"197": {
"name": "Scorpion",
"author": "Camila Veiga",
"image": "2024/03/d3e71b4963a7d61d55be2760482890aa.svg"
},
"345": {
"name": "Sea Horse",
"author": "Otávio Bittencourt",
"image": "2024/05/6de639607e4bbdca8f8ecb57c402cd6e.svg"
},
"172": {
"name": "Seahorse",
"author": "Oasis Mini",
"image": "2024/03/2283d765860cddd72025a150921dd6ce.svg"
},
"190": {
"name": "Seahorse",
"author": "Camila Veiga",
"image": "2024/03/c3ec7121261a3f75ff56630b672136fe.svg"
},
"357": {
"name": "Seal",
"author": "Camila Veiga",
"image": "2024/05/4833cf1cfbfc79ef6195bd2e1c006059.svg"
},
"390": {
"name": "Sheep",
"author": "Otávio Bittencourt",
"image": "2024/06/31e46f5f5997e742394892849eda505a.svg"
},
"136": {
"name": "Shield",
"author": "Oasis Mini",
"image": "2024/02/6f0952def38040c7a48bc56c7c44bf67.svg"
},
"203": {
"name": "Shimeji",
"author": "Camila Veiga",
"image": "2024/03/9873bcedd0b8f0560d8619fdddf42090.svg"
},
"149": {
"name": "Sierpenski",
"author": "Oasis Mini",
"image": "2024/03/27205730092d3b5c866bd53b9d26be97.svg"
},
"209": {
"name": "Skull",
"author": null,
"image": "2024/03/374f2efbfed6e4ab91137dbc6068e446.svg"
},
"158": {
"name": "Slightly Frightening Panda",
"author": "Oasis Mini",
"image": "2024/03/6877ff4d26904605066f246f31ed3cea.svg"
},
"180": {
"name": "Slot",
"author": "Camila Veiga",
"image": "2024/02/d4999457ab2769ddaef2c75736adab3a.svg"
},
"266": {
"name": "Snail",
"author": "Camila Veiga",
"image": "2024/05/6dd0ce7a83776d0a275d4bfcdc37d53f.svg"
},
"160": {
"name": "Spaceman",
"author": "Oasis Mini",
"image": "2024/03/b3d10e661d26c0bd7f8cc3ecee3b0ace.svg"
},
"125": {
"name": "Spiral Gyrations",
"author": "Oasis Mini",
"image": "2024/02/bfe3669fb18b99ba153bb07c2ea1d223.svg"
},
"119": {
"name": "Spiral In to Out",
"author": "Oasis Mini",
"image": "2024/02/f52427297697620a11131d037078fa2e.svg"
},
"117": {
"name": "Spiral Out to In",
"author": "Oasis Mini",
"image": "2024/03/4402aad108bb2a5c100b9f150ea3d97b.svg"
},
"20": {
"name": "SpiralizedWeb",
"author": "Zach",
"image": "2024/02/99e4863256d8ffb5f3b5239f19e2270b.svg"
},
"126": {
"name": "Spun Web",
"author": "Zach",
"image": "2024/02/99e4863256d8ffb5f3b5239f19e2270b.svg"
},
"267": {
"name": "Squid",
"author": "Camila Veiga",
"image": "2024/05/948f8d6eb814ce3a21e5a76ed90b3ea4.svg"
},
"175": {
"name": "Squirrel",
"author": "Camila Veiga",
"image": "2024/05/57981d2262e861b1b36ee842cff8b0d8.svg"
},
"265": {
"name": "Starfish",
"author": "Camila Veiga",
"image": "2024/05/a05aa52a44f34da15ddb1f5a3009fc1d.svg"
},
"115": {
"name": "String ray",
"author": "Camila Veiga",
"image": "2024/02/ca0d18b5213d4a18e0fabe76d4170247.svg"
},
"245": {
"name": "Sunflower",
"author": "Camila Veiga",
"image": "2024/05/bf6e4b6d739226139ced981ba9e38f60.svg"
},
"208": {
"name": "Swallow",
"author": null,
"image": "2024/03/026f52b5e539f1dd14215c751923024e.svg"
},
"370": {
"name": "Swirl",
"author": "Matt",
"image": "2024/05/156a6da37221c44878cd3c155f1d6918.svg"
},
"161": {
"name": "T-Rex",
"author": "Oasis Mini",
"image": "2024/03/3829ea91a3af828e7046f473707b0627.svg"
},
"455": {
"name": "Teste",
"author": "Otávio Bittencourt",
"image": "2024/06/ecd77e23fe859ba8e7e8c6a6ecfc9b8e.svg"
},
"223": {
"name": "The Knot",
"author": null,
"image": "2024/03/63013ee4146acb3af028949f98944bd9.svg"
},
"308": {
"name": "The Noise",
"author": "Matt",
"image": "2024/05/765c11e5dda140b236075b912731f69f.svg"
},
"483": {
"name": "Tiger",
"author": "Otávio Bittencourt",
"image": "2024/06/1bfb7dcda755b2d98ee85b83748d095b.svg"
},
"237": {
"name": "Toy Poodle",
"author": "Camila Veiga",
"image": "2024/05/9f559796eac7691049af8dadda742ad8.svg"
},
"130": {
"name": "Tri-Circle",
"author": "Oasis Mini",
"image": "2024/02/0a41c8694c1cd6559baafd82963286f6.svg"
},
"135": {
"name": "Triforce",
"author": "Oasis Mini",
"image": "2024/02/fefeea07184b4597243ba7b2dd2711fa.svg"
},
"247": {
"name": "Tropical Frog",
"author": "Oasis Mini",
"image": "2024/05/24f51e96925b83d64c8f63bd6c1b36b4.svg"
},
"242": {
"name": "Tropical Monkey texture",
"author": "Camila Veiga",
"image": "2024/05/6358af0a11dfa985f61bd9a7dec90fd3.svg"
},
"248": {
"name": "Tropical Snake",
"author": "Camila Veiga",
"image": "2024/05/d5bf2ba5d6417196d106b4da756035a1.svg"
},
"54": {
"name": "Tucan",
"author": "Camila Veiga",
"image": "2024/02/699dd2fff292f1104f8dbdf60f187043.svg"
},
"176": {
"name": "Tulips",
"author": "Camila Veiga",
"image": "2024/02/876563c5bafafee7e31c7ed96a846e00.svg"
},
"120": {
"name": "Turtle",
"author": "Junior Veloso",
"image": "2024/02/0dde4cf30929697c9d9145145771db31.svg"
},
"218": {
"name": "Unicorn",
"author": null,
"image": "2024/03/ed353a6e18917d9c2df0e4278e59b01d.svg"
},
"124": {
"name": "Warped Reuleaux",
"author": "Oasis Mini",
"image": "2024/02/a2aa2e71910c96680f78b65b81201b61.svg"
},
"127": {
"name": "Warped Squares",
"author": "Oasis Mini",
"image": "2024/02/8042b0f37b0cb37c739ac64e754ab774.svg"
},
"169": {
"name": "Whale",
"author": "Oasis Mini",
"image": "2024/03/283e1c9b6ee397a7822c58af01fcbbc3.svg"
},
"287": {
"name": "Windmill",
"author": "Matt",
"image": "2024/05/bcad3d06339ec7a345420191b7201ce1.svg"
},
"500": {
"name": "Wipe Left to Right",
"author": "Xilufer",
"image": "2024/06/3bdc415360a6466cf6245527bf85bd29.svg"
},
"498": {
"name": "Wipe Top to Bottom",
"author": "Xilufer",
"image": "2024/06/56b0cb09f15b44bac418ee2d1ed1940e.svg"
},
"77": {
"name": "Wolf head",
"author": "Camila Veiga",
"image": "2024/02/0c35befdb13ab7702f4c3b71371bf75c.svg"
},
"360": {
"name": "Woodpecker",
"author": "Camila Veiga",
"image": "2024/05/95ea026589751d7fca381f2c3df9380d.svg"
},
"437": {
"name": "Yorkshire",
"author": "Otávio Bittencourt",
"image": "2024/06/be59f584c87cfff3aa13e5887a69e183.svg"
}
}

View File

@@ -4,16 +4,15 @@ import logging
import math import math
from xml.etree.ElementTree import Element, SubElement, tostring from xml.etree.ElementTree import Element, SubElement, tostring
# import re
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
COLOR_DARK = "#28292E"
COLOR_LIGHT = "#FFFFFF" BACKGROUND_FILL = ("#CCC9C4", "#28292E")
COLOR_LIGHT_SHADE = "#FFFFFF" COLOR_DARK = ("#28292E", "#F4F5F8")
COLOR_MEDIUM_SHADE = "#E5E2DE" COLOR_LIGHT = ("#FFFFFF", "#222428")
COLOR_MEDIUM_TINT = "#B8B8B8" COLOR_LIGHT_SHADE = ("#FFFFFF", "#86888F")
FILL_SVG_STATUS = "#CCC9C4" COLOR_MEDIUM_SHADE = ("#E5E2DE", "#86888F")
COLOR_MEDIUM_TINT = ("#B8B8B8", "#FFFFFF")
def _bit_to_bool(val: str) -> bool: def _bit_to_bool(val: str) -> bool:
@@ -27,7 +26,6 @@ def draw_svg(track: dict, progress: int, model_id: str) -> str | None:
try: try:
if progress is not None: if progress is not None:
paths = svg_content.split("L") 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)) total = track.get("reduced_svg_content", {}).get(model_id, len(paths))
percent = (100 * progress) / total percent = (100 * progress) / total
progress = math.floor((percent / 100) * len(paths)) progress = math.floor((percent / 100) * len(paths))
@@ -42,15 +40,24 @@ def draw_svg(track: dict, progress: int, model_id: str) -> str | None:
"class": "svg-status", "class": "svg-status",
}, },
) )
# style = SubElement(svg, "style")
# style.text = """ style = SubElement(svg, "style")
# .progress_arc_incomplete { style.text = f"""
# stroke: #E5E2DE; circle.background {{ fill: {BACKGROUND_FILL[0]}; }}
# } circle.ball {{ stroke: {COLOR_DARK[0]}; fill: {COLOR_LIGHT[0]}; }}
# circle.circleClass { path.progress_arc {{ stroke: {COLOR_MEDIUM_SHADE[0]}; }}
# stroke: #006600; path.progress_arc_complete {{ stroke: {COLOR_DARK[0]}; }}
# fill: #cc0000; path.track {{ stroke: {COLOR_LIGHT_SHADE[0]}; }}
# }""" path.track_complete {{ stroke: {COLOR_MEDIUM_TINT[0]}; }}
@media (prefers-color-scheme: dark) {{
circle.background {{ fill: {BACKGROUND_FILL[1]}; }}
circle.ball {{ stroke: {COLOR_DARK[1]}; fill: {COLOR_LIGHT[1]}; }}
path.progress_arc {{ stroke: {COLOR_MEDIUM_SHADE[1]}; }}
path.progress_arc_complete {{ stroke: {COLOR_DARK[1]}; }}
path.track {{ stroke: {COLOR_LIGHT_SHADE[1]}; }}
path.track_complete {{ stroke: {COLOR_MEDIUM_TINT[1]}; }}
}}"""
group = SubElement( group = SubElement(
svg, svg,
"g", "g",
@@ -63,8 +70,7 @@ def draw_svg(track: dict, progress: int, model_id: str) -> str | None:
group, group,
"path", "path",
{ {
"class": "progress_arc_incomplete", "class": "progress_arc",
"stroke": COLOR_MEDIUM_SHADE,
"stroke-width": "2", "stroke-width": "2",
"d": progress_arc, "d": progress_arc,
}, },
@@ -76,7 +82,7 @@ def draw_svg(track: dict, progress: int, model_id: str) -> str | None:
group, group,
"path", "path",
{ {
"stroke": COLOR_DARK, "class": "progress_arc_complete",
"stroke-width": "4", "stroke-width": "4",
"d": "L".join(progress_arc_paths[:paths_to_draw]), "d": "L".join(progress_arc_paths[:paths_to_draw]),
}, },
@@ -86,8 +92,8 @@ def draw_svg(track: dict, progress: int, model_id: str) -> str | None:
group, group,
"circle", "circle",
{ {
"class": "background",
"r": "100", "r": "100",
"fill": FILL_SVG_STATUS,
"cx": "100", "cx": "100",
"cy": "100", "cy": "100",
"opacity": "0.3", "opacity": "0.3",
@@ -98,7 +104,7 @@ def draw_svg(track: dict, progress: int, model_id: str) -> str | None:
group, group,
"path", "path",
{ {
"stroke": COLOR_LIGHT_SHADE, "class": "track",
"stroke-width": "1.4", "stroke-width": "1.4",
"d": svg_content, "d": svg_content,
}, },
@@ -108,7 +114,7 @@ def draw_svg(track: dict, progress: int, model_id: str) -> str | None:
group, group,
"path", "path",
{ {
"stroke": COLOR_MEDIUM_TINT, "class": "track_complete",
"stroke-width": "1.8", "stroke-width": "1.8",
"d": "L".join(paths[:progress]), "d": "L".join(paths[:progress]),
}, },
@@ -119,9 +125,8 @@ def draw_svg(track: dict, progress: int, model_id: str) -> str | None:
group, group,
"circle", "circle",
{ {
"stroke": COLOR_DARK, "class": "ball",
"stroke-width": "1", "stroke-width": "1",
"fill": COLOR_LIGHT,
"cx": f"{_cx:.2f}", "cx": f"{_cx:.2f}",
"cy": f"{_cy:.2f}", "cy": f"{_cy:.2f}",
"r": "5", "r": "5",
@@ -131,3 +136,4 @@ def draw_svg(track: dict, progress: int, model_id: str) -> str | None:
return tostring(svg).decode() return tostring(svg).decode()
except Exception as e: except Exception as e:
_LOGGER.exception(e) _LOGGER.exception(e)
return None

View File

@@ -0,0 +1,62 @@
"""Oasis Mini select entity."""
from __future__ import annotations
from typing import Any
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .coordinator import OasisMiniCoordinator
from .entity import OasisMiniEntity
from .pyoasismini.const import TRACKS
class OasisMiniSelectEntity(OasisMiniEntity, SelectEntity):
"""Oasis Mini select entity."""
def __init__(
self,
coordinator: OasisMiniCoordinator,
entry: ConfigEntry[Any],
description: EntityDescription,
) -> None:
"""Construct an Oasis Mini select entity."""
super().__init__(coordinator, entry, description)
self._attr_options = [
TRACKS.get(str(track), {}).get("name", str(track))
for track in self.device.playlist
]
@property
def current_option(self) -> str:
"""Return the selected entity option to represent the entity state."""
return self.options[self.device.playlist_index]
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
await self.device.async_change_track(self.options.index(option))
await self.coordinator.async_request_refresh()
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._attr_options = [
TRACKS.get(str(track), {}).get("name", str(track))
for track in self.device.playlist
]
return super()._handle_coordinator_update()
DESCRIPTOR = SelectEntityDescription(key="playlist", name="Playlist")
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up Oasis Mini select using config entry."""
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities([OasisMiniSelectEntity(coordinator, entry, DESCRIPTOR)])

View File

@@ -2,9 +2,6 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorEntity, SensorEntity,
SensorEntityDescription, SensorEntityDescription,
@@ -18,27 +15,19 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN from .const import DOMAIN
from .coordinator import OasisMiniCoordinator from .coordinator import OasisMiniCoordinator
from .entity import OasisMiniEntity from .entity import OasisMiniEntity
from .pyoasismini import OasisMini
@dataclass(frozen=True, kw_only=True) async def async_setup_entry(
class OasisMiniSensorEntityDescription(SensorEntityDescription): hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
"""Oasis Mini sensor entity description.""" ) -> None:
"""Set up Oasis Mini sensors using config entry."""
lookup_fn: Callable[[OasisMini], Any] | None = None coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
[
class OasisMiniSensorEntity(OasisMiniEntity, SensorEntity): OasisMiniSensorEntity(coordinator, entry, descriptor)
"""Oasis Mini sensor entity.""" for descriptor in DESCRIPTORS
]
entity_description: OasisMiniSensorEntityDescription | SensorEntityDescription )
@property
def native_value(self) -> str | None:
"""Return the value reported by the sensor."""
if lookup_fn := getattr(self.entity_description, "lookup_fn", None):
return lookup_fn(self.device)
return getattr(self.device, self.entity_description.key)
DESCRIPTORS = { DESCRIPTORS = {
@@ -49,14 +38,7 @@ DESCRIPTORS = {
name="Download progress", name="Download progress",
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
), ),
OasisMiniSensorEntityDescription( } | {
key="playlist",
name="Playlist",
lookup_fn=lambda device: ",".join(map(str, device.playlist)),
),
}
OTHERS = {
SensorEntityDescription( SensorEntityDescription(
key=key, key=key,
name=key.replace("_", " ").capitalize(), name=key.replace("_", " ").capitalize(),
@@ -73,14 +55,10 @@ OTHERS = {
} }
async def async_setup_entry( class OasisMiniSensorEntity(OasisMiniEntity, SensorEntity):
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback """Oasis Mini sensor entity."""
) -> None:
"""Set up Oasis Mini sensors using config entry.""" @property
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id] def native_value(self) -> str | None:
async_add_entities( """Return the value reported by the sensor."""
[ return getattr(self.device, self.entity_description.key)
OasisMiniSensorEntity(coordinator, entry, descriptor)
for descriptor in DESCRIPTORS | OTHERS
]
)

View File

@@ -6,6 +6,11 @@
"host": "[%key:common::config_flow::data::host%]" "host": "[%key:common::config_flow::data::host%]"
} }
}, },
"reconfigure": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
}
},
"reauth_confirm": { "reauth_confirm": {
"data": {} "data": {}
} }
@@ -17,7 +22,9 @@
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
}, },
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]" "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
} }
}, },
"options": { "options": {

View File

@@ -6,6 +6,11 @@
"host": "Host" "host": "Host"
} }
}, },
"reconfigure": {
"data": {
"host": "Host"
}
},
"reauth_confirm": { "reauth_confirm": {
"data": {} "data": {}
} }
@@ -17,7 +22,9 @@
"unknown": "Unexpected error" "unknown": "Unexpected error"
}, },
"abort": { "abort": {
"already_configured": "Device is already configured" "already_configured": "Device is already configured",
"reauth_successful": "Re-authentication was successful",
"reconfigure_successful": "Re-configuration was successful"
} }
}, },
"options": { "options": {

View File

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

View File

@@ -4,3 +4,10 @@ known-first-party = ["homeassistant", "tests"]
forced-separate = ["tests"] forced-separate = ["tests"]
combine-as-imports = true combine-as-imports = true
split-on-trailing-comma = false split-on-trailing-comma = false
[tool.pylint."MESSAGES CONTROL"]
# abstract-method - with intro of async there are always methods missing
disable = [
"abstract-method",
"unexpected-keyword-arg",
]