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

10 Commits
0.3.1 ... 0.7.1

Author SHA1 Message Date
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
20 changed files with 692 additions and 254 deletions

View File

@@ -7,7 +7,8 @@ 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
@@ -23,7 +24,8 @@ PLATFORMS = [
Platform.NUMBER, Platform.NUMBER,
Platform.SELECT, Platform.SELECT,
Platform.SENSOR, Platform.SENSOR,
Platform.SWITCH, # Platform.SWITCH,
Platform.UPDATE,
] ]
@@ -38,10 +40,28 @@ 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
if entry.unique_id != coordinator.device.serial_number:
await client.session.close()
raise ConfigEntryError("Serial number mismatch")
hass.data[DOMAIN][entry.entry_id] = coordinator hass.data[DOMAIN][entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

View File

@@ -12,12 +12,14 @@ from homeassistant.components.button import (
ButtonEntityDescription, ButtonEntityDescription,
) )
from homeassistant.config_entries import ConfigEntry 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 .const import DOMAIN
from .coordinator import OasisMiniCoordinator 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
@@ -38,14 +40,7 @@ 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 = int(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 +54,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,6 +10,7 @@ from aiohttp import ClientConnectorError
from httpx import ConnectError, HTTPStatusError from httpx import ConnectError, HTTPStatusError
import voluptuous as vol import voluptuous as vol
from homeassistant.components import dhcp
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_HOST, CONF_PASSWORD from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_HOST, CONF_PASSWORD
from homeassistant.core import callback from homeassistant.core import callback
@@ -63,28 +64,30 @@ 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: ConfigEntry) -> 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 +109,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 +140,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 +158,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,44 @@ _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()
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 self.data:
raise ConfigEntryAuthFailed raise UpdateFailed(
f"Couldn't read from the Oasis Mini after {self.attempt} attempts"
) from ex
else:
self.attempt = 0
if data != self.data: if data != self.data:
self.last_updated = datetime.now() self.last_updated = datetime.now()
return data return data

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import logging import logging
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac
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
@@ -29,16 +30,18 @@ class OasisMiniEntity(CoordinatorEntity[OasisMiniCoordinator]):
"""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=entry.title,
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))):
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,9 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from homeassistant.components.image import Image, ImageEntity, ImageEntityDescription
from homeassistant.components.image import ImageEntity, ImageEntityDescription
from homeassistant.config_entries import ConfigEntry 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
@@ -12,6 +10,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN from .const import DOMAIN
from .coordinator import OasisMiniCoordinator from .coordinator import OasisMiniCoordinator
from .entity import OasisMiniEntity from .entity import OasisMiniEntity
from .pyoasismini.const import TRACKS
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,6 +20,8 @@ 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,
@@ -31,19 +32,34 @@ class OasisMiniImageEntity(OasisMiniEntity, ImageEntity):
"""Initialize the entity.""" """Initialize the entity."""
super().__init__(coordinator, entry_id, description) super().__init__(coordinator, entry_id, 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
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 not self.device.access_token:
self._attr_image_url = (
f"https://app.grounded.so/uploads/{track['image']}"
if (track := TRACKS.get(str(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(
@@ -51,5 +67,4 @@ async def async_setup_entry(
) -> 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] coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id]
if coordinator.device.access_token: async_add_entities([OasisMiniImageEntity(coordinator, entry, IMAGE)])
async_add_entities([OasisMiniImageEntity(coordinator, entry, IMAGE)])

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,7 +3,8 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
import math import logging
from typing import Any
from homeassistant.components.media_player import ( from homeassistant.components.media_player import (
MediaPlayerEntity, MediaPlayerEntity,
@@ -15,14 +16,16 @@ from homeassistant.components.media_player import (
) )
from homeassistant.config_entries import ConfigEntry 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 .const import DOMAIN
from .coordinator import OasisMiniCoordinator from .coordinator import OasisMiniCoordinator
from .entity import OasisMiniEntity from .entity import OasisMiniEntity
from .helpers import add_and_play_track
from .pyoasismini.const import TRACKS from .pyoasismini.const import TRACKS
BRIGHTNESS_SCALE = (1, 200) _LOGGER = logging.getLogger(__name__)
class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity): class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
@@ -30,9 +33,13 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
_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.CLEAR_PLAYLIST
| MediaPlayerEntityFeature.REPEAT_SET | MediaPlayerEntityFeature.REPEAT_SET
) )
@@ -42,19 +49,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(str(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 +75,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(str(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 +116,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 +129,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,6 +143,31 @@ 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, **kwargs: Any
) -> None:
"""Play a piece of media."""
if media_id not in TRACKS:
media_id = next(
(
id
for id, info in TRACKS.items()
if info["name"].lower() == media_id.lower()
),
media_id,
)
try:
media_id = int(media_id)
except ValueError as err:
raise ServiceValidationError(f"Invalid media: {media_id}") from err
await add_and_play_track(self.device, media_id)
async def async_clear_playlist(self) -> None:
"""Clear players playlist."""
await self.device.async_set_playlist([0])
await self.coordinator.async_request_refresh()
DESCRIPTOR = MediaPlayerEntityDescription(key="oasis_mini", name=None) DESCRIPTOR = MediaPlayerEntityDescription(key="oasis_mini", name=None)

View File

@@ -2,7 +2,11 @@
from __future__ import annotations from __future__ import annotations
from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.components.number import (
NumberEntity,
NumberEntityDescription,
NumberMode,
)
from homeassistant.config_entries import ConfigEntry 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
@@ -10,6 +14,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN from .const import DOMAIN
from .coordinator import OasisMiniCoordinator from .coordinator import OasisMiniCoordinator
from .entity import OasisMiniEntity from .entity import OasisMiniEntity
from .pyoasismini import BALL_SPEED_MAX, BALL_SPEED_MIN, LED_SPEED_MAX, LED_SPEED_MIN
class OasisMiniNumberEntity(OasisMiniEntity, NumberEntity): class OasisMiniNumberEntity(OasisMiniEntity, NumberEntity):
@@ -33,14 +38,16 @@ 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,
), ),
} }

View File

@@ -12,19 +12,30 @@ 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: "running",
5: "paused", 5: "paused",
9: "error", 9: "error",
11: "updating",
13: "downloading", 13: "downloading",
15: "live drawing",
}
AUTOPLAY_MAP = {
"0": "on",
"1": "off",
"2": "5 minutes",
"3": "10 minutes",
"4": "30 minutes",
} }
ATTRIBUTES: Final[list[tuple[str, Callable[[str], Any]]]] = [ ATTRIBUTES: Final[list[tuple[str, Callable[[str], Any]]]] = [
("status_code", int), # see status code map ("status_code", int), # see status code map
("error", str), # error, 0 = none, and 10 = ?, 18 = can't download? ("error", int), # error, 0 = none, and 10 = ?, 18 = can't download?
("ball_speed", int), # 200 - 800 ("ball_speed", int), # 200 - 1000
("playlist", lambda value: [int(track) for track in value.split(",")]), # noqa: E501 # comma separated track ids ("playlist", lambda value: [int(track) for track in value.split(",") if track]), # noqa: E501 # comma separated track ids
("playlist_index", int), # index of above ("playlist_index", int), # index of above
("progress", int), # 0 - max svg path ("progress", int), # 0 - max svg path
("led_effect", str), # led effect (code lookup) ("led_effect", str), # led effect (code lookup)
@@ -37,7 +48,7 @@ ATTRIBUTES: Final[list[tuple[str, Callable[[str], Any]]]] = [
("max_brightness", int), ("max_brightness", int),
("wifi_connected", _bit_to_bool), ("wifi_connected", _bit_to_bool),
("repeat_playlist", _bit_to_bool), ("repeat_playlist", _bit_to_bool),
("pause_between_tracks", _bit_to_bool), ("autoplay", AUTOPLAY_MAP.get),
] ]
LED_EFFECTS: Final[dict[str, str]] = { LED_EFFECTS: Final[dict[str, str]] = {
@@ -59,25 +70,36 @@ 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 color: str
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 +119,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 +153,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 +175,31 @@ 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."""
await self._async_command(params={"ADDJOBLIST": track}) if 0 in self.playlist:
self.playlist.append(track) playlist = [t for t in self.playlist if t] + [track]
await self.async_set_playlist(playlist)
else:
await self._async_command(params={"ADDJOBLIST": track})
self.playlist.append(track)
async def async_change_track(self, index: int) -> None: async def async_change_track(self, index: int) -> None:
"""Change the track.""" """Change the track."""
if index >= len(self.playlist): if index >= len(self.playlist):
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_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,7 +212,7 @@ 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": ""}) status = await self._async_get(params={"GETSTATUS": ""})
_LOGGER.debug("Status: %s", status) _LOGGER.debug("Status: %s", status)
@@ -171,6 +233,8 @@ 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()
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 +251,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})
@@ -211,36 +275,40 @@ class OasisMini:
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 playlist."""
await self._async_command(params={"WRIJOBLIST": ",".join(map(str, playlist))})
self.playlist = playlist
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 +321,85 @@ 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."""
try:
return await self._async_cloud_request("GET", f"api/track/{track_id}")
except Exception as ex:
_LOGGER.exception(ex)
return None
async def async_cloud_get_tracks(
self, tracks: list[int] | None = None
) -> list[dict[str, Any]]:
"""Get tracks info from the cloud"""
response = await self._async_cloud_request(
"GET", "api/track", params={"ids[]": tracks or []}
)
track_details = response.get("data", [])
while next_page_url := response.get("next_page_url"):
response = await self._async_cloud_request("GET", next_page_url)
track_details += response.get("data", [])
return track_details
async def async_cloud_get_latest_software_details(self) -> dict[str, int | str]:
"""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."""
if (track := self._track) and track.get("id") == self.track_id:
return track
if self.track_id:
self._track = await self.async_cloud_get_track_info(self.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)
self._playlist = {
track["id"]: {
"name": track["name"],
"author": ((track.get("author") or {}).get("person") or {}).get(
"name", "Oasis Mini"
),
"image": track["image"],
}
for track in tracks
}
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: if not self.access_token:
return return
response = await self._async_request( return await self._async_request(
"GET", method,
urljoin(CLOUD_BASE_URL, f"api/track/{track_id}"), urljoin(CLOUD_BASE_URL, url),
headers={"Authorization": f"Bearer {self.access_token}"}, headers={"Authorization": f"Bearer {self.access_token}"},
**kwargs,
) )
return response
async def async_cloud_get_tracks(self, tracks: list[int]) -> None: async def _async_command(self, **kwargs: Any) -> str | None:
"""Get cloud tracks.""" """Send a command to the device."""
if not self.access_token: result = await self._async_get(**kwargs)
return _LOGGER.debug("Result: %s", result)
response = await self._async_request( async def _async_get(self, **kwargs: Any) -> str | None:
"GET", """Perform a GET request."""
urljoin(CLOUD_BASE_URL, "api/track"), return await self._async_request("GET", self.url, **kwargs)
headers={"Authorization": f"Bearer {self.access_token}"},
params={"ids[]": tracks},
)
return response
async def _async_request(self, method: str, url: str, **kwargs) -> Any: async def _async_request(self, method: str, url: str, **kwargs) -> Any:
"""Login via the cloud.""" """Perform a request."""
response = await self._session.request(method, url, **kwargs) response = await self._session.request(method, url, **kwargs)
if response.status == 200: if response.status == 200:
if response.headers.get("Content-Type") == "application/json": if response.content_type == "application/json":
return await response.json() return await response.json()
return await response.text() if response.content_type == "text/plain":
return await response.text()
return None
response.raise_for_status() 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
)
async def async_get_playlist_details(self) -> dict:
"""Get playlist info."""
return await self.async_cloud_get_tracks(self.playlist)

View File

@@ -710,9 +710,9 @@
"image": "2024/03/3829ea91a3af828e7046f473707b0627.svg" "image": "2024/03/3829ea91a3af828e7046f473707b0627.svg"
}, },
"455": { "455": {
"name": "Teste", "name": "Princess",
"author": "Otávio Bittencourt", "author": "Otávio Bittencourt",
"image": "2024/06/ecd77e23fe859ba8e7e8c6a6ecfc9b8e.svg" "image": "2024/07/ecd77e23fe859ba8e7e8c6a6ecfc9b8e.svg"
}, },
"223": { "223": {
"name": "The Knot", "name": "The Knot",
@@ -823,5 +823,100 @@
"name": "Yorkshire", "name": "Yorkshire",
"author": "Otávio Bittencourt", "author": "Otávio Bittencourt",
"image": "2024/06/be59f584c87cfff3aa13e5887a69e183.svg" "image": "2024/06/be59f584c87cfff3aa13e5887a69e183.svg"
},
"953": {
"name": "Grizzly bear",
"author": "Otávio Bittencourt",
"image": "2024/07/a3c63d580c4e4a95cdcc457fedf7dcce.svg"
},
"670": {
"name": "Horse",
"author": "Otávio Bittencourt",
"image": "2024/07/9fec8716ce98fdbf0c02db14b47b0d66.svg"
},
"513": {
"name": "Clover Flower",
"author": "Riley P",
"image": "2024/06/b7de1c0518e5ce9cbdd8f3dd6d995e3a.svg"
},
"537": {
"name": "Full moon",
"author": "001547.d33e09ec63fb4259a31a494ad194e028.0314",
"image": "2024/07/3b06cb1bd961c01bd2411515549d907e.svg"
},
"531": {
"name": "Ghost",
"author": "Stephen Murphy",
"image": "2024/07/106d0bed641489cc5b2ee371dcfdebfa.svg"
},
"509": {
"name": "Heart loop",
"author": "000653.17c9b352828247bd858234a2a114f79b.1358",
"image": "2024/06/985c1c16fe0ce704b17229a8c7e795f5.svg"
},
"535": {
"name": "Hubcap",
"author": "001547.d33e09ec63fb4259a31a494ad194e028.0314",
"image": "2024/07/565216e030c9fa2a474c4f57366a5cc3.svg"
},
"538": {
"name": "Noise cell",
"author": "001547.d33e09ec63fb4259a31a494ad194e028.0314",
"image": "2024/07/b60bebf49043ef7969a722d826e88bf5.svg"
},
"559": {
"name": "Polymath",
"author": "Codie Johnston",
"image": "2024/07/7a5fd9826476071567967fc17ec6cb12.svg"
},
"1264": {
"name": "Raccoon",
"author": "Otávio Bittencourt",
"image": "2024/07/5ff0bd18649b029e16ac32f3b96f9715.svg"
},
"551": {
"name": "snowflake",
"author": "christina",
"image": "2024/07/77ae32407d7d2563110b1ee4607f6b7e.svg"
},
"553": {
"name": "spheres",
"author": "max",
"image": "2024/07/7a292d6cb204fbe4fc4a56ef8e4e9228.svg"
},
"544": {
"name": "Star round",
"author": "Codie Johnston",
"image": "2024/07/5e05532a764a109b090abc06f217f62e.svg"
},
"517": {
"name": "Starburst",
"author": "001547.d33e09ec63fb4259a31a494ad194e028.0314",
"image": "2024/06/ec2c03a42db75c33ddf677c6ac52e7b3.svg"
},
"1137": {
"name": "Tiger",
"author": "Otávio Bittencourt",
"image": "2024/07/02da0d000c200fb8cab3f1d38a90e077.svg"
},
"528": {
"name": "Tight spiral in to out",
"author": "Codie Johnston",
"image": "2024/06/82360f9b4c9dc169bceb99a1b4a3a13c.svg"
},
"527": {
"name": "Tight spiral out to in",
"author": "Codie Johnston",
"image": "2024/06/eef9f4aa33ca80e3f09e4c4661c6c80e.svg"
},
"519": {
"name": "Web",
"author": "000653.17c9b352828247bd858234a2a114f79b.1358",
"image": "2024/06/9c05e1e19cb5ecf6e156e44a8a8829e5.svg"
},
"536": {
"name": "Yin yang",
"author": "001547.d33e09ec63fb4259a31a494ad194e028.0314",
"image": "2024/07/36fe669628c5e4dfd6d33a263196a750.svg"
} }
} }

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,7 +2,8 @@
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.config_entries import ConfigEntry
@@ -13,12 +14,25 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN from .const import DOMAIN
from .coordinator import OasisMiniCoordinator from .coordinator import OasisMiniCoordinator
from .entity import OasisMiniEntity from .entity import OasisMiniEntity
from .pyoasismini import 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,
@@ -31,22 +45,62 @@ class OasisMiniSelectEntity(OasisMiniEntity, SelectEntity):
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()
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(str(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, 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(
@@ -54,4 +108,9 @@ async def async_setup_entry(
) -> 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] coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities([OasisMiniSelectEntity(coordinator, entry, DESCRIPTOR)]) async_add_entities(
[
OasisMiniSelectEntity(coordinator, entry, descriptor)
for descriptor in DESCRIPTORS
]
)

View File

@@ -8,7 +8,7 @@ from homeassistant.components.sensor import (
SensorStateClass, SensorStateClass,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory from homeassistant.const import PERCENTAGE, 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
@@ -22,12 +22,18 @@ async def async_setup_entry(
) -> 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 = hass.data[DOMAIN][entry.entry_id]
async_add_entities( entities = [
[ OasisMiniSensorEntity(coordinator, entry, descriptor)
OasisMiniSensorEntity(coordinator, entry, descriptor) for descriptor in DESCRIPTORS
for descriptor in DESCRIPTORS ]
] if coordinator.device.access_token:
) entities.extend(
[
OasisMiniSensorEntity(coordinator, entry, descriptor)
for descriptor in CLOUD_DESCRIPTORS
]
)
async_add_entities(entities)
DESCRIPTORS = { DESCRIPTORS = {
@@ -36,7 +42,8 @@ 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(
@@ -54,6 +61,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 Oasis Mini",
"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%]"

View File

@@ -1,64 +1,54 @@
"""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.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 .const import DOMAIN
from .coordinator import OasisMiniCoordinator # 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: ConfigEntry, async_add_entities: AddEntitiesCallback
# ) -> None:
@property # """Set up Oasis Mini switchs using config entry."""
def is_on(self) -> bool: # coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id]
"""Return True if entity is on.""" # async_add_entities(
return int(getattr(self.device, self.entity_description.key)) # [
# OasisMiniSwitchEntity(coordinator, entry, descriptor)
async def async_turn_off(self, **kwargs: Any) -> None: # for descriptor in DESCRIPTORS
"""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 = { # 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 Oasis Mini",
"data": { "data": {
"email": "Email", "email": "Email",
"password": "Password" "password": "Password"

View File

@@ -0,0 +1,80 @@
"""Oasis Mini update entity."""
from __future__ import annotations
from datetime import timedelta
from typing import Any
from homeassistant.components.update import (
UpdateDeviceClass,
UpdateEntity,
UpdateEntityDescription,
UpdateEntityFeature,
)
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
SCAN_INTERVAL = timedelta(hours=6)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up Oasis Mini updates using config entry."""
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id]
if coordinator.device.access_token:
async_add_entities(
[OasisMiniUpdateEntity(coordinator, entry, 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()
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