1
0
mirror of https://github.com/natekspencer/hacs-oasis_mini.git synced 2025-11-08 05:03:52 -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

35
.devcontainer/README.md Normal file
View File

@@ -0,0 +1,35 @@
## Developing with Visual Studio Code + devcontainer
The easiest way to get started with custom integration development is to use Visual Studio Code with devcontainers. This approach will create a preconfigured development environment with all the tools you need.
In the container you will have a dedicated Home Assistant core instance running with your custom component code. You can configure this instance by updating the `./config/configuration.yaml` file.
**Prerequisites**
- [git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git)
- Docker
- For Linux, macOS, or Windows 10 Pro/Enterprise/Education use the [current release version of Docker](https://docs.docker.com/install/)
- Windows 10 Home requires [WSL 2](https://docs.microsoft.com/windows/wsl/wsl2-install) and the current Edge version of Docker Desktop (see instructions [here](https://docs.docker.com/docker-for-windows/wsl-tech-preview/)). This can also be used for Windows Pro/Enterprise/Education.
- [Visual Studio code](https://code.visualstudio.com/)
- [Remote - Containers (VSC Extension)][extension-link]
[More info about requirements and devcontainer in general](https://code.visualstudio.com/docs/remote/containers#_getting-started)
[extension-link]: https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers
**Getting started:**
1. Fork the repository.
2. Clone the repository to your computer.
3. Open the repository using Visual Studio code.
When you open this repository with Visual Studio code you are asked to "Reopen in Container", this will start the build of the container.
_If you don't see this notification, open the command palette and select `Remote-Containers: Reopen Folder in Container`._
### Step by Step debugging
With the development container,
you can test your custom component in Home Assistant with step by step debugging.
Launch the debugger with the existing debugging configuration `Home Assistant`.

View File

@@ -0,0 +1,37 @@
// 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",
"postAttachCommand": ".devcontainer/setup",
"forwardPorts": [8123],
"customizations": {
"vscode": {
"extensions": [
"ms-python.python",
"ms-python.vscode-pylance",
"esbenp.prettier-vscode",
"github.vscode-pull-request-github",
"ryanluker.vscode-coverage-gutters",
"charliermarsh.ruff"
],
"settings": {
"files.eol": "\n",
"editor.tabSize": 4,
"python.pythonPath": "/usr/bin/python3",
"python.analysis.autoSearchPaths": false,
"editor.formatOnPaste": false,
"editor.formatOnSave": true,
"editor.formatOnType": true,
"editor.codeActionsOnSave": {
"source.organizeImports": "always"
},
"files.trimTrailingWhitespace": true
}
}
},
"remoteUser": "vscode",
"features": {
"rust": "latest"
}
}

9
.devcontainer/setup Executable file
View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -e
cd "$(dirname "$0")/.."
python3 -m pip install --requirement requirements.txt --upgrade
mkdir -p config

34
.github/workflows/release.yaml vendored Normal file
View File

@@ -0,0 +1,34 @@
name: Release
on:
release:
types: [published]
permissions: {}
jobs:
release:
name: Release
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set version number
shell: bash
run: |
yq -i -o json '.version="${{ github.event.release.tag_name }}"' \
"${{ github.workspace }}/custom_components/oasis_mini/manifest.json"
- name: ZIP integration directory
shell: bash
run: |
cd "${{ github.workspace }}/custom_components/oasis_mini"
zip oasis_mini.zip -r ./
- name: Upload ZIP file to release
uses: softprops/action-gh-release@v2
with:
files: ${{ github.workspace }}/custom_components/oasis_mini/oasis_mini.zip

169
.gitignore vendored Normal file
View File

@@ -0,0 +1,169 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
VS Code Settings / Launch files
.vscode/
# macOS
.DS_Store
# Home Assistant
config/

64
README.md Normal file
View File

@@ -0,0 +1,64 @@
![Release](https://img.shields.io/github/v/release/natekspencer/hacs-oasis_mini?style=for-the-badge)
[![Buy Me A Coffee/Beer](https://img.shields.io/badge/Buy_Me_A_☕/🍺-F16061?style=for-the-badge&logo=ko-fi&logoColor=white&labelColor=grey)](https://ko-fi.com/natekspencer)
[![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg?style=for-the-badge)](https://github.com/hacs/integration)
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://brands.home-assistant.io/oasis_mini/dark_logo.png">
<img alt="Oasis Mini logo" src="https://brands.home-assistant.io/oasis_mini/logo.png">
</picture>
# Oasis Mini for Home Assistant
Home Assistant integration for Oasis Mini kinetic sand art devices.
# Installation
There are two main ways to install this custom component within your Home Assistant instance:
1. Using HACS (see https://hacs.xyz/ for installation instructions if you do not already have it installed):
[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=natekspencer&repository=hacs-oasis_mini&category=integration)
1. Use the convenient My Home Assistant link above, or, from within Home Assistant, click on the link to **HACS**
2. Click on **Integrations**
3. Click on the vertical ellipsis in the top right and select **Custom repositories**
4. Enter the URL for this repository in the section that says _Add custom repository URL_ and select **Integration** in the _Category_ dropdown list
5. Click the **ADD** button
6. Close the _Custom repositories_ window
7. You should now be able to see the _Oasis Mini_ card on the HACS Integrations page. Click on **INSTALL** and proceed with the installation instructions.
8. Restart your Home Assistant instance and then proceed to the _Configuration_ section below.
2. Manual Installation:
1. Download or clone this repository
2. Copy the contents of the folder **custom_components/oasis_mini** into the same file structure on your Home Assistant instance
- An easy way to do this is using the [Samba add-on](https://www.home-assistant.io/getting-started/configuration/#editing-configuration-via-sambawindows-networking), but feel free to do so however you want
3. Restart your Home Assistant instance and then proceed to the _Configuration_ section below.
While the manual installation above seems like less steps, it's important to note that you will not be able to see updates to this custom component unless you are subscribed to the watch list. You will then have to repeat each step in the process. By using HACS, you'll be able to see that an update is available and easily update the custom component.
# Configuration
[![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=oasis_mini)
There is a config flow for this Oasis Mini integration. After installing the custom component, use the convenient My Home Assistant link above.
Alternatively:
1. Go to **Configuration**->**Integrations**
2. Click **+ ADD INTEGRATION** to setup a new integration
3. Search for **Oasis Mini** and click on it
4. You will be guided through the rest of the setup process via the config flow
# Options
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.
---
## Support Me
I'm not employed by Kinetic Oasis, and provide this custom component purely for your own enjoyment and home automation needs.
If you already own an Oasis Mini, found this integration useful and want to donate, consider [sponsoring me on GitHub](https://github.com/sponsors/natekspencer) or buying me a coffee ☕ (or beer 🍺) instead by using the link below:
<a href='https://ko-fi.com/Y8Y57F59S' target='_blank'><img height='36' style='border:0px;height:36px;' src='https://storage.ko-fi.com/cdn/kofi1.png?v=3' border='0' alt='Buy Me a Coffee at ko-fi.com' /></a>

24
config/configuration.yaml Normal file
View File

@@ -0,0 +1,24 @@
# Limited configuration instead of default_config
# https://www.home-assistant.io/integrations/default_config
automation:
dhcp:
frontend:
history:
logbook:
media_source:
logger:
default: info
logs:
custom_components.oasis_mini: debug
homeassistant:
name: HACS-Oasis Mini
auth_providers:
- type: trusted_networks
trusted_networks:
- 127.0.0.1
- 192.0.0.0/8
- ::1
allow_bypass_login: true
- type: homeassistant

View File

@@ -0,0 +1,64 @@
"""Support for Oasis Mini."""
from __future__ import annotations
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import DOMAIN
from .coordinator import OasisMiniCoordinator
from .helpers import create_client
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [
Platform.IMAGE,
Platform.LIGHT,
Platform.MEDIA_PLAYER,
Platform.NUMBER,
Platform.SENSOR,
Platform.SWITCH,
]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Oasis Mini from a config entry."""
hass.data.setdefault(DOMAIN, {})
client = create_client(entry.data | entry.options)
coordinator = OasisMiniCoordinator(hass, client)
await coordinator.async_config_entry_first_refresh()
if not coordinator.data:
raise ConfigEntryNotReady
hass.data[DOMAIN][entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(update_listener))
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
await hass.data[DOMAIN][entry.entry_id].device.session.close()
del hass.data[DOMAIN][entry.entry_id]
return unload_ok
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle removal of an entry."""
if entry.options:
client = create_client(entry.data | entry.options)
await client.async_cloud_logout()
await client.session.close()
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)

View File

@@ -0,0 +1,153 @@
"""Config flow for Oasis Mini integration."""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from aiohttp import ClientConnectorError
from httpx import ConnectError, HTTPStatusError
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry, ConfigFlow
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_HOST, CONF_PASSWORD
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.schema_config_entry_flow import (
SchemaCommonFlowHandler,
SchemaFlowError,
SchemaFlowFormStep,
SchemaOptionsFlowHandler,
)
from .const import DOMAIN
from .coordinator import OasisMiniCoordinator
from .helpers import create_client
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
OPTIONS_SCHEMA = vol.Schema(
{
vol.Optional(CONF_EMAIL): str,
vol.Optional(CONF_PASSWORD): str,
}
)
async def cloud_login(
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
) -> dict[str, Any]:
coordinator: OasisMiniCoordinator = handler.parent_handler.hass.data[DOMAIN][
handler.parent_handler.config_entry.entry_id
]
try:
await coordinator.device.async_cloud_login(
email=user_input[CONF_EMAIL], password=user_input[CONF_PASSWORD]
)
user_input[CONF_ACCESS_TOKEN] = coordinator.device.access_token
except:
raise SchemaFlowError("invalid_auth")
del user_input[CONF_PASSWORD]
return user_input
OPTIONS_FLOW = {
"init": SchemaFlowFormStep(OPTIONS_SCHEMA, validate_user_input=cloud_login)
}
class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Oasis Mini."""
VERSION = 1
host: str | None = None
serial_number: str | None = None
@staticmethod
@callback
def async_get_options_flow(config_entry: ConfigEntry) -> SchemaOptionsFlowHandler:
"""Get the options flow for this handler."""
return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW)
# async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult:
# """Handle dhcp discovery."""
# self.host = discovery_info.ip
# self.name = discovery_info.hostname
# await self.async_set_unique_id(discovery_info.macaddress)
# self._abort_if_unique_id_configured(updates={CONF_HOST: self.host})
# return await self.async_step_api_key()
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
return await self._async_step("user", STEP_USER_DATA_SCHEMA, user_input)
async def _async_step(
self, step_id: str, schema: vol.Schema, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle step setup."""
if abort := self._abort_if_configured(user_input):
return abort
errors = {}
if user_input is not None:
if not (errors := await self.validate_client(user_input)):
data = {CONF_HOST: user_input.get(CONF_HOST, self.host)}
if existing_entry := self.hass.config_entries.async_get_entry(
self.context.get("entry_id")
):
self.hass.config_entries.async_update_entry(
existing_entry, data=data
)
await self.hass.config_entries.async_reload(existing_entry.entry_id)
return self.async_abort(reason="reauth_successful")
return self.async_create_entry(
title=f"Oasis Mini {self.serial_number}",
data=data,
)
return self.async_show_form(step_id=step_id, data_schema=schema, errors=errors)
async def validate_client(self, user_input: dict[str, Any]) -> dict[str, str]:
"""Validate client setup."""
errors = {}
try:
client = create_client({"host": self.host} | user_input)
self.serial_number = await client.async_get_serial_number()
if not self.serial_number:
errors["base"] = "invalid_host"
except asyncio.TimeoutError:
errors["base"] = "timeout_connect"
except ConnectError:
errors["base"] = "invalid_host"
except ClientConnectorError:
errors["base"] = "invalid_host"
except HTTPStatusError as err:
errors["base"] = str(err)
except Exception as ex: # pylint: disable=broad-except
_LOGGER.error(ex)
errors["base"] = "unknown"
finally:
await client.session.close()
return errors
@callback
def _abort_if_configured(
self, user_input: dict[str, Any] | None
) -> FlowResult | None:
"""Abort if configured."""
if self.host or user_input:
data = {CONF_HOST: self.host, **(user_input or {})}
for entry in self._async_current_entries():
if entry.data[CONF_HOST] == data[CONF_HOST]:
return self.async_abort(reason="already_configured")
return None

View File

@@ -0,0 +1,5 @@
"""Constants for the Oasis Mini integration."""
from typing import Final
DOMAIN: Final = "oasis_mini"

View File

@@ -0,0 +1,47 @@
"""Oasis Mini coordinator."""
from __future__ import annotations
from datetime import datetime, timedelta
import logging
import async_timeout
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
from .pyoasismini import OasisMini
_LOGGER = logging.getLogger(__name__)
class OasisMiniCoordinator(DataUpdateCoordinator[str]):
"""Oasis Mini data update coordinator."""
last_updated: datetime | None = None
def __init__(self, hass: HomeAssistant, device: OasisMini) -> None:
"""Initialize."""
super().__init__(
hass, _LOGGER, name=DOMAIN, update_interval=timedelta(seconds=10)
)
self.device = device
async def _async_update_data(self):
try:
async with async_timeout.timeout(10):
if not self.device.serial_number:
await self.device.async_get_serial_number()
if not self.device.software_version:
await self.device.async_get_software_version()
data = await self.device.async_get_status()
await self.device.async_get_current_track_details()
except Exception as ex:
raise UpdateFailed("Couldn't read oasis_mini") from ex
if data is None:
raise ConfigEntryAuthFailed
if data != self.data:
self.last_updated = datetime.now()
return data

View File

@@ -0,0 +1,47 @@
"""Oasis Mini entity."""
from __future__ import annotations
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.entity import DeviceInfo, EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import OasisMiniCoordinator
from .pyoasismini import OasisMini
_LOGGER = logging.getLogger(__name__)
class OasisMiniEntity(CoordinatorEntity[OasisMiniCoordinator]):
"""Base class for Oasis Mini entities."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: OasisMiniCoordinator,
entry: ConfigEntry,
description: EntityDescription,
) -> None:
"""Construct a Oasis Mini entity."""
super().__init__(coordinator)
self.entity_description = description
serial_number = coordinator.device.serial_number
self._attr_unique_id = f"{serial_number}-{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, serial_number)},
name=entry.title,
manufacturer="Kinetic Oasis",
model="Oasis Mini",
serial_number=serial_number,
sw_version=coordinator.device.software_version,
)
@property
def device(self) -> OasisMini:
"""Return the device."""
return self.coordinator.device

View File

@@ -0,0 +1,14 @@
"""Helpers for the Oasis Mini integration."""
from __future__ import annotations
from typing import Any
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST
from .pyoasismini import OasisMini
def create_client(data: dict[str, Any]) -> OasisMini:
"""Create a Oasis Mini local client."""
return OasisMini(data[CONF_HOST], data.get(CONF_ACCESS_TOKEN))

View File

@@ -0,0 +1,55 @@
"""Oasis Mini image entity."""
from __future__ import annotations
from datetime import datetime
from homeassistant.components.image import ImageEntity, ImageEntityDescription
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.utils import draw_svg
IMAGE = ImageEntityDescription(key="image", name=None)
class OasisMiniImageEntity(OasisMiniEntity, ImageEntity):
"""Oasis Mini image entity."""
_attr_content_type = "image/svg+xml"
def __init__(
self,
coordinator: OasisMiniCoordinator,
entry_id: str,
description: ImageEntityDescription,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator, entry_id, description)
ImageEntity.__init__(self, coordinator.hass)
@property
def image_last_updated(self) -> datetime | None:
"""The time when the image was last updated."""
return self.coordinator.last_updated
def image(self) -> bytes | None:
"""Return bytes of image."""
return draw_svg(
self.device._current_track_details,
self.device.progress,
"1",
)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up Oasis Mini camera using config entry."""
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id]
if coordinator.device.access_token:
async_add_entities([OasisMiniImageEntity(coordinator, entry, IMAGE)])

View File

@@ -0,0 +1,118 @@
"""Oasis Mini light entity."""
from __future__ import annotations
import math
from typing import Any
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_EFFECT,
ATTR_RGB_COLOR,
ColorMode,
LightEntity,
LightEntityDescription,
LightEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util.color import (
brightness_to_value,
color_rgb_to_hex,
rgb_hex_to_rgb_list,
value_to_brightness,
)
from .const import DOMAIN
from .coordinator import OasisMiniCoordinator
from .entity import OasisMiniEntity
from .pyoasismini import LED_EFFECTS
class OasisMiniLightEntity(OasisMiniEntity, LightEntity):
"""Oasis Mini light entity."""
_attr_supported_features = LightEntityFeature.EFFECT
@property
def brightness(self) -> int:
"""Return the brightness of this light between 0..255."""
scale = (1, self.device.max_brightness)
return value_to_brightness(scale, self.device.brightness)
@property
def color_mode(self) -> ColorMode:
"""Return the color mode of the light."""
# if self.effect in (
# "Rainbow",
# "Glitter",
# "Confetti",
# "BPM",
# "Juggle",
# "Theater",
# ):
# return ColorMode.BRIGHTNESS
return ColorMode.RGB
@property
def effect(self) -> str:
"""Return the current effect."""
return LED_EFFECTS.get(self.device.led_effect)
@property
def effect_list(self) -> list[str]:
"""Return the list of supported effects."""
return list(LED_EFFECTS.values())
@property
def is_on(self) -> bool:
"""Return True if entity is on."""
return self.device.brightness > 0
@property
def rgb_color(self) -> tuple[int, int, int]:
"""Return the rgb color value [int, int, int]."""
return rgb_hex_to_rgb_list(self.device.color.replace("#", ""))
@property
def supported_color_modes(self) -> set[ColorMode]:
"""Flag supported color modes."""
return {ColorMode.RGB}
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
await self.device.async_set_led(brightness=0)
await self.coordinator.async_request_refresh()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
if brightness := kwargs.get(ATTR_BRIGHTNESS):
scale = (1, self.device.max_brightness)
brightness = math.ceil(brightness_to_value(scale, brightness))
else:
brightness = self.device.brightness or 100
if color := kwargs.get(ATTR_RGB_COLOR):
color = f"#{color_rgb_to_hex(*color)}"
if led_effect := kwargs.get(ATTR_EFFECT):
led_effect = next(
(k for k, v in LED_EFFECTS.items() if v == led_effect), None
)
await self.device.async_set_led(
brightness=brightness, color=color, led_effect=led_effect
)
await self.coordinator.async_request_refresh()
DESCRIPTOR = LightEntityDescription(key="led", name="LED")
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up Oasis Mini lights using config entry."""
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities([OasisMiniLightEntity(coordinator, entry, DESCRIPTOR)])

View File

@@ -0,0 +1,12 @@
{
"domain": "oasis_mini",
"name": "Oasis Mini",
"codeowners": ["@natekspencer"],
"config_flow": true,
"documentation": "https://github.com/natekspencer/hacs-oasis_mini",
"integration_type": "device",
"iot_class": "local_polling",
"issue_tracker": "https://github.com/natekspencer/hacs-oasis_mini/issues",
"loggers": ["custom_components.oasis_mini"],
"version": "0.0.0"
}

View File

@@ -0,0 +1,130 @@
"""Oasis Mini media player entity."""
from __future__ import annotations
from datetime import datetime
import math
from homeassistant.components.media_player import (
MediaPlayerEntity,
MediaPlayerEntityDescription,
MediaPlayerEntityFeature,
MediaPlayerState,
MediaType,
RepeatMode,
)
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
BRIGHTNESS_SCALE = (1, 200)
class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
"""Oasis Mini media player entity."""
_attr_media_image_remotely_accessible = True
_attr_supported_features = (
MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.REPEAT_SET
)
@property
def media_content_type(self) -> MediaType:
"""Content type of current playing media."""
return MediaType.IMAGE
@property
def media_duration(self) -> int:
"""Duration of current playing media in seconds."""
if (
track_details := self.device._current_track_details
) and "reduced_svg_content" in track_details:
return track_details["reduced_svg_content"].get("1")
return math.ceil(self.media_position / 0.99)
@property
def media_image_url(self) -> str | None:
"""Image url of current playing media."""
if (
track_details := self.device._current_track_details
) and "image" in track_details:
return f"https://app.grounded.so/uploads/{track_details['image']}"
return None
@property
def media_position(self) -> int:
"""Position of current playing media in seconds."""
return self.device.progress
@property
def media_position_updated_at(self) -> datetime | None:
"""When was the position of the current playing media valid."""
return self.coordinator.last_updated
@property
def media_title(self) -> str:
"""Title of current playing media."""
if track_details := self.device._current_track_details:
return track_details.get("name", self.device.current_track_id)
return f"Unknown Title (#{self.device.current_track_id})"
@property
def repeat(self) -> RepeatMode:
"""Return current repeat mode."""
if self.device.repeat_playlist:
return RepeatMode.ALL
return RepeatMode.OFF
@property
def state(self) -> MediaPlayerState:
"""State of the player."""
status_code = self.device.status_code
if status_code in (3, 13):
return MediaPlayerState.BUFFERING
if status_code in (2, 5):
return MediaPlayerState.PAUSED
if status_code == 4:
return MediaPlayerState.PLAYING
return MediaPlayerState.STANDBY
async def async_media_pause(self) -> None:
"""Send pause command."""
await self.device.async_pause()
await self.coordinator.async_request_refresh()
async def async_media_play(self) -> None:
"""Send play command."""
await self.device.async_play()
await self.coordinator.async_request_refresh()
async def async_set_repeat(self, repeat: RepeatMode) -> None:
"""Set repeat mode."""
await self.device.async_set_repeat_playlist(
repeat != RepeatMode.OFF
and not (repeat == RepeatMode.ONE and self.repeat == RepeatMode.ALL)
)
await self.coordinator.async_request_refresh()
async def async_media_next_track(self) -> None:
"""Send next track command."""
if (index := self.device.playlist_index + 1) >= len(self.device.playlist):
index = 0
return await self.device.async_change_track(index)
DESCRIPTOR = MediaPlayerEntityDescription(key="oasis_mini", name=None)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up Oasis Mini media_players using config entry."""
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities([OasisMiniMediaPlayerEntity(coordinator, entry, DESCRIPTOR)])

View File

@@ -0,0 +1,58 @@
"""Oasis Mini number entity."""
from __future__ import annotations
from homeassistant.components.number import NumberEntity, NumberEntityDescription
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
class OasisMiniNumberEntity(OasisMiniEntity, NumberEntity):
"""Oasis Mini number entity."""
@property
def native_value(self) -> str | None:
"""Return the value reported by the number."""
return getattr(self.device, self.entity_description.key)
async def async_set_native_value(self, value: float) -> None:
"""Set new value."""
if self.entity_description.key == "ball_speed":
await self.device.async_set_ball_speed(value)
elif self.entity_description.key == "led_speed":
await self.device.async_set_led(led_speed=value)
await self.coordinator.async_request_refresh()
DESCRIPTORS = {
NumberEntityDescription(
key="ball_speed",
name="Ball speed",
native_max_value=800,
native_min_value=200,
),
NumberEntityDescription(
key="led_speed",
name="LED speed",
native_max_value=90,
native_min_value=-90,
),
}
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up Oasis Mini numbers using config entry."""
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
[
OasisMiniNumberEntity(coordinator, entry, descriptor)
for descriptor in DESCRIPTORS
]
)

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)

View File

@@ -0,0 +1,86 @@
"""Oasis Mini sensor entity."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable
from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
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
@dataclass(frozen=True, kw_only=True)
class OasisMiniSensorEntityDescription(SensorEntityDescription):
"""Oasis Mini sensor entity description."""
lookup_fn: Callable[[OasisMini], Any] | None = None
class OasisMiniSensorEntity(OasisMiniEntity, SensorEntity):
"""Oasis Mini sensor entity."""
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 = {
SensorEntityDescription(
key="download_progress",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
name="Download progress",
state_class=SensorStateClass.TOTAL_INCREASING,
),
OasisMiniSensorEntityDescription(
key="playlist",
name="Playlist",
lookup_fn=lambda device: ",".join(map(str, device.playlist)),
),
}
OTHERS = {
SensorEntityDescription(
key=key,
name=key.replace("_", " ").capitalize(),
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
)
for key in (
"busy",
"error",
"led_color_id",
"status",
"wifi_connected",
)
}
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up Oasis Mini sensors using config entry."""
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
[
OasisMiniSensorEntity(coordinator, entry, descriptor)
for descriptor in DESCRIPTORS | OTHERS
]
)

View File

@@ -0,0 +1,36 @@
{
"config": {
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
}
},
"reauth_confirm": {
"data": {}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_host": "[%key:common::config_flow::error::invalid_host%]",
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"options": {
"step": {
"init": {
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
}
}
},
"error": {
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
}
}
}

View File

@@ -0,0 +1,64 @@
"""Oasis Mini switch entity."""
from __future__ import annotations
from typing import Any
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
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
class OasisMiniSwitchEntity(OasisMiniEntity, SwitchEntity):
"""Oasis Mini switch entity."""
@property
def is_on(self) -> bool:
"""Return True if entity is on."""
return int(getattr(self.device, self.entity_description.key))
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
if self.entity_description.key == "pause_between_tracks":
await self.device.async_set_pause_between_tracks(False)
elif self.entity_description.key == "repeat_playlist":
await self.device.async_set_repeat_playlist(False)
await self.coordinator.async_request_refresh()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
if self.entity_description.key == "pause_between_tracks":
await self.device.async_set_pause_between_tracks(True)
elif self.entity_description.key == "repeat_playlist":
await self.device.async_set_repeat_playlist(True)
await self.coordinator.async_request_refresh()
DESCRIPTORS = {
SwitchEntityDescription(
key="pause_between_tracks",
name="Pause between tracks",
),
# SwitchEntityDescription(
# key="repeat_playlist",
# name="Repeat playlist",
# ),
}
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up Oasis Mini switchs using config entry."""
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
[
OasisMiniSwitchEntity(coordinator, entry, descriptor)
for descriptor in DESCRIPTORS
]
)

View File

@@ -0,0 +1,36 @@
{
"config": {
"step": {
"user": {
"data": {
"host": "Host"
}
},
"reauth_confirm": {
"data": {}
}
},
"error": {
"cannot_connect": "Failed to connect",
"invalid_host": "Invalid hostname or IP address",
"timeout_connect": "Timeout establishing connection",
"unknown": "Unexpected error"
},
"abort": {
"already_configured": "Device is already configured"
}
},
"options": {
"step": {
"init": {
"data": {
"email": "Email",
"password": "Password"
}
}
},
"error": {
"invalid_auth": "Invalid authentication"
}
}
}

7
hacs.json Normal file
View File

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

6
pyproject.toml Normal file
View File

@@ -0,0 +1,6 @@
[tool.ruff.lint.isort]
force-sort-within-sections = true
known-first-party = ["homeassistant", "tests"]
forced-separate = ["tests"]
combine-as-imports = true
split-on-trailing-comma = false

13
requirements.txt Normal file
View File

@@ -0,0 +1,13 @@
# Home Assistant
homeassistant>=2024.4
home-assistant-frontend
numpy
PyTurboJPEG
# Integration
aiohttp
# Development
colorlog
pip>=21.0
ruff