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

4 Commits
0.5.0 ... 0.7.0

Author SHA1 Message Date
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
18 changed files with 329 additions and 100 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
@@ -39,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

@@ -19,6 +19,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 .helpers import add_and_play_track
from .pyoasismini import OasisMini from .pyoasismini import OasisMini
from .pyoasismini.const import TRACKS from .pyoasismini.const import TRACKS
@@ -39,17 +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 (index := device.playlist.index(track)) != device.playlist_index:
if index != (next_index := device.playlist_index + 1):
await device.async_move_track(index, next_index)
await device.async_change_track(next_index)
if device.status_code != 4:
await device.async_play()
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)

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,16 +19,25 @@ _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: if not self.device.mac_address:
@@ -40,10 +48,15 @@ class OasisMiniCoordinator(DataUpdateCoordinator[str]):
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

@@ -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,15 +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(self.device.track, self.device.progress, "1") if not self._cached_image:
self._cached_image = Image(
self.content_type, draw_svg(self.device.track, self._progress, "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(
@@ -47,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,13 +16,17 @@ 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
_LOGGER = logging.getLogger(__name__)
class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity): class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
"""Oasis Mini media player entity.""" """Oasis Mini media player entity."""
@@ -33,6 +38,7 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
| MediaPlayerEntityFeature.STOP | MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.PREVIOUS_TRACK | MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.NEXT_TRACK | MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.PLAY_MEDIA
| MediaPlayerEntityFeature.CLEAR_PLAYLIST | MediaPlayerEntityFeature.CLEAR_PLAYLIST
| MediaPlayerEntityFeature.REPEAT_SET | MediaPlayerEntityFeature.REPEAT_SET
) )
@@ -43,11 +49,11 @@ 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 (track := self.device.track) and "reduced_svg_content" in track: if (track := self.device.track) 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:
@@ -69,8 +75,10 @@ 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 self.device.track_id:
return None
if not (track := self.device.track): if not (track := self.device.track):
track = TRACKS.get(str(self.device.track_id), {}) track = TRACKS.get(str(self.device.track_id), {})
return track.get("name", f"Unknown Title (#{self.device.track_id})") return track.get("name", f"Unknown Title (#{self.device.track_id})")
@@ -84,11 +92,11 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
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 == 9: if self.device.error or status_code in (9, 11):
return MediaPlayerState.OFF return MediaPlayerState.OFF
if status_code == 2: if status_code == 2:
return MediaPlayerState.IDLE return MediaPlayerState.IDLE
if status_code in (3, 11, 13): if status_code in (3, 13):
return MediaPlayerState.BUFFERING return MediaPlayerState.BUFFERING
if status_code == 4: if status_code == 4:
return MediaPlayerState.PLAYING return MediaPlayerState.PLAYING
@@ -135,6 +143,26 @@ 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: async def async_clear_playlist(self) -> None:
"""Clear players playlist.""" """Clear players playlist."""
await self.device.async_set_playlist([0]) await self.device.async_set_playlist([0])

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
@@ -34,12 +38,14 @@ DESCRIPTORS = {
NumberEntityDescription( NumberEntityDescription(
key="ball_speed", key="ball_speed",
name="Ball speed", name="Ball speed",
mode=NumberMode.SLIDER,
native_max_value=BALL_SPEED_MAX, native_max_value=BALL_SPEED_MAX,
native_min_value=BALL_SPEED_MIN, native_min_value=BALL_SPEED_MIN,
), ),
NumberEntityDescription( NumberEntityDescription(
key="led_speed", key="led_speed",
name="LED speed", name="LED speed",
mode=NumberMode.SLIDER,
native_max_value=LED_SPEED_MAX, native_max_value=LED_SPEED_MAX,
native_min_value=LED_SPEED_MIN, native_min_value=LED_SPEED_MIN,
), ),

View File

@@ -35,7 +35,7 @@ ATTRIBUTES: Final[list[tuple[str, Callable[[str], Any]]]] = [
("status_code", int), # see status code map ("status_code", int), # see status code map
("error", int), # error, 0 = none, and 10 = ?, 18 = can't download? ("error", int), # error, 0 = none, and 10 = ?, 18 = can't download?
("ball_speed", int), # 200 - 1000 ("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)
@@ -83,6 +83,7 @@ class OasisMini:
_access_token: str | None = None _access_token: str | None = None
_mac_address: str | None = None _mac_address: str | None = None
_ip_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 _track: dict | None = None
@@ -122,6 +123,16 @@ class OasisMini:
"""Return the mac address.""" """Return the mac address."""
return self._mac_address return self._mac_address
@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:
"""Return the serial number.""" """Return the serial number."""
@@ -150,8 +161,10 @@ class OasisMini:
return None return None
@property @property
def track_id(self) -> int: def track_id(self) -> int | None:
"""Return the current track id.""" """Return the current track id."""
if not self.playlist:
return None
i = self.playlist_index i = self.playlist_index
return self.playlist[0] if i >= len(self.playlist) else self.playlist[i] return self.playlist[0] if i >= len(self.playlist) else self.playlist[i]
@@ -199,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)
@@ -314,11 +327,18 @@ class OasisMini:
"""Get cloud track info.""" """Get cloud track info."""
return await self._async_cloud_request("GET", f"api/track/{track_id}") return await self._async_cloud_request("GET", f"api/track/{track_id}")
async def async_cloud_get_tracks(self, tracks: list[int]) -> dict: async def async_cloud_get_tracks(
self, tracks: list[int] | None = None
) -> list[dict[str, Any]]:
"""Get tracks info from the cloud""" """Get tracks info from the cloud"""
return await self._async_cloud_request( response = await self._async_cloud_request(
"GET", "api/track", params={"ids[]": tracks} "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]: async def async_cloud_get_latest_software_details(self) -> dict[str, int | str]:
"""Get the latest software details from the cloud.""" """Get the latest software details from the cloud."""
@@ -332,9 +352,21 @@ class OasisMini:
self._track = await self.async_cloud_get_track_info(self.track_id) self._track = await self.async_cloud_get_track_info(self.track_id)
return self._track return self._track
async def async_get_playlist_details(self) -> dict: async def async_get_playlist_details(self) -> dict[int, dict[str, str]]:
"""Get playlist info.""" """Get playlist info."""
return await self.async_cloud_get_tracks(self.playlist) 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: async def _async_cloud_request(self, method: str, url: str, **kwargs: Any) -> Any:
"""Perform a cloud request.""" """Perform a cloud request."""

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",
@@ -833,5 +833,90 @@
"name": "Horse", "name": "Horse",
"author": "Otávio Bittencourt", "author": "Otávio Bittencourt",
"image": "2024/07/9fec8716ce98fdbf0c02db14b47b0d66.svg" "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

@@ -22,6 +22,7 @@ from .pyoasismini.const import TRACKS
class OasisMiniSelectEntityDescription(SelectEntityDescription): class OasisMiniSelectEntityDescription(SelectEntityDescription):
"""Oasis Mini select entity description.""" """Oasis Mini select entity description."""
current_value: Callable[[OasisMini], Any]
select_fn: Callable[[OasisMini, int], Awaitable[None]] select_fn: Callable[[OasisMini, int], Awaitable[None]]
update_handler: Callable[[OasisMiniSelectEntity], None] | None = None update_handler: Callable[[OasisMiniSelectEntity], None] | None = None
@@ -30,6 +31,7 @@ class OasisMiniSelectEntity(OasisMiniEntity, SelectEntity):
"""Oasis Mini select entity.""" """Oasis Mini select entity."""
entity_description: OasisMiniSelectEntityDescription entity_description: OasisMiniSelectEntityDescription
_current_value: Any | None = None
def __init__( def __init__(
self, self,
@@ -48,6 +50,10 @@ class OasisMiniSelectEntity(OasisMiniEntity, SelectEntity):
def _handle_coordinator_update(self) -> None: def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator.""" """Handle updated data from the coordinator."""
new_value = self.entity_description.current_value(self.device)
if self._current_value == new_value:
return
self._current_value = new_value
if update_handler := self.entity_description.update_handler: if update_handler := self.entity_description.update_handler:
update_handler(self) update_handler(self)
else: else:
@@ -61,19 +67,29 @@ class OasisMiniSelectEntity(OasisMiniEntity, SelectEntity):
def playlist_update_handler(entity: OasisMiniSelectEntity) -> None: def playlist_update_handler(entity: OasisMiniSelectEntity) -> None:
"""Handle playlist updates.""" """Handle playlist updates."""
# pylint: disable=protected-access # pylint: disable=protected-access
device = entity.device
options = [ options = [
TRACKS.get(str(track), {}).get("name", str(track)) device._playlist.get(track, {}).get(
for track in entity.device.playlist "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 entity._attr_options = options
index = min(entity.device.playlist_index, len(options) - 1) index = min(device.playlist_index, len(options) - 1)
entity._attr_current_option = options[index] entity._attr_current_option = options[index] if options else None
DESCRIPTORS = ( DESCRIPTORS = (
OasisMiniSelectEntityDescription( OasisMiniSelectEntityDescription(
key="playlist", key="playlist",
name="Playlist", name="Playlist",
current_value=lambda device: (device.playlist, device.playlist_index),
select_fn=lambda device, option: device.async_change_track(option), select_fn=lambda device, option: device.async_change_track(option),
update_handler=playlist_update_handler, update_handler=playlist_update_handler,
), ),
@@ -81,6 +97,7 @@ DESCRIPTORS = (
key="autoplay", key="autoplay",
name="Autoplay", name="Autoplay",
options=list(AUTOPLAY_MAP.values()), options=list(AUTOPLAY_MAP.values()),
current_value=lambda device: device.autoplay,
select_fn=lambda device, option: device.async_set_autoplay(option), select_fn=lambda device, option: device.async_set_autoplay(option),
), ),
) )

View File

@@ -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 = {
@@ -55,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

@@ -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

@@ -66,10 +66,14 @@ class OasisMiniUpdateEntity(OasisMiniEntity, UpdateEntity):
self, version: str | None, backup: bool, **kwargs: Any self, version: str | None, backup: bool, **kwargs: Any
) -> None: ) -> None:
"""Install an update.""" """Install an update."""
version = await self.device.async_get_software_version()
if version == self.latest_version:
return
await self.device.async_upgrade() await self.device.async_upgrade()
async def async_update(self) -> None: async def async_update(self) -> None:
"""Update the entity.""" """Update the entity."""
await self.device.async_get_software_version()
software = await self.device.async_cloud_get_latest_software_details() software = await self.device.async_cloud_get_latest_software_details()
self._attr_latest_version = software["version"] self._attr_latest_version = software["version"]
self._attr_release_summary = software["description"] self._attr_release_summary = software["description"]

View File

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