1
0
mirror of https://github.com/natekspencer/hacs-oasis_mini.git synced 2025-11-13 23:53:51 -05:00

26 Commits
0.3.1 ... 0.7.6

Author SHA1 Message Date
Nathan Spencer
b1f211d843 Merge pull request #20 from natekspencer/dev
Handle empty color code
2024-08-06 09:38:42 -06:00
Nathan Spencer
99bf3b2ef0 Handle empty color code 2024-08-06 09:36:59 -06:00
Nathan Spencer
3f4f7720c0 Merge pull request #19 from natekspencer/dev
Use runtime data instead of hass.data and other code cleanup
2024-08-04 14:08:16 -06:00
Nathan Spencer
6e13c22d43 Use runtime data instead of hass.data and other code cleanup 2024-08-04 14:06:26 -06:00
Nathan Spencer
f5bf50a801 Merge pull request #18 from natekspencer/dev
Better error handling
2024-08-03 17:33:21 -06:00
Nathan Spencer
33e62528ba Better error handling 2024-08-03 17:31:30 -06:00
Nathan Spencer
3014f0f11c Merge pull request #16 from natekspencer/dev
Handle invalid index bug in play random track button
2024-08-02 12:03:07 -06:00
Nathan Spencer
a44c035828 Handle invalid index bug in play random track button 2024-08-02 12:01:27 -06:00
Nathan Spencer
31276048dc Merge pull request #15 from natekspencer/natekspencer-patch-1
Create dependabot.yml
2024-08-02 07:24:40 -06:00
Nathan Spencer
742fc26a4f Create dependabot.yml 2024-08-02 07:21:26 -06:00
Nathan Spencer
3acd45da9d Merge pull request #14 from natekspencer/dev
Revert command timeout logic
2024-07-31 21:04:57 -06:00
Nathan Spencer
a736c72c8e Revert timeout changes, I'll fix later 2024-07-31 21:03:33 -06:00
Nathan Spencer
c87bb241ef Allow reboot command even if device is busy 2024-07-31 20:55:37 -06:00
Nathan Spencer
6ee81db9d4 Merge pull request #13 from natekspencer/dev
Add support for enqueue options in media_player.play_media service and other minor improvements
2024-07-31 19:28:56 -06:00
Nathan Spencer
6d6b7929d5 Fix hassfest error 2024-07-31 19:25:02 -06:00
Nathan Spencer
cc80c295f6 Add support for enqueue options in media_player.play_media service and other minor improvements 2024-07-31 19:16:15 -06:00
Nathan Spencer
423e7eba9f Merge pull request #12 from natekspencer/dev
Handle unknown track ids
2024-07-31 00:10:58 -06:00
Nathan Spencer
d70dd0a650 Handle unknown track ids 2024-07-31 00:09:21 -06:00
Nathan Spencer
cee752b6ce Merge pull request #11 from natekspencer/dev
Add additional features
2024-07-30 23:50:08 -06:00
Nathan Spencer
3b90603bef Add additional features 2024-07-30 23:47:14 -06:00
Nathan Spencer
e77804ec0d Merge pull request #10 from natekspencer/dev
Handle IP update from DHCP and add drawing progress sensor
2024-07-25 10:55:44 -06:00
Nathan Spencer
96edafd006 Handle IP update from DHCP and add drawing progress sensor 2024-07-25 10:52:47 -06:00
Nathan Spencer
71180f68f9 Merge pull request #9 from natekspencer/dev
Updates to handle firmware version 0.71 and other improvements
2024-07-18 13:05:28 -06:00
Nathan Spencer
0d539888e5 Updates to handle firmware version 0.71 and other improvements 2024-07-18 13:03:19 -06:00
Nathan Spencer
4186755a92 Merge pull request #8 from natekspencer/dev
Add update entity
2024-07-17 09:47:02 -06:00
Nathan Spencer
7c8ca361ba Add update entity 2024-07-17 09:44:32 -06:00
24 changed files with 2360 additions and 665 deletions

11
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,11 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "pip" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"

View File

@@ -1,6 +1,9 @@
![Release](https://img.shields.io/github/v/release/natekspencer/hacs-oasis_mini?style=for-the-badge) [![Release](https://img.shields.io/github/v/release/natekspencer/hacs-oasis_mini?style=for-the-badge)](https://github.com/natekspencer/hacs-oasis_mini/releases)
[![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) [![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) [![HACS Custom](https://img.shields.io/badge/HACS-Custom-41BDF5.svg?style=for-the-badge)](https://github.com/hacs/integration)
![Downloads](https://img.shields.io/github/downloads/natekspencer/hacs-oasis_mini/total?style=flat-square)
![Latest Downloads](https://img.shields.io/github/downloads/natekspencer/hacs-oasis_mini/latest/total?style=flat-square)
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="https://brands.home-assistant.io/oasis_mini/dark_logo.png"> <source media="(prefers-color-scheme: dark)" srcset="https://brands.home-assistant.io/oasis_mini/dark_logo.png">

View File

@@ -7,12 +7,15 @@ import logging
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
import homeassistant.helpers.device_registry as dr
from .const import DOMAIN from .const import DOMAIN
from .coordinator import OasisMiniCoordinator from .coordinator import OasisMiniCoordinator
from .helpers import create_client from .helpers import create_client
type OasisMiniConfigEntry = ConfigEntry[OasisMiniCoordinator]
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
PLATFORMS = [ PLATFORMS = [
@@ -23,13 +26,13 @@ PLATFORMS = [
Platform.NUMBER, Platform.NUMBER,
Platform.SELECT, Platform.SELECT,
Platform.SENSOR, Platform.SENSOR,
Platform.SWITCH, # Platform.SWITCH,
Platform.UPDATE,
] ]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: OasisMiniConfigEntry) -> bool:
"""Set up Oasis Mini from a config entry.""" """Set up Oasis Mini from a config entry."""
hass.data.setdefault(DOMAIN, {})
client = create_client(entry.data | entry.options) client = create_client(entry.data | entry.options)
coordinator = OasisMiniCoordinator(hass, client) coordinator = OasisMiniCoordinator(hass, client)
@@ -38,11 +41,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except Exception as ex: except Exception as ex:
_LOGGER.exception(ex) _LOGGER.exception(ex)
if not entry.unique_id:
if not (serial_number := coordinator.device.serial_number):
dev_reg = dr.async_get(hass)
devices = dr.async_entries_for_config_entry(dev_reg, entry.entry_id)
serial_number = next(
(
identifier[1]
for identifier in devices[0].identifiers
if identifier[0] == DOMAIN
),
None,
)
hass.config_entries.async_update_entry(entry, unique_id=serial_number)
if not coordinator.data: if not coordinator.data:
await client.session.close() await client.session.close()
raise ConfigEntryNotReady raise ConfigEntryNotReady
hass.data[DOMAIN][entry.entry_id] = coordinator if entry.unique_id != coordinator.device.serial_number:
await client.session.close()
raise ConfigEntryError("Serial number mismatch")
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -50,15 +71,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: OasisMiniConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): await entry.runtime_data.device.session.close()
await hass.data[DOMAIN][entry.entry_id].device.session.close() return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
del hass.data[DOMAIN][entry.entry_id]
return unload_ok
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: async def async_remove_entry(hass: HomeAssistant, entry: OasisMiniConfigEntry) -> None:
"""Handle removal of an entry.""" """Handle removal of an entry."""
if entry.options: if entry.options:
client = create_client(entry.data | entry.options) client = create_client(entry.data | entry.options)
@@ -66,6 +85,6 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
await client.session.close() await client.session.close()
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: async def update_listener(hass: HomeAssistant, entry: OasisMiniConfigEntry) -> None:
"""Handle options update.""" """Handle options update."""
await hass.config_entries.async_reload(entry.entry_id) await hass.config_entries.async_reload(entry.entry_id)

View File

@@ -11,25 +11,26 @@ from homeassistant.components.button import (
ButtonEntity, ButtonEntity,
ButtonEntityDescription, ButtonEntityDescription,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN from . import OasisMiniConfigEntry
from .coordinator import OasisMiniCoordinator
from .entity import OasisMiniEntity from .entity import OasisMiniEntity
from .helpers import add_and_play_track
from .pyoasismini import OasisMini from .pyoasismini import OasisMini
from .pyoasismini.const import TRACKS from .pyoasismini.const import TRACKS
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant,
entry: OasisMiniConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Oasis Mini button using config entry.""" """Set up Oasis Mini button using config entry."""
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities( async_add_entities(
[ [
OasisMiniButtonEntity(coordinator, entry, descriptor) OasisMiniButtonEntity(entry.runtime_data, descriptor)
for descriptor in DESCRIPTORS for descriptor in DESCRIPTORS
] ]
) )
@@ -37,15 +38,8 @@ async def async_setup_entry(
async def play_random_track(device: OasisMini) -> None: async def play_random_track(device: OasisMini) -> None:
"""Play random track.""" """Play random track."""
track = int(random.choice(list(TRACKS))) track = random.choice(list(TRACKS))
if track not in device.playlist: await add_and_play_track(device, track)
await device.async_add_track_to_playlist(track)
# Move track to next item in the playlist and then select it
if (idx := device.playlist.index(track)) != (next_idx := device.playlist_index + 1):
await device.async_move_track(idx, next_idx)
await device.async_change_track(next_idx)
await device.async_play()
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
@@ -59,6 +53,7 @@ DESCRIPTORS = (
OasisMiniButtonEntityDescription( OasisMiniButtonEntityDescription(
key="reboot", key="reboot",
device_class=ButtonDeviceClass.RESTART, device_class=ButtonDeviceClass.RESTART,
entity_category=EntityCategory.CONFIG,
press_fn=lambda device: device.async_reboot(), press_fn=lambda device: device.async_reboot(),
), ),
OasisMiniButtonEntityDescription( OasisMiniButtonEntityDescription(

View File

@@ -10,7 +10,8 @@ from aiohttp import ClientConnectorError
from httpx import ConnectError, HTTPStatusError from httpx import ConnectError, HTTPStatusError
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.components import dhcp
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_HOST, CONF_PASSWORD from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_HOST, CONF_PASSWORD
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers.schema_config_entry_flow import ( from homeassistant.helpers.schema_config_entry_flow import (
@@ -20,6 +21,7 @@ from homeassistant.helpers.schema_config_entry_flow import (
SchemaOptionsFlowHandler, SchemaOptionsFlowHandler,
) )
from . import OasisMiniConfigEntry
from .const import DOMAIN from .const import DOMAIN
from .coordinator import OasisMiniCoordinator from .coordinator import OasisMiniCoordinator
from .helpers import create_client from .helpers import create_client
@@ -37,9 +39,7 @@ async def cloud_login(
handler: SchemaCommonFlowHandler, user_input: dict[str, Any] handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Cloud login.""" """Cloud login."""
coordinator: OasisMiniCoordinator = handler.parent_handler.hass.data[DOMAIN][ coordinator: OasisMiniCoordinator = handler.parent_handler.config_entry.runtime_data
handler.parent_handler.config_entry.entry_id
]
try: try:
await coordinator.device.async_cloud_login( await coordinator.device.async_cloud_login(
@@ -63,28 +63,32 @@ class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1 VERSION = 1
host: str | None = None
serial_number: str | None = None
@staticmethod @staticmethod
@callback @callback
def async_get_options_flow(config_entry: ConfigEntry) -> SchemaOptionsFlowHandler: def async_get_options_flow(
config_entry: OasisMiniConfigEntry,
) -> SchemaOptionsFlowHandler:
"""Get the options flow for this handler.""" """Get the options flow for this handler."""
return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW)
# async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> ConfigFlowResult: async def async_step_dhcp(
# """Handle dhcp discovery.""" self, discovery_info: dhcp.DhcpServiceInfo
# self.host = discovery_info.ip ) -> ConfigFlowResult:
# self.name = discovery_info.hostname """Handle DHCP discovery."""
# await self.async_set_unique_id(discovery_info.macaddress) host = {CONF_HOST: discovery_info.ip}
# self._abort_if_unique_id_configured(updates={CONF_HOST: self.host}) await self.validate_client(host)
# return await self.async_step_api_key() self._abort_if_unique_id_configured(updates=host)
# This should never happen since we only listen to DHCP requests
# for configured devices.
return self.async_abort(reason="already_configured")
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle the initial step.""" """Handle the initial step."""
return await self._async_step("user", STEP_USER_DATA_SCHEMA, user_input) return await self._async_step(
"user", STEP_USER_DATA_SCHEMA, user_input, user_input
)
async def async_step_reconfigure( async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
@@ -106,26 +110,24 @@ class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN):
suggested_values: dict[str, Any] | None = None, suggested_values: dict[str, Any] | None = None,
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle step setup.""" """Handle step setup."""
if abort := self._abort_if_configured(user_input):
return abort
errors = {} errors = {}
if user_input is not None: if user_input is not None:
if not (errors := await self.validate_client(user_input)): if not (errors := await self.validate_client(user_input)):
data = {CONF_HOST: user_input.get(CONF_HOST, self.host)} if step_id != "reconfigure":
self._abort_if_unique_id_configured(updates=user_input)
if existing_entry := self.hass.config_entries.async_get_entry( if existing_entry := self.hass.config_entries.async_get_entry(
self.context.get("entry_id") self.context.get("entry_id")
): ):
self.hass.config_entries.async_update_entry( self.hass.config_entries.async_update_entry(
existing_entry, data=data existing_entry, data=user_input
) )
await self.hass.config_entries.async_reload(existing_entry.entry_id) await self.hass.config_entries.async_reload(existing_entry.entry_id)
return self.async_abort(reason="reconfigure_successful") return self.async_abort(reason="reconfigure_successful")
return self.async_create_entry( return self.async_create_entry(
title=f"Oasis Mini {self.serial_number}", title=f"Oasis Mini {self.unique_id}",
data=data, data=user_input,
) )
return self.async_show_form( return self.async_show_form(
@@ -139,9 +141,9 @@ class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN):
errors = {} errors = {}
try: try:
async with asyncio.timeout(10): async with asyncio.timeout(10):
client = create_client({"host": self.host} | user_input) client = create_client(user_input)
self.serial_number = await client.async_get_serial_number() await self.async_set_unique_id(await client.async_get_serial_number())
if not self.serial_number: if not self.unique_id:
errors["base"] = "invalid_host" errors["base"] = "invalid_host"
except asyncio.TimeoutError: except asyncio.TimeoutError:
errors["base"] = "timeout_connect" errors["base"] = "timeout_connect"
@@ -157,15 +159,3 @@ class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN):
finally: finally:
await client.session.close() await client.session.close()
return errors return errors
@callback
def _abort_if_configured(
self, user_input: dict[str, Any] | None
) -> ConfigFlowResult | 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

@@ -8,7 +8,6 @@ import logging
import async_timeout import async_timeout
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN from .const import DOMAIN
@@ -20,28 +19,43 @@ _LOGGER = logging.getLogger(__name__)
class OasisMiniCoordinator(DataUpdateCoordinator[str]): class OasisMiniCoordinator(DataUpdateCoordinator[str]):
"""Oasis Mini data update coordinator.""" """Oasis Mini data update coordinator."""
attempt: int = 0
last_updated: datetime | None = None last_updated: datetime | None = None
def __init__(self, hass: HomeAssistant, device: OasisMini) -> None: def __init__(self, hass: HomeAssistant, device: OasisMini) -> None:
"""Initialize.""" """Initialize."""
super().__init__( super().__init__(
hass, _LOGGER, name=DOMAIN, update_interval=timedelta(seconds=10) hass,
_LOGGER,
name=DOMAIN,
update_interval=timedelta(seconds=10),
always_update=False,
) )
self.device = device self.device = device
async def _async_update_data(self): async def _async_update_data(self):
"""Update the data."""
data: str | None = None
self.attempt += 1
try: try:
async with async_timeout.timeout(10): async with async_timeout.timeout(10):
if not self.device.mac_address:
await self.device.async_get_mac_address()
if not self.device.serial_number: if not self.device.serial_number:
await self.device.async_get_serial_number() await self.device.async_get_serial_number()
if not self.device.software_version: if not self.device.software_version:
await self.device.async_get_software_version() await self.device.async_get_software_version()
data = await self.device.async_get_status() data = await self.device.async_get_status()
self.attempt = 0
await self.device.async_get_current_track_details() await self.device.async_get_current_track_details()
except Exception as ex: await self.device.async_get_playlist_details()
raise UpdateFailed("Couldn't read from the Oasis Mini") from ex except Exception as ex: # pylint:disable=broad-except
if data is None: if self.attempt > 2 or not (data or self.data):
raise ConfigEntryAuthFailed raise UpdateFailed(
f"Couldn't read from the Oasis Mini after {self.attempt} attempts"
) from ex
if data != self.data: if data != self.data:
self.last_updated = datetime.now() self.last_updated = datetime.now()
return data return data

View File

@@ -2,9 +2,7 @@
from __future__ import annotations from __future__ import annotations
import logging from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.entity import DeviceInfo, EntityDescription from homeassistant.helpers.entity import DeviceInfo, EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -12,8 +10,6 @@ from .const import DOMAIN
from .coordinator import OasisMiniCoordinator from .coordinator import OasisMiniCoordinator
from .pyoasismini import OasisMini from .pyoasismini import OasisMini
_LOGGER = logging.getLogger(__name__)
class OasisMiniEntity(CoordinatorEntity[OasisMiniCoordinator]): class OasisMiniEntity(CoordinatorEntity[OasisMiniCoordinator]):
"""Base class for Oasis Mini entities.""" """Base class for Oasis Mini entities."""
@@ -21,24 +17,23 @@ class OasisMiniEntity(CoordinatorEntity[OasisMiniCoordinator]):
_attr_has_entity_name = True _attr_has_entity_name = True
def __init__( def __init__(
self, self, coordinator: OasisMiniCoordinator, description: EntityDescription
coordinator: OasisMiniCoordinator,
entry: ConfigEntry,
description: EntityDescription,
) -> None: ) -> None:
"""Construct an Oasis Mini entity.""" """Construct an Oasis Mini entity."""
super().__init__(coordinator) super().__init__(coordinator)
self.entity_description = description self.entity_description = description
serial_number = coordinator.device.serial_number device = coordinator.device
serial_number = device.serial_number
self._attr_unique_id = f"{serial_number}-{description.key}" self._attr_unique_id = f"{serial_number}-{description.key}"
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, format_mac(device.mac_address))},
identifiers={(DOMAIN, serial_number)}, identifiers={(DOMAIN, serial_number)},
name=entry.title, name=f"Oasis Mini {serial_number}",
manufacturer="Kinetic Oasis", manufacturer="Kinetic Oasis",
model="Oasis Mini", model="Oasis Mini",
serial_number=serial_number, serial_number=serial_number,
sw_version=coordinator.device.software_version, sw_version=device.software_version,
) )
@property @property

View File

@@ -12,3 +12,18 @@ from .pyoasismini import OasisMini
def create_client(data: dict[str, Any]) -> OasisMini: def create_client(data: dict[str, Any]) -> OasisMini:
"""Create a Oasis Mini local client.""" """Create a Oasis Mini local client."""
return OasisMini(data[CONF_HOST], data.get(CONF_ACCESS_TOKEN)) return OasisMini(data[CONF_HOST], data.get(CONF_ACCESS_TOKEN))
async def add_and_play_track(device: OasisMini, track: int) -> None:
"""Add and play a track."""
if track not in device.playlist:
await device.async_add_track_to_playlist(track)
# Move track to next item in the playlist and then select it
if (index := device.playlist.index(track)) != device.playlist_index:
if index != (_next := min(device.playlist_index + 1, len(device.playlist) - 1)):
await device.async_move_track(index, _next)
await device.async_change_track(_next)
if device.status_code != 4:
await device.async_play()

View File

@@ -2,16 +2,15 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from homeassistant.components.image import Image, ImageEntity, ImageEntityDescription
from homeassistant.core import HomeAssistant, callback
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 homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import UNDEFINED
from .const import DOMAIN from . import OasisMiniConfigEntry
from .coordinator import OasisMiniCoordinator from .coordinator import OasisMiniCoordinator
from .entity import OasisMiniEntity from .entity import OasisMiniEntity
from .pyoasismini.const import TRACKS
from .pyoasismini.utils import draw_svg from .pyoasismini.utils import draw_svg
IMAGE = ImageEntityDescription(key="image", name=None) IMAGE = ImageEntityDescription(key="image", name=None)
@@ -21,35 +20,55 @@ class OasisMiniImageEntity(OasisMiniEntity, ImageEntity):
"""Oasis Mini image entity.""" """Oasis Mini image entity."""
_attr_content_type = "image/svg+xml" _attr_content_type = "image/svg+xml"
_track_id: int | None = None
_progress: int = 0
def __init__( def __init__(
self, self, coordinator: OasisMiniCoordinator, description: ImageEntityDescription
coordinator: OasisMiniCoordinator,
entry_id: str,
description: ImageEntityDescription,
) -> None: ) -> None:
"""Initialize the entity.""" """Initialize the entity."""
super().__init__(coordinator, entry_id, description) super().__init__(coordinator, description)
ImageEntity.__init__(self, coordinator.hass) ImageEntity.__init__(self, coordinator.hass)
self._handle_coordinator_update()
@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: def image(self) -> bytes | None:
"""Return bytes of image.""" """Return bytes of image."""
return draw_svg( if not self._cached_image:
self.device._current_track_details, self._cached_image = Image(
self.device.progress, self.content_type, draw_svg(self.device.track, self._progress, "1")
"1",
) )
return self._cached_image.content
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
if self._track_id != self.device.track_id or (
self._progress != self.device.progress and self.device.access_token
):
self._attr_image_last_updated = self.coordinator.last_updated
self._track_id = self.device.track_id
self._progress = self.device.progress
self._cached_image = None
if self.device.track and self.device.track.get("svg_content"):
self._attr_image_url = UNDEFINED
else:
self._attr_image_url = (
f"https://app.grounded.so/uploads/{track['image']}"
if (
track := (self.device.track or TRACKS.get(self.device.track_id))
)
and "image" in track
else None
)
if self.hass:
super()._handle_coordinator_update()
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant,
entry: OasisMiniConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Oasis Mini camera using config entry.""" """Set up Oasis Mini camera using config entry."""
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities([OasisMiniImageEntity(entry.runtime_data, IMAGE)])
if coordinator.device.access_token:
async_add_entities([OasisMiniImageEntity(coordinator, entry, IMAGE)])

View File

@@ -14,7 +14,6 @@ from homeassistant.components.light import (
LightEntityDescription, LightEntityDescription,
LightEntityFeature, LightEntityFeature,
) )
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util.color import ( from homeassistant.util.color import (
@@ -24,8 +23,7 @@ from homeassistant.util.color import (
value_to_brightness, value_to_brightness,
) )
from .const import DOMAIN from . import OasisMiniConfigEntry
from .coordinator import OasisMiniCoordinator
from .entity import OasisMiniEntity from .entity import OasisMiniEntity
from .pyoasismini import LED_EFFECTS from .pyoasismini import LED_EFFECTS
@@ -70,8 +68,10 @@ class OasisMiniLightEntity(OasisMiniEntity, LightEntity):
return self.device.brightness > 0 return self.device.brightness > 0
@property @property
def rgb_color(self) -> tuple[int, int, int]: def rgb_color(self) -> tuple[int, int, int] | None:
"""Return the rgb color value [int, int, int].""" """Return the rgb color value [int, int, int]."""
if not self.device.color:
return None
return rgb_hex_to_rgb_list(self.device.color.replace("#", "")) return rgb_hex_to_rgb_list(self.device.color.replace("#", ""))
@property @property
@@ -110,8 +110,9 @@ DESCRIPTOR = LightEntityDescription(key="led", name="LED")
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant,
entry: OasisMiniConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Oasis Mini lights using config entry.""" """Set up Oasis Mini lights using config entry."""
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities([OasisMiniLightEntity(entry.runtime_data, DESCRIPTOR)])
async_add_entities([OasisMiniLightEntity(coordinator, entry, DESCRIPTOR)])

View File

@@ -3,6 +3,7 @@
"name": "Oasis Mini", "name": "Oasis Mini",
"codeowners": ["@natekspencer"], "codeowners": ["@natekspencer"],
"config_flow": true, "config_flow": true,
"dhcp": [{ "registered_devices": true }],
"documentation": "https://github.com/natekspencer/hacs-oasis_mini", "documentation": "https://github.com/natekspencer/hacs-oasis_mini",
"integration_type": "device", "integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",

View File

@@ -3,9 +3,10 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
import math from typing import Any
from homeassistant.components.media_player import ( from homeassistant.components.media_player import (
MediaPlayerEnqueue,
MediaPlayerEntity, MediaPlayerEntity,
MediaPlayerEntityDescription, MediaPlayerEntityDescription,
MediaPlayerEntityFeature, MediaPlayerEntityFeature,
@@ -13,26 +14,28 @@ from homeassistant.components.media_player import (
MediaType, MediaType,
RepeatMode, RepeatMode,
) )
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN from . import OasisMiniConfigEntry
from .coordinator import OasisMiniCoordinator
from .entity import OasisMiniEntity from .entity import OasisMiniEntity
from .pyoasismini.const import TRACKS from .pyoasismini.const import TRACKS
BRIGHTNESS_SCALE = (1, 200)
class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity): class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
"""Oasis Mini media player entity.""" """Oasis Mini media player entity."""
_attr_media_image_remotely_accessible = True _attr_media_image_remotely_accessible = True
_attr_supported_features = ( _attr_supported_features = (
MediaPlayerEntityFeature.NEXT_TRACK MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.PLAY_MEDIA
| MediaPlayerEntityFeature.MEDIA_ENQUEUE
| MediaPlayerEntityFeature.CLEAR_PLAYLIST
| MediaPlayerEntityFeature.REPEAT_SET | MediaPlayerEntityFeature.REPEAT_SET
) )
@@ -42,19 +45,17 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
return MediaType.IMAGE return MediaType.IMAGE
@property @property
def media_duration(self) -> int: def media_duration(self) -> int | None:
"""Duration of current playing media in seconds.""" """Duration of current playing media in seconds."""
if ( if (track := self.device.track) and "reduced_svg_content" in track:
track := self.device._current_track_details
) and "reduced_svg_content" in track:
return track["reduced_svg_content"].get("1") return track["reduced_svg_content"].get("1")
return math.ceil(self.media_position / 0.99) return None
@property @property
def media_image_url(self) -> str | None: def media_image_url(self) -> str | None:
"""Image url of current playing media.""" """Image url of current playing media."""
if not (track := self.device._current_track_details): if not (track := self.device.track):
track = TRACKS.get(str(self.device.current_track_id)) track = TRACKS.get(self.device.track_id)
if track and "image" in track: if track and "image" in track:
return f"https://app.grounded.so/uploads/{track['image']}" return f"https://app.grounded.so/uploads/{track['image']}"
return None return None
@@ -70,30 +71,36 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
return self.coordinator.last_updated return self.coordinator.last_updated
@property @property
def media_title(self) -> str: def media_title(self) -> str | None:
"""Title of current playing media.""" """Title of current playing media."""
if not (track := self.device._current_track_details): if not self.device.track_id:
track = TRACKS.get(str(self.device.current_track_id), {}) return None
return track.get("name", f"Unknown Title (#{self.device.current_track_id})") if not (track := self.device.track):
track = TRACKS.get(self.device.track_id, {})
return track.get("name", f"Unknown Title (#{self.device.track_id})")
@property @property
def repeat(self) -> RepeatMode: def repeat(self) -> RepeatMode:
"""Return current repeat mode.""" """Return current repeat mode."""
if self.device.repeat_playlist: return RepeatMode.ALL if self.device.repeat_playlist else RepeatMode.OFF
return RepeatMode.ALL
return RepeatMode.OFF
@property @property
def state(self) -> MediaPlayerState: def state(self) -> MediaPlayerState:
"""State of the player.""" """State of the player."""
status_code = self.device.status_code status_code = self.device.status_code
if self.device.error or status_code in (9, 11):
return MediaPlayerState.OFF
if status_code == 2:
return MediaPlayerState.IDLE
if status_code in (3, 13): if status_code in (3, 13):
return MediaPlayerState.BUFFERING return MediaPlayerState.BUFFERING
if status_code in (2, 5):
return MediaPlayerState.PAUSED
if status_code == 4: if status_code == 4:
return MediaPlayerState.PLAYING return MediaPlayerState.PLAYING
return MediaPlayerState.STANDBY if status_code == 5:
return MediaPlayerState.PAUSED
if status_code == 15:
return MediaPlayerState.ON
return MediaPlayerState.IDLE
async def async_media_pause(self) -> None: async def async_media_pause(self) -> None:
"""Send pause command.""" """Send pause command."""
@@ -105,6 +112,11 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
await self.device.async_play() await self.device.async_play()
await self.coordinator.async_request_refresh() await self.coordinator.async_request_refresh()
async def async_media_stop(self) -> None:
"""Send stop command."""
await self.device.async_stop()
await self.coordinator.async_request_refresh()
async def async_set_repeat(self, repeat: RepeatMode) -> None: async def async_set_repeat(self, repeat: RepeatMode) -> None:
"""Set repeat mode.""" """Set repeat mode."""
await self.device.async_set_repeat_playlist( await self.device.async_set_repeat_playlist(
@@ -113,6 +125,13 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
) )
await self.coordinator.async_request_refresh() await self.coordinator.async_request_refresh()
async def async_media_previous_track(self) -> None:
"""Send previous track command."""
if (index := self.device.playlist_index - 1) < 0:
index = len(self.device.playlist) - 1
await self.device.async_change_track(index)
await self.coordinator.async_request_refresh()
async def async_media_next_track(self) -> None: async def async_media_next_track(self) -> None:
"""Send next track command.""" """Send next track command."""
if (index := self.device.playlist_index + 1) >= len(self.device.playlist): if (index := self.device.playlist_index + 1) >= len(self.device.playlist):
@@ -120,13 +139,66 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
await self.device.async_change_track(index) await self.device.async_change_track(index)
await self.coordinator.async_request_refresh() await self.coordinator.async_request_refresh()
async def async_play_media(
self,
media_type: MediaType | str,
media_id: str,
enqueue: MediaPlayerEnqueue | None = None,
**kwargs: Any,
) -> None:
"""Play a piece of media."""
if media_id not in map(str, TRACKS):
media_id = next(
(
id
for id, info in TRACKS.items()
if info["name"].lower() == media_id.lower()
),
media_id,
)
try:
track = int(media_id)
except ValueError as err:
raise ServiceValidationError(f"Invalid media: {media_id}") from err
device = self.device
enqueue = MediaPlayerEnqueue.NEXT if not enqueue else enqueue
if enqueue == MediaPlayerEnqueue.REPLACE:
await device.async_set_playlist([track])
else:
await device.async_add_track_to_playlist(track)
if enqueue in (MediaPlayerEnqueue.NEXT, MediaPlayerEnqueue.PLAY):
# Move track to next item in the playlist
if (index := (len(device.playlist) - 1)) != device.playlist_index:
if index != (
_next := min(device.playlist_index + 1, len(device.playlist) - 1)
):
await device.async_move_track(index, _next)
if enqueue == MediaPlayerEnqueue.PLAY:
await device.async_change_track(_next)
if (
enqueue in (MediaPlayerEnqueue.PLAY, MediaPlayerEnqueue.REPLACE)
and device.status_code != 4
):
await device.async_play()
await self.coordinator.async_request_refresh()
async def async_clear_playlist(self) -> None:
"""Clear players playlist."""
await self.device.async_clear_playlist()
await self.coordinator.async_request_refresh()
DESCRIPTOR = MediaPlayerEntityDescription(key="oasis_mini", name=None) DESCRIPTOR = MediaPlayerEntityDescription(key="oasis_mini", name=None)
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant,
entry: OasisMiniConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Oasis Mini media_players using config entry.""" """Set up Oasis Mini media_players using config entry."""
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities([OasisMiniMediaPlayerEntity(entry.runtime_data, DESCRIPTOR)])
async_add_entities([OasisMiniMediaPlayerEntity(coordinator, entry, DESCRIPTOR)])

View File

@@ -2,14 +2,17 @@
from __future__ import annotations from __future__ import annotations
from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.components.number import (
from homeassistant.config_entries import ConfigEntry NumberEntity,
NumberEntityDescription,
NumberMode,
)
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN from . import OasisMiniConfigEntry
from .coordinator import OasisMiniCoordinator
from .entity import OasisMiniEntity from .entity import OasisMiniEntity
from .pyoasismini import BALL_SPEED_MAX, BALL_SPEED_MIN, LED_SPEED_MAX, LED_SPEED_MIN
class OasisMiniNumberEntity(OasisMiniEntity, NumberEntity): class OasisMiniNumberEntity(OasisMiniEntity, NumberEntity):
@@ -33,26 +36,29 @@ DESCRIPTORS = {
NumberEntityDescription( NumberEntityDescription(
key="ball_speed", key="ball_speed",
name="Ball speed", name="Ball speed",
native_max_value=800, mode=NumberMode.SLIDER,
native_min_value=200, native_max_value=BALL_SPEED_MAX,
native_min_value=BALL_SPEED_MIN,
), ),
NumberEntityDescription( NumberEntityDescription(
key="led_speed", key="led_speed",
name="LED speed", name="LED speed",
native_max_value=90, mode=NumberMode.SLIDER,
native_min_value=-90, native_max_value=LED_SPEED_MAX,
native_min_value=LED_SPEED_MIN,
), ),
} }
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant,
entry: OasisMiniConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Oasis Mini numbers using config entry.""" """Set up Oasis Mini numbers using config entry."""
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities( async_add_entities(
[ [
OasisMiniNumberEntity(coordinator, entry, descriptor) OasisMiniNumberEntity(entry.runtime_data, descriptor)
for descriptor in DESCRIPTORS for descriptor in DESCRIPTORS
] ]
) )

View File

@@ -2,43 +2,37 @@
import asyncio import asyncio
import logging import logging
from typing import Any, Awaitable, Callable, Final from typing import Any, Awaitable, Final
from urllib.parse import urljoin from urllib.parse import urljoin
from aiohttp import ClientSession from aiohttp import ClientResponseError, ClientSession
import async_timeout
from .const import TRACKS
from .utils import _bit_to_bool from .utils import _bit_to_bool
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
STATUS_CODE_MAP = { STATUS_CODE_MAP = {
0: "booting", # maybe?
2: "stopped", 2: "stopped",
3: "centering", 3: "centering",
4: "running", 4: "playing",
5: "paused", 5: "paused",
9: "error", 9: "error",
11: "updating",
13: "downloading", 13: "downloading",
15: "live",
}
AUTOPLAY_MAP = {
"0": "on",
"1": "off",
"2": "5 minutes",
"3": "10 minutes",
"4": "30 minutes",
} }
ATTRIBUTES: Final[list[tuple[str, Callable[[str], Any]]]] = [
("status_code", int), # see status code map
("error", str), # error, 0 = none, and 10 = ?, 18 = can't download?
("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]] = { LED_EFFECTS: Final[dict[str, str]] = {
"0": "Solid", "0": "Solid",
@@ -59,25 +53,37 @@ LED_EFFECTS: Final[dict[str, str]] = {
} }
CLOUD_BASE_URL = "https://app.grounded.so" CLOUD_BASE_URL = "https://app.grounded.so"
CLOUD_API_URL = f"{CLOUD_BASE_URL}/api"
BALL_SPEED_MAX: Final = 1000
BALL_SPEED_MIN: Final = 200
LED_SPEED_MAX: Final = 90
LED_SPEED_MIN: Final = -90
class OasisMini: class OasisMini:
"""Oasis Mini API client class.""" """Oasis Mini API client class."""
_access_token: str | None = None _access_token: str | None = None
_current_track_details: dict | None = None _mac_address: str | None = None
_ip_address: str | None = None
_playlist: dict[int, dict[str, str]] = {}
_serial_number: str | None = None _serial_number: str | None = None
_software_version: str | None = None _software_version: str | None = None
_track: dict | None = None
autoplay: str
brightness: int brightness: int
color: str busy: bool
color: str | None = None
download_progress: int
error: int
led_effect: str led_effect: str
led_speed: int led_speed: int
max_brightness: int max_brightness: int
playlist: list[int] playlist: list[int]
playlist_index: int playlist_index: int
progress: int progress: int
repeat_playlist: bool
status_code: int status_code: int
def __init__( def __init__(
@@ -97,10 +103,19 @@ class OasisMini:
return self._access_token return self._access_token
@property @property
def current_track_id(self) -> int: def mac_address(self) -> str | None:
"""Return the current track.""" """Return the mac address."""
i = self.playlist_index return self._mac_address
return self.playlist[0] if i >= len(self.playlist) else self.playlist[i]
@property
def drawing_progress(self) -> float | None:
"""Return the drawing progress percent."""
if not (self.track and (svg_content := self.track.get("svg_content"))):
return None
paths = svg_content.split("L")
total = self.track.get("reduced_svg_content", {}).get("1", len(paths))
percent = (100 * self.progress) / total
return percent
@property @property
def serial_number(self) -> str | None: def serial_number(self) -> str | None:
@@ -122,6 +137,21 @@ class OasisMini:
"""Return the status.""" """Return the status."""
return STATUS_CODE_MAP.get(self.status_code, f"Unknown ({self.status_code})") return STATUS_CODE_MAP.get(self.status_code, f"Unknown ({self.status_code})")
@property
def track(self) -> dict | None:
"""Return the current track info."""
if self._track and self._track.get("id") == self.track_id:
return self._track
return None
@property
def track_id(self) -> int | None:
"""Return the current track id."""
if not self.playlist:
return None
i = self.playlist_index
return self.playlist[0] if i >= len(self.playlist) else self.playlist[i]
@property @property
def url(self) -> str: def url(self) -> str:
"""Return the url.""" """Return the url."""
@@ -129,15 +159,38 @@ class OasisMini:
async def async_add_track_to_playlist(self, track: int) -> None: async def async_add_track_to_playlist(self, track: int) -> None:
"""Add track to playlist.""" """Add track to playlist."""
if not track:
return
if 0 in self.playlist:
playlist = [t for t in self.playlist if t] + [track]
return await self.async_set_playlist(playlist)
await self._async_command(params={"ADDJOBLIST": track}) await self._async_command(params={"ADDJOBLIST": track})
self.playlist.append(track) self.playlist.append(track)
async def async_change_track(self, index: int) -> None: async def async_change_track(self, index: int) -> None:
"""Change the track.""" """Change the track."""
if index >= len(self.playlist): if index >= len(self.playlist):
raise ValueError("Invalid selection") raise ValueError("Invalid index specified")
await self._async_command(params={"CMDCHANGETRACK": index}) await self._async_command(params={"CMDCHANGETRACK": index})
async def async_clear_playlist(self) -> None:
"""Clear the playlist."""
await self.async_set_playlist([])
async def async_get_ip_address(self) -> str | None:
"""Get the ip address."""
self._ip_address = await self._async_get(params={"GETIP": ""})
_LOGGER.debug("IP address: %s", self._ip_address)
return self._ip_address
async def async_get_mac_address(self) -> str | None:
"""Get the mac address."""
self._mac_address = await self._async_get(params={"GETMAC": ""})
_LOGGER.debug("MAC address: %s", self._mac_address)
return self._mac_address
async def async_get_serial_number(self) -> str | None: async def async_get_serial_number(self) -> str | None:
"""Get the serial number.""" """Get the serial number."""
self._serial_number = await self._async_get(params={"GETOASISID": ""}) self._serial_number = await self._async_get(params={"GETOASISID": ""})
@@ -150,16 +203,41 @@ class OasisMini:
_LOGGER.debug("Software version: %s", self._software_version) _LOGGER.debug("Software version: %s", self._software_version)
return self._software_version return self._software_version
async def async_get_status(self) -> None: async def async_get_status(self) -> str:
"""Get the status from the device.""" """Get the status from the device."""
status = await self._async_get(params={"GETSTATUS": ""}) raw_status = await self._async_get(params={"GETSTATUS": ""})
_LOGGER.debug("Status: %s", status) _LOGGER.debug("Status: %s", raw_status)
for index, value in enumerate(status.split(";")): values = raw_status.split(";")
attr, func = ATTRIBUTES[index] playlist = [int(track) for track in values[3].split(",") if track]
if (old_value := getattr(self, attr, None)) != (value := func(value)): status = {
_LOGGER.debug("%s changed: '%s' -> '%s'", attr, old_value, value) "status_code": int(values[0]), # see status code map
setattr(self, attr, value) "error": int(values[1]), # noqa: E501; error, 0 = none, and 10 = ?, 18 = can't download?
return status "ball_speed": int(values[2]), # 200 - 1000
"playlist": playlist,
"playlist_index": min(int(values[4]), len(playlist)), # index of above
"progress": int(values[5]), # 0 - max svg path
"led_effect": values[6], # led effect (code lookup)
"led_color_id": values[7], # led color id?
"led_speed": int(values[8]), # -90 - 90
"brightness": int(values[9]) if values[10] else 0, # noqa: E501; 0 - 200 in app, but seems to be 0 (off) to 304 (max), then repeats
"color": values[10] or None, # hex color code
"busy": _bit_to_bool(values[11]), # noqa: E501; device is busy (downloading track, centering, software update)?
"download_progress": int(values[12]),
"max_brightness": int(values[13]),
"wifi_connected": _bit_to_bool(values[14]),
"repeat_playlist": _bit_to_bool(values[15]),
"autoplay": AUTOPLAY_MAP.get(values[16]),
}
for key, value in status.items():
if (old_value := getattr(self, key, None)) != value:
_LOGGER.debug(
"%s changed: '%s' -> '%s'",
key.replace("_", " ").capitalize(),
old_value,
value,
)
setattr(self, key, value)
return raw_status
async def async_move_track(self, _from: int, _to: int) -> None: async def async_move_track(self, _from: int, _to: int) -> None:
"""Move a track in the playlist.""" """Move a track in the playlist."""
@@ -171,6 +249,9 @@ class OasisMini:
async def async_play(self) -> None: async def async_play(self) -> None:
"""Send play command.""" """Send play command."""
if self.status_code == 15:
await self.async_stop()
if self.track_id:
await self._async_command(params={"CMDPLAY": ""}) await self._async_command(params={"CMDPLAY": ""})
async def async_reboot(self) -> None: async def async_reboot(self) -> None:
@@ -187,8 +268,8 @@ class OasisMini:
async def async_set_ball_speed(self, speed: int) -> None: async def async_set_ball_speed(self, speed: int) -> None:
"""Set the Oasis Mini ball speed.""" """Set the Oasis Mini ball speed."""
if not 200 <= speed <= 800: if not BALL_SPEED_MIN <= speed <= BALL_SPEED_MAX:
raise Exception("Invalid speed specified") raise ValueError("Invalid speed specified")
await self._async_command(params={"WRIOASISSPEED": speed}) await self._async_command(params={"WRIOASISSPEED": speed})
@@ -204,43 +285,51 @@ class OasisMini:
if led_effect is None: if led_effect is None:
led_effect = self.led_effect led_effect = self.led_effect
if color is None: if color is None:
color = self.color color = self.color or "#ffffff"
if led_speed is None: if led_speed is None:
led_speed = self.led_speed led_speed = self.led_speed
if brightness is None: if brightness is None:
brightness = self.brightness brightness = self.brightness
if led_effect not in LED_EFFECTS: if led_effect not in LED_EFFECTS:
raise Exception("Invalid led effect specified") raise ValueError("Invalid led effect specified")
if not -90 <= led_speed <= 90: if not LED_SPEED_MIN <= led_speed <= LED_SPEED_MAX:
raise Exception("Invalid led speed specified") raise ValueError("Invalid led speed specified")
if not 0 <= brightness <= 200: if not 0 <= brightness <= self.max_brightness:
raise Exception("Invalid brightness specified") raise ValueError("Invalid brightness specified")
await self._async_command( await self._async_command(
params={"WRILED": f"{led_effect};0;{color};{led_speed};{brightness}"} params={"WRILED": f"{led_effect};0;{color};{led_speed};{brightness}"}
) )
async def async_set_pause_between_tracks(self, pause: bool) -> None: async def async_set_autoplay(self, option: bool | int | str) -> None:
"""Set the Oasis Mini pause between tracks.""" """Set autoplay."""
await self._async_command(params={"WRIWAITAFTER": 1 if pause else 0}) if isinstance(option, bool):
option = 0 if option else 1
if str(option) not in AUTOPLAY_MAP:
raise ValueError("Invalid pause option specified")
await self._async_command(params={"WRIWAITAFTER": option})
async def async_set_playlist(self, playlist: list[int]) -> None:
"""Set the playlist."""
if is_playing := (self.status_code == 4):
await self.async_stop()
await self._async_command(params={"WRIJOBLIST": ",".join(map(str, playlist))})
self.playlist = playlist
if is_playing:
await self.async_play()
async def async_set_repeat_playlist(self, repeat: bool) -> None: async def async_set_repeat_playlist(self, repeat: bool) -> None:
"""Set the Oasis Mini repeat playlist.""" """Set repeat playlist."""
await self._async_command(params={"WRIREPEATJOB": 1 if repeat else 0}) await self._async_command(params={"WRIREPEATJOB": 1 if repeat else 0})
async def _async_command(self, **kwargs: Any) -> str | None: async def async_stop(self) -> None:
"""Send a command request.""" """Send stop command."""
result = await self._async_get(**kwargs) await self._async_command(params={"CMDSTOP": ""})
_LOGGER.debug("Result: %s", result)
async def _async_get(self, **kwargs: Any) -> str | None: async def async_upgrade(self, beta: bool = False) -> None:
"""Perform a GET request.""" """Trigger a software upgrade."""
response = await self._session.get(self.url, **kwargs) await self._async_command(params={"CMDUPGRADE": 1 if beta else 0})
if response.status == 200 and response.content_type == "text/plain":
text = await response.text()
return text
return None
async def async_cloud_login(self, email: str, password: str) -> None: async def async_cloud_login(self, email: str, password: str) -> None:
"""Login via the cloud.""" """Login via the cloud."""
@@ -253,59 +342,106 @@ class OasisMini:
async def async_cloud_logout(self) -> None: async def async_cloud_logout(self) -> None:
"""Login via the cloud.""" """Login via the cloud."""
if not self.access_token: await self._async_cloud_request("GET", "api/auth/logout")
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: async def async_cloud_get_track_info(self, track_id: int) -> dict[str, Any] | None:
"""Get cloud track info.""" """Get cloud track info."""
if not self.access_token: try:
return return await self._async_cloud_request("GET", f"api/track/{track_id}")
except ClientResponseError as err:
if err.status == 404:
return {"id": track_id, "name": f"Unknown Title (#{track_id})"}
except Exception as ex:
_LOGGER.exception(ex)
return None
response = await self._async_request( async def async_cloud_get_tracks(
"GET", self, tracks: list[int] | None = None
urljoin(CLOUD_BASE_URL, f"api/track/{track_id}"), ) -> list[dict[str, Any]]:
headers={"Authorization": f"Bearer {self.access_token}"}, """Get tracks info from the cloud"""
response = await self._async_cloud_request(
"GET", "api/track", params={"ids[]": tracks or []}
) )
return response if not response:
return None
async def async_cloud_get_tracks(self, tracks: list[int]) -> None: track_details = response.get("data", [])
"""Get cloud tracks.""" while next_page_url := response.get("next_page_url"):
if not self.access_token: response = await self._async_cloud_request("GET", next_page_url)
return track_details += response.get("data", [])
response = await self._async_request(
"GET",
urljoin(CLOUD_BASE_URL, "api/track"),
headers={"Authorization": f"Bearer {self.access_token}"},
params={"ids[]": tracks},
)
return response
async def _async_request(self, method: str, url: str, **kwargs) -> Any:
"""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 return track_details
self._current_track_details = await self.async_cloud_get_track_info( async def async_cloud_get_latest_software_details(self) -> dict[str, int | str]:
self.current_track_id """Get the latest software details from the cloud."""
return await self._async_cloud_request("GET", "api/software/last-version")
async def async_get_current_track_details(self) -> dict | None:
"""Get current track info, refreshing if needed."""
track_id = self.track_id
if (track := self._track) and track.get("id") == track_id:
return track
if track_id:
self._track = await self.async_cloud_get_track_info(track_id)
if not self._track:
self._track = TRACKS.get(
track_id, {"id": track_id, "name": f"Unknown Title (#{track_id})"}
)
return self._track
async def async_get_playlist_details(self) -> dict[int, dict[str, str]]:
"""Get playlist info."""
if set(self.playlist).difference(self._playlist.keys()):
tracks = await self.async_cloud_get_tracks(self.playlist)
all_tracks = TRACKS | {
track["id"]: {
"name": track["name"],
"author": ((track.get("author") or {}).get("person") or {}).get(
"name", "Oasis Mini"
),
"image": track["image"],
}
for track in tracks
}
for track in self.playlist:
self._playlist[track] = all_tracks.get(
track, {"name": f"Unknown Title (#{track})"}
)
return self._playlist
async def _async_cloud_request(self, method: str, url: str, **kwargs: Any) -> Any:
"""Perform a cloud request."""
if not self.access_token:
return
return await self._async_request(
method,
urljoin(CLOUD_BASE_URL, url),
headers={"Authorization": f"Bearer {self.access_token}"},
**kwargs,
) )
async def async_get_playlist_details(self) -> dict: async def _async_command(self, **kwargs: Any) -> str | None:
"""Get playlist info.""" """Send a command to the device."""
return await self.async_cloud_get_tracks(self.playlist) result = await self._async_get(**kwargs)
_LOGGER.debug("Result: %s", result)
async def _async_get(self, **kwargs: Any) -> str | None:
"""Perform a GET request."""
return await self._async_request("GET", self.url, **kwargs)
async def _async_request(self, method: str, url: str, **kwargs) -> Any:
"""Perform a request."""
_LOGGER.debug(
"%s %s",
method,
self._session._build_url(url).update_query( # pylint: disable=protected-access
kwargs.get("params")
),
)
response = await self._session.request(method, url, **kwargs)
if response.status == 200:
if response.content_type == "application/json":
return await response.json()
if response.content_type == "text/plain":
return await response.text()
return None
response.raise_for_status()

View File

@@ -4,8 +4,13 @@ from __future__ import annotations
import json import json
import os import os
from typing import Final from typing import Any, Final
__TRACKS_FILE = os.path.join(os.path.dirname(__file__), "tracks.json") __TRACKS_FILE = os.path.join(os.path.dirname(__file__), "tracks.json")
with open(__TRACKS_FILE, "r", encoding="utf8") as file: try:
TRACKS: Final[dict[str, dict[str, str]]] = json.load(file) with open(__TRACKS_FILE, "r", encoding="utf8") as file:
TRACKS: Final[dict[int, dict[str, Any]]] = {
int(k): v for k, v in json.load(file).items()
}
except Exception: # ignore: broad-except
TRACKS = {}

File diff suppressed because it is too large Load Diff

View File

@@ -27,8 +27,8 @@ def draw_svg(track: dict, progress: int, model_id: str) -> str | None:
if progress is not None: if progress is not None:
paths = svg_content.split("L") paths = svg_content.split("L")
total = track.get("reduced_svg_content", {}).get(model_id, len(paths)) total = track.get("reduced_svg_content", {}).get(model_id, len(paths))
percent = (100 * progress) / total percent = min((100 * progress) / total, 100)
progress = math.floor((percent / 100) * len(paths)) progress = math.floor((percent / 100) * (len(paths) - 1))
svg = Element( svg = Element(
"svg", "svg",

View File

@@ -2,56 +2,115 @@
from __future__ import annotations from __future__ import annotations
from typing import Any from dataclasses import dataclass
from typing import Any, Awaitable, Callable
from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN from . import OasisMiniConfigEntry
from .coordinator import OasisMiniCoordinator from .coordinator import OasisMiniCoordinator
from .entity import OasisMiniEntity from .entity import OasisMiniEntity
from .pyoasismini import AUTOPLAY_MAP, OasisMini
from .pyoasismini.const import TRACKS from .pyoasismini.const import TRACKS
@dataclass(frozen=True, kw_only=True)
class OasisMiniSelectEntityDescription(SelectEntityDescription):
"""Oasis Mini select entity description."""
current_value: Callable[[OasisMini], Any]
select_fn: Callable[[OasisMini, int], Awaitable[None]]
update_handler: Callable[[OasisMiniSelectEntity], None] | None = None
class OasisMiniSelectEntity(OasisMiniEntity, SelectEntity): class OasisMiniSelectEntity(OasisMiniEntity, SelectEntity):
"""Oasis Mini select entity.""" """Oasis Mini select entity."""
entity_description: OasisMiniSelectEntityDescription
_current_value: Any | None = None
def __init__( def __init__(
self, self,
coordinator: OasisMiniCoordinator, coordinator: OasisMiniCoordinator,
entry: ConfigEntry[Any],
description: EntityDescription, description: EntityDescription,
) -> None: ) -> None:
"""Construct an Oasis Mini select entity.""" """Construct an Oasis Mini select entity."""
super().__init__(coordinator, entry, description) super().__init__(coordinator, description)
self._handle_coordinator_update() self._handle_coordinator_update()
async def async_select_option(self, option: str) -> None: async def async_select_option(self, option: str) -> None:
"""Change the selected option.""" """Change the selected option."""
await self.device.async_change_track(self.options.index(option)) await self.entity_description.select_fn(self.device, self.options.index(option))
await self.coordinator.async_request_refresh() await self.coordinator.async_request_refresh()
@callback
def _handle_coordinator_update(self) -> None: def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator.""" """Handle updated data from the coordinator."""
options = [ new_value = self.entity_description.current_value(self.device)
TRACKS.get(str(track), {}).get("name", str(track)) if self._current_value == new_value:
for track in self.device.playlist return
] self._current_value = new_value
self._attr_options = options if update_handler := self.entity_description.update_handler:
self._attr_current_option = options[self.device.playlist_index] update_handler(self)
else:
self._attr_current_option = getattr(
self.device, self.entity_description.key
)
if self.hass: if self.hass:
return super()._handle_coordinator_update() return super()._handle_coordinator_update()
DESCRIPTOR = SelectEntityDescription(key="playlist", name="Playlist") def playlist_update_handler(entity: OasisMiniSelectEntity) -> None:
"""Handle playlist updates."""
# pylint: disable=protected-access
device = entity.device
options = [
device._playlist.get(track, {}).get(
"name",
TRACKS.get(track, {"id": track, "name": f"Unknown Title (#{track})"}).get(
"name",
device.track["name"]
if device.track and device.track["id"] == track
else str(track),
),
)
for track in device.playlist
]
entity._attr_options = options
index = min(device.playlist_index, len(options) - 1)
entity._attr_current_option = options[index] if options else None
DESCRIPTORS = (
OasisMiniSelectEntityDescription(
key="playlist",
name="Playlist",
current_value=lambda device: (device.playlist.copy(), device.playlist_index),
select_fn=lambda device, option: device.async_change_track(option),
update_handler=playlist_update_handler,
),
OasisMiniSelectEntityDescription(
key="autoplay",
name="Autoplay",
options=list(AUTOPLAY_MAP.values()),
current_value=lambda device: device.autoplay,
select_fn=lambda device, option: device.async_set_autoplay(option),
),
)
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant,
entry: OasisMiniConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Oasis Mini select using config entry.""" """Set up Oasis Mini select using config entry."""
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities(
async_add_entities([OasisMiniSelectEntity(coordinator, entry, DESCRIPTOR)]) [
OasisMiniSelectEntity(entry.runtime_data, descriptor)
for descriptor in DESCRIPTORS
]
)

View File

@@ -7,27 +7,33 @@ from homeassistant.components.sensor import (
SensorEntityDescription, SensorEntityDescription,
SensorStateClass, SensorStateClass,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN from . import OasisMiniConfigEntry
from .coordinator import OasisMiniCoordinator from .coordinator import OasisMiniCoordinator
from .entity import OasisMiniEntity from .entity import OasisMiniEntity
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant,
entry: OasisMiniConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Oasis Mini sensors using config entry.""" """Set up Oasis Mini sensors using config entry."""
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id] coordinator: OasisMiniCoordinator = entry.runtime_data
async_add_entities( entities = [
OasisMiniSensorEntity(coordinator, descriptor) for descriptor in DESCRIPTORS
]
if coordinator.device.access_token:
entities.extend(
[ [
OasisMiniSensorEntity(coordinator, entry, descriptor) OasisMiniSensorEntity(coordinator, descriptor)
for descriptor in DESCRIPTORS for descriptor in CLOUD_DESCRIPTORS
] ]
) )
async_add_entities(entities)
DESCRIPTORS = { DESCRIPTORS = {
@@ -36,12 +42,14 @@ DESCRIPTORS = {
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
name="Download progress", name="Download progress",
state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
), ),
} | { } | {
SensorEntityDescription( SensorEntityDescription(
key=key, key=key,
name=key.replace("_", " ").capitalize(), name=key.replace("_", " ").capitalize(),
translation_key=key,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
) )
@@ -54,6 +62,17 @@ DESCRIPTORS = {
) )
} }
CLOUD_DESCRIPTORS = (
SensorEntityDescription(
key="drawing_progress",
entity_category=EntityCategory.DIAGNOSTIC,
name="Drawing progress",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
),
)
class OasisMiniSensorEntity(OasisMiniEntity, SensorEntity): class OasisMiniSensorEntity(OasisMiniEntity, SensorEntity):
"""Oasis Mini sensor entity.""" """Oasis Mini sensor entity."""

View File

@@ -10,9 +10,6 @@
"data": { "data": {
"host": "[%key:common::config_flow::data::host%]" "host": "[%key:common::config_flow::data::host%]"
} }
},
"reauth_confirm": {
"data": {}
} }
}, },
"error": { "error": {
@@ -23,13 +20,13 @@
}, },
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
} }
}, },
"options": { "options": {
"step": { "step": {
"init": { "init": {
"description": "Add your cloud credentials to get additional information about your device",
"data": { "data": {
"email": "[%key:common::config_flow::data::email%]", "email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]" "password": "[%key:common::config_flow::data::password%]"
@@ -39,5 +36,23 @@
"error": { "error": {
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
} }
},
"entity": {
"sensor": {
"status": {
"name": "Status",
"state": {
"booting": "Booting",
"stopped": "Stopped",
"centering": "Centering",
"playing": "Playing",
"paused": "Paused",
"error": "Error",
"updating": "Updating",
"downloading": "Downloading",
"live": "Live drawing"
}
}
}
} }
} }

View File

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

View File

@@ -10,9 +10,6 @@
"data": { "data": {
"host": "Host" "host": "Host"
} }
},
"reauth_confirm": {
"data": {}
} }
}, },
"error": { "error": {
@@ -23,13 +20,13 @@
}, },
"abort": { "abort": {
"already_configured": "Device is already configured", "already_configured": "Device is already configured",
"reauth_successful": "Re-authentication was successful",
"reconfigure_successful": "Re-configuration was successful" "reconfigure_successful": "Re-configuration was successful"
} }
}, },
"options": { "options": {
"step": { "step": {
"init": { "init": {
"description": "Add your cloud credentials to get additional information about your device",
"data": { "data": {
"email": "Email", "email": "Email",
"password": "Password" "password": "Password"
@@ -39,5 +36,23 @@
"error": { "error": {
"invalid_auth": "Invalid authentication" "invalid_auth": "Invalid authentication"
} }
},
"entity": {
"sensor": {
"status": {
"name": "Status",
"state": {
"booting": "Booting",
"stopped": "Stopped",
"centering": "Centering",
"playing": "Playing",
"paused": "Paused",
"error": "Error",
"updating": "Updating",
"downloading": "Downloading",
"live": "Live drawing"
}
}
}
} }
} }

View File

@@ -0,0 +1,85 @@
"""Oasis Mini update entity."""
from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any
from homeassistant.components.update import (
UpdateDeviceClass,
UpdateEntity,
UpdateEntityDescription,
UpdateEntityFeature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import OasisMiniConfigEntry
from .coordinator import OasisMiniCoordinator
from .entity import OasisMiniEntity
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(hours=6)
async def async_setup_entry(
hass: HomeAssistant,
entry: OasisMiniConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Oasis Mini updates using config entry."""
coordinator: OasisMiniCoordinator = entry.runtime_data
if coordinator.device.access_token:
async_add_entities([OasisMiniUpdateEntity(coordinator, DESCRIPTOR)], True)
DESCRIPTOR = UpdateEntityDescription(
key="software", device_class=UpdateDeviceClass.FIRMWARE
)
class OasisMiniUpdateEntity(OasisMiniEntity, UpdateEntity):
"""Oasis Mini update entity."""
_attr_supported_features = (
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
)
@property
def in_progress(self) -> bool | int:
"""Update installation progress."""
if self.device.status_code == 11:
return self.device.download_progress
return False
@property
def installed_version(self) -> str:
"""Version installed and in use."""
return self.device.software_version
@property
def should_poll(self) -> bool:
"""Set polling to True."""
return True
async def async_install(
self, version: str | None, backup: bool, **kwargs: Any
) -> None:
"""Install an update."""
version = await self.device.async_get_software_version()
if version == self.latest_version:
return
await self.device.async_upgrade()
async def async_update(self) -> None:
"""Update the entity."""
await self.device.async_get_software_version()
software = await self.device.async_cloud_get_latest_software_details()
if not software:
_LOGGER.warning("Unable to get latest software details")
return
self._attr_latest_version = software["version"]
self._attr_release_summary = software["description"]
self._attr_release_url = f"https://app.grounded.so/software/{software['id']}"

View File

@@ -1,6 +1,5 @@
# Home Assistant # Home Assistant
homeassistant>=2024.4 homeassistant>=2024.4
home-assistant-frontend
numpy numpy
PyTurboJPEG PyTurboJPEG