mirror of
https://github.com/natekspencer/hacs-oasis_mini.git
synced 2025-11-17 01:23:43 -05:00
Initial commit
This commit is contained in:
64
custom_components/oasis_mini/__init__.py
Executable file
64
custom_components/oasis_mini/__init__.py
Executable 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)
|
||||
153
custom_components/oasis_mini/config_flow.py
Executable file
153
custom_components/oasis_mini/config_flow.py
Executable 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
|
||||
5
custom_components/oasis_mini/const.py
Executable file
5
custom_components/oasis_mini/const.py
Executable file
@@ -0,0 +1,5 @@
|
||||
"""Constants for the Oasis Mini integration."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
DOMAIN: Final = "oasis_mini"
|
||||
47
custom_components/oasis_mini/coordinator.py
Normal file
47
custom_components/oasis_mini/coordinator.py
Normal 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
|
||||
47
custom_components/oasis_mini/entity.py
Normal file
47
custom_components/oasis_mini/entity.py
Normal 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
|
||||
14
custom_components/oasis_mini/helpers.py
Executable file
14
custom_components/oasis_mini/helpers.py
Executable 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))
|
||||
55
custom_components/oasis_mini/image.py
Normal file
55
custom_components/oasis_mini/image.py
Normal 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)])
|
||||
118
custom_components/oasis_mini/light.py
Normal file
118
custom_components/oasis_mini/light.py
Normal 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)])
|
||||
12
custom_components/oasis_mini/manifest.json
Executable file
12
custom_components/oasis_mini/manifest.json
Executable 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"
|
||||
}
|
||||
130
custom_components/oasis_mini/media_player.py
Normal file
130
custom_components/oasis_mini/media_player.py
Normal 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)])
|
||||
58
custom_components/oasis_mini/number.py
Normal file
58
custom_components/oasis_mini/number.py
Normal 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
|
||||
]
|
||||
)
|
||||
272
custom_components/oasis_mini/pyoasismini/__init__.py
Normal file
272
custom_components/oasis_mini/pyoasismini/__init__.py
Normal 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
|
||||
)
|
||||
133
custom_components/oasis_mini/pyoasismini/utils.py
Normal file
133
custom_components/oasis_mini/pyoasismini/utils.py
Normal 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)
|
||||
86
custom_components/oasis_mini/sensor.py
Normal file
86
custom_components/oasis_mini/sensor.py
Normal 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
|
||||
]
|
||||
)
|
||||
36
custom_components/oasis_mini/strings.json
Executable file
36
custom_components/oasis_mini/strings.json
Executable 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%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
64
custom_components/oasis_mini/switch.py
Normal file
64
custom_components/oasis_mini/switch.py
Normal 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
|
||||
]
|
||||
)
|
||||
36
custom_components/oasis_mini/translations/en.json
Executable file
36
custom_components/oasis_mini/translations/en.json
Executable 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user