1
0
mirror of https://github.com/natekspencer/hacs-oasis_mini.git synced 2025-12-06 18:44:14 -05:00

3 Commits

Author SHA1 Message Date
Nathan Spencer
83de1d5606 Add additional helpers 2025-11-23 06:45:01 +00:00
Nathan Spencer
2a92212aad Get track info from the cloud when playlist or index changes 2025-11-23 00:13:45 +00:00
Nathan Spencer
ecad472bbd Better mqtt handling when connection is interrupted 2025-11-22 20:51:17 +00:00
21 changed files with 420 additions and 220 deletions

View File

@@ -10,9 +10,9 @@
<img alt="Oasis Mini logo" src="https://brands.home-assistant.io/oasis_mini/logo.png">
</picture>
# Oasis Mini for Home Assistant
# Oasis Control for Home Assistant
Home Assistant integration for Oasis Mini kinetic sand art devices.
Home Assistant integration for Oasis kinetic sand art devices.
# Installation
@@ -43,13 +43,13 @@ While the manual installation above seems like less steps, it's important to not
[![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=oasis_mini)
There is a config flow for this Oasis Mini integration. After installing the custom component, use the convenient My Home Assistant link above.
There is a config flow for this Oasis Control integration. After installing the custom component, use the convenient My Home Assistant link above.
Alternatively:
1. Go to **Configuration**->**Integrations**
2. Click **+ ADD INTEGRATION** to setup a new integration
3. Search for **Oasis Mini** and click on it
3. Search for **Oasis Control** and click on it
4. You will be guided through the rest of the setup process via the config flow
# Options
@@ -76,6 +76,6 @@ data:
I'm not employed by Kinetic Oasis, and provide this custom component purely for your own enjoyment and home automation needs.
If you already own an Oasis Mini, found this integration useful and want to donate, consider [sponsoring me on GitHub](https://github.com/sponsors/natekspencer) or buying me a coffee ☕ (or beer 🍺) instead by using the link below:
If you already own an Oasis device, found this integration useful and want to donate, consider [sponsoring me on GitHub](https://github.com/sponsors/natekspencer) or buying me a coffee ☕ (or beer 🍺) instead by using the link below:
<a href='https://ko-fi.com/Y8Y57F59S' target='_blank'><img height='36' style='border:0px;height:36px;' src='https://storage.ko-fi.com/cdn/kofi1.png?v=3' border='0' alt='Buy Me a Coffee at ko-fi.com' /></a>

View File

@@ -10,6 +10,7 @@ from homeassistant.const import CONF_EMAIL, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed
import homeassistant.helpers.entity_registry as er
import homeassistant.util.dt as dt_util
from .coordinator import OasisDeviceCoordinator
from .helpers import create_client
@@ -59,6 +60,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OasisDeviceConfigEntry)
entry.runtime_data = coordinator
def _on_oasis_update() -> None:
coordinator.last_updated = dt_util.now()
coordinator.async_update_listeners()
for device in coordinator.data:

View File

@@ -78,4 +78,3 @@ class OasisDeviceButtonEntity(OasisDeviceEntity, ButtonEntity):
async def async_press(self) -> None:
"""Press the button."""
await self.entity_description.press_fn(self.device)
await self.coordinator.async_request_refresh()

View File

@@ -9,6 +9,7 @@ import async_timeout
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
import homeassistant.util.dt as dt_util
from .const import DOMAIN
from .pyoasiscontrol import OasisCloudClient, OasisDevice, OasisMqttClient
@@ -33,7 +34,7 @@ class OasisDeviceCoordinator(DataUpdateCoordinator[list[OasisDevice]]):
hass,
_LOGGER,
name=DOMAIN,
update_interval=timedelta(seconds=10),
update_interval=timedelta(minutes=10),
always_update=False,
)
self.cloud_client = cloud_client
@@ -52,6 +53,7 @@ class OasisDeviceCoordinator(DataUpdateCoordinator[list[OasisDevice]]):
OasisDevice(
model=raw_device.get("model", {}).get("name"),
serial_number=raw_device.get("serial_number"),
cloud=self.cloud_client,
)
for raw_device in raw_devices
]
@@ -60,16 +62,12 @@ class OasisDeviceCoordinator(DataUpdateCoordinator[list[OasisDevice]]):
for device in devices:
self.mqtt_client.register_device(device)
await self.mqtt_client.wait_until_ready(device, request_status=True)
if not device.mac_address:
await device.async_get_mac_address()
# if not device.software_version:
# await device.async_get_software_version()
# data = await self.device.async_get_status()
# devices = self.cloud_client.mac_address
if not await device.async_get_mac_address():
raise Exception(
"Could not get mac address for %s", device.serial_number
)
await self.cloud_client.async_get_playlists()
self.attempt = 0
# await self.device.async_get_current_track_details()
# await self.device.async_get_playlist_details()
# await self.device.async_cloud_get_playlists()
except Exception as ex: # pylint:disable=broad-except
if self.attempt > 2 or not (devices or self.data):
raise UpdateFailed(
@@ -77,5 +75,5 @@ class OasisDeviceCoordinator(DataUpdateCoordinator[list[OasisDevice]]):
) from ex
if devices != self.data:
self.last_updated = datetime.now()
self.last_updated = dt_util.now()
return devices

View File

@@ -30,8 +30,12 @@ class OasisDeviceEntity(CoordinatorEntity[OasisDeviceCoordinator]):
serial_number = device.serial_number
self._attr_unique_id = f"{serial_number}-{description.key}"
connections = set()
if mac_address := device.mac_address:
connections.add((CONNECTION_NETWORK_MAC, format_mac(mac_address)))
self._attr_device_info = DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, format_mac(device.mac_address))},
connections=connections,
identifiers={(DOMAIN, serial_number)},
name=f"{device.model} {serial_number}",
manufacturer=device.manufacturer,

View File

@@ -2,9 +2,12 @@
from __future__ import annotations
import asyncio
import logging
from typing import Any
import async_timeout
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -23,12 +26,18 @@ def create_client(hass: HomeAssistant, data: dict[str, Any]) -> OasisCloudClient
async def add_and_play_track(device: OasisDevice, track: int) -> None:
"""Add and play a track."""
async with async_timeout.timeout(10):
if track not in device.playlist:
await device.async_add_track_to_playlist(track)
while track not in device.playlist:
await asyncio.sleep(0.1)
# Move track to next item in the playlist and then select it
if (index := device.playlist.index(track)) != device.playlist_index:
if index != (_next := min(device.playlist_index + 1, len(device.playlist) - 1)):
if index != (
_next := min(device.playlist_index + 1, len(device.playlist) - 1)
):
await device.async_move_track(index, _next)
await device.async_change_track(_next)

View File

@@ -11,8 +11,6 @@ from . import OasisDeviceConfigEntry
from .coordinator import OasisDeviceCoordinator
from .entity import OasisDeviceEntity
from .pyoasiscontrol import OasisDevice
from .pyoasiscontrol.const import TRACKS
from .pyoasiscontrol.utils import draw_svg
async def async_setup_entry(
@@ -52,33 +50,24 @@ class OasisDeviceImageEntity(OasisDeviceEntity, ImageEntity):
def image(self) -> bytes | None:
"""Return bytes of image."""
if not self._cached_image:
self._cached_image = Image(
self.content_type, draw_svg(self.device.track, self._progress, "1")
)
self._cached_image = Image(self.content_type, self.device.create_svg())
return self._cached_image.content
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
device = self.device
if (
self._track_id != self.device.track_id
or self._progress != self.device.progress
) and (self.device.status == "playing" or self._cached_image is None):
self._track_id != device.track_id or self._progress != device.progress
) and (device.status == "playing" or self._cached_image is None):
self._attr_image_last_updated = self.coordinator.last_updated
self._track_id = self.device.track_id
self._progress = self.device.progress
self._track_id = device.track_id
self._progress = device.progress
self._cached_image = None
if self.device.track and self.device.track.get("svg_content"):
if device.track and device.track.get("svg_content"):
self._attr_image_url = UNDEFINED
else:
self._attr_image_url = (
f"https://app.grounded.so/uploads/{track['image']}"
if (
track := (self.device.track or TRACKS.get(self.device.track_id))
)
and "image" in track
else None
)
self._attr_image_url = device.track_image_url
if self.hass:
super()._handle_coordinator_update()

View File

@@ -53,7 +53,7 @@ class OasisDeviceLightEntity(OasisDeviceEntity, LightEntity):
@property
def brightness(self) -> int:
"""Return the brightness of this light between 0..255."""
scale = (1, self.device.max_brightness)
scale = (1, self.device.brightness_max)
return value_to_brightness(scale, self.device.brightness)
@property
@@ -99,15 +99,14 @@ class OasisDeviceLightEntity(OasisDeviceEntity, LightEntity):
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
await self.device.async_set_led(brightness=0)
await self.coordinator.async_request_refresh()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
if brightness := kwargs.get(ATTR_BRIGHTNESS):
scale = (1, self.device.max_brightness)
scale = (1, self.device.brightness_max)
brightness = math.ceil(brightness_to_value(scale, brightness))
else:
brightness = self.device.brightness or 100
brightness = self.device.brightness or self.device.brightness_on
if color := kwargs.get(ATTR_RGB_COLOR):
color = f"#{color_rgb_to_hex(*color)}"
@@ -120,4 +119,3 @@ class OasisDeviceLightEntity(OasisDeviceEntity, LightEntity):
await self.device.async_set_led(
brightness=brightness, color=color, led_effect=led_effect
)
await self.coordinator.async_request_refresh()

View File

@@ -23,7 +23,6 @@ from .const import DOMAIN
from .coordinator import OasisDeviceCoordinator
from .entity import OasisDeviceEntity
from .helpers import get_track_id
from .pyoasiscontrol.const import TRACKS
async def async_setup_entry(
@@ -73,11 +72,7 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity):
@property
def media_image_url(self) -> str | None:
"""Image url of current playing media."""
if not (track := self.device.track):
track = TRACKS.get(self.device.track_id)
if track and "image" in track:
return f"https://app.grounded.so/uploads/{track['image']}"
return None
return self.device.track_image_url
@property
def media_position(self) -> int:
@@ -92,11 +87,7 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity):
@property
def media_title(self) -> str | None:
"""Title of current playing media."""
if not self.device.track_id:
return None
if not (track := self.device.track):
track = TRACKS.get(self.device.track_id, {})
return track.get("name", f"Unknown Title (#{self.device.track_id})")
return self.device.track_name
@property
def repeat(self) -> RepeatMode:
@@ -134,19 +125,16 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity):
"""Send pause command."""
self.abort_if_busy()
await self.device.async_pause()
await self.coordinator.async_request_refresh()
async def async_media_play(self) -> None:
"""Send play command."""
self.abort_if_busy()
await self.device.async_play()
await self.coordinator.async_request_refresh()
async def async_media_stop(self) -> None:
"""Send stop command."""
self.abort_if_busy()
await self.device.async_stop()
await self.coordinator.async_request_refresh()
async def async_set_repeat(self, repeat: RepeatMode) -> None:
"""Set repeat mode."""
@@ -154,7 +142,6 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity):
repeat != RepeatMode.OFF
and not (repeat == RepeatMode.ONE and self.repeat == RepeatMode.ALL)
)
await self.coordinator.async_request_refresh()
async def async_media_previous_track(self) -> None:
"""Send previous track command."""
@@ -162,7 +149,6 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity):
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:
"""Send next track command."""
@@ -170,7 +156,6 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity):
if (index := self.device.playlist_index + 1) >= len(self.device.playlist):
index = 0
await self.device.async_change_track(index)
await self.coordinator.async_request_refresh()
async def async_play_media(
self,
@@ -220,10 +205,7 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity):
):
await device.async_play()
await self.coordinator.async_request_refresh()
async def async_clear_playlist(self) -> None:
"""Clear players playlist."""
self.abort_if_busy()
await self.device.async_clear_playlist()
await self.coordinator.async_request_refresh()

View File

@@ -63,8 +63,8 @@ class OasisDeviceNumberEntity(OasisDeviceEntity, NumberEntity):
async def async_set_native_value(self, value: float) -> None:
"""Set new value."""
value = int(value)
if self.entity_description.key == "ball_speed":
await self.device.async_set_ball_speed(value)
elif self.entity_description.key == "led_speed":
await self.device.async_set_led(led_speed=value)
await self.coordinator.async_request_refresh()

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from datetime import timedelta
from datetime import datetime, timedelta
import logging
from typing import Any
from urllib.parse import urljoin
@@ -37,7 +37,7 @@ class OasisCloudClient:
_access_token: str | None
# these are "cache" fields for tracks/playlists
_playlists_next_refresh: float
_playlists_next_refresh: datetime
playlists: list[dict[str, Any]]
_playlist_details: dict[int, dict[str, str]]
@@ -52,7 +52,7 @@ class OasisCloudClient:
self._access_token = access_token
# simple in-memory caches
self._playlists_next_refresh = 0.0
self._playlists_next_refresh = now()
self.playlists = []
self._playlist_details = {}

View File

@@ -7,9 +7,7 @@ from typing import Any
from aiohttp import ClientSession
from ..const import AUTOPLAY_MAP
from ..device import OasisDevice
from ..utils import _bit_to_bool, _parse_int
from .transport import OasisClientProtocol
_LOGGER = logging.getLogger(__name__)
@@ -179,37 +177,4 @@ class OasisHttpClient(OasisClientProtocol):
return
_LOGGER.debug("Status for %s: %s", device.serial_number, raw_status)
values = raw_status.split(";")
if len(values) < 7:
_LOGGER.warning(
"Unexpected status format for %s: %s", device.serial_number, values
)
return
playlist = [_parse_int(track) for track in values[3].split(",") if track]
shift = len(values) - 18 if len(values) > 17 else 0
try:
status: dict[str, Any] = {
"status_code": _parse_int(values[0]),
"error": _parse_int(values[1]),
"ball_speed": _parse_int(values[2]),
"playlist": playlist,
"playlist_index": min(_parse_int(values[4]), len(playlist)),
"progress": _parse_int(values[5]),
"led_effect": values[6],
"led_speed": _parse_int(values[8]),
"brightness": _parse_int(values[9]),
"color": values[10] if "#" in values[10] else None,
"busy": _bit_to_bool(values[11 + shift]),
"download_progress": _parse_int(values[12 + shift]),
"max_brightness": _parse_int(values[13 + shift]),
"repeat_playlist": _bit_to_bool(values[15 + shift]),
"autoplay": AUTOPLAY_MAP.get(value := values[16 + shift], value),
}
except Exception: # noqa: BLE001
_LOGGER.exception("Error parsing HTTP status for %s", device.serial_number)
return
device.update_from_status_dict(status)
device.update_from_status_string(raw_status)

View File

@@ -11,9 +11,8 @@ from typing import Any, Final
import aiomqtt
from ..const import AUTOPLAY_MAP
from ..device import OasisDevice
from ..utils import _bit_to_bool
from ..utils import _bit_to_bool, _parse_int
from .transport import OasisClientProtocol
_LOGGER = logging.getLogger(__name__)
@@ -26,6 +25,9 @@ USERNAME: Final = "YXBw"
PASSWORD: Final = "RWdETFlKMDczfi4t"
RECONNECT_INTERVAL: Final = 4
# Command queue behaviour
MAX_PENDING_COMMANDS: Final = 10
class OasisMqttClient(OasisClientProtocol):
"""MQTT-based Oasis transport using WSS.
@@ -58,6 +60,11 @@ class OasisMqttClient(OasisClientProtocol):
self._subscribed_serials: set[str] = set()
self._subscription_lock = asyncio.Lock()
# Pending command queue: (serial, payload)
self._command_queue: asyncio.Queue[tuple[str, str]] = asyncio.Queue(
maxsize=MAX_PENDING_COMMANDS
)
def register_device(self, device: OasisDevice) -> None:
"""Register a device so MQTT messages can be routed to it."""
if not device.serial_number:
@@ -70,6 +77,10 @@ class OasisMqttClient(OasisClientProtocol):
self._first_status_events.setdefault(serial, asyncio.Event())
self._mac_events.setdefault(serial, asyncio.Event())
# Attach ourselves as the client if the device doesn't already have one
if not device.client:
device.attach_client(self)
# If we're already connected, subscribe to this device's topics
if self._client is not None:
try:
@@ -81,9 +92,6 @@ class OasisMqttClient(OasisClientProtocol):
"Could not schedule subscription for %s (no running loop)", serial
)
if not device.client:
device.attach_client(self)
def unregister_device(self, device: OasisDevice) -> None:
serial = device.serial_number
if not serial:
@@ -135,8 +143,9 @@ class OasisMqttClient(OasisClientProtocol):
async def _resubscribe_all(self) -> None:
"""Resubscribe to all known devices after (re)connect."""
self._subscribed_serials.clear()
for serial in list(self._devices):
for serial, device in self._devices.items():
await self._subscribe_serial(serial)
await self.async_get_all(device)
def start(self) -> None:
"""Start MQTT connection loop."""
@@ -168,6 +177,14 @@ class OasisMqttClient(OasisClientProtocol):
finally:
self._client = None
# Drop pending commands on stop
while not self._command_queue.empty():
try:
self._command_queue.get_nowait()
self._command_queue.task_done()
except asyncio.QueueEmpty:
break
async def wait_until_ready(
self, device: OasisDevice, timeout: float = 10.0, request_status: bool = True
) -> bool:
@@ -260,7 +277,7 @@ class OasisMqttClient(OasisClientProtocol):
brightness: int,
) -> None:
payload = f"WRILED={led_effect};0;{color};{led_speed};{brightness}"
await self._publish_command(device, payload)
await self._publish_command(device, payload, bool(brightness))
async def async_send_sleep_command(self, device: OasisDevice) -> None:
await self._publish_command(device, "CMDSLEEP")
@@ -300,9 +317,6 @@ class OasisMqttClient(OasisClientProtocol):
payload = f"WRIJOBLIST={track_str}"
await self._publish_command(device, payload)
# local state optimistic update
device.update_from_status_dict({"playlist": playlist})
async def async_send_set_repeat_playlist_command(
self,
device: OasisDevice,
@@ -328,7 +342,7 @@ class OasisMqttClient(OasisClientProtocol):
await self._publish_command(device, payload)
async def async_send_play_command(self, device: OasisDevice) -> None:
await self._publish_command(device, "CMDPLAY")
await self._publish_command(device, "CMDPLAY", True)
async def async_send_pause_command(self, device: OasisDevice) -> None:
await self._publish_command(device, "CMDPAUSE")
@@ -339,21 +353,96 @@ class OasisMqttClient(OasisClientProtocol):
async def async_send_reboot_command(self, device: OasisDevice) -> None:
await self._publish_command(device, "CMDBOOT")
async def async_get_all(self, device: OasisDevice) -> None:
"""Request FULLSTATUS + SCHEDULE (compact snapshot)."""
await self._publish_command(device, "GETALL")
async def async_get_status(self, device: OasisDevice) -> None:
"""Ask device to publish STATUS topics."""
await self._publish_command(device, "GETSTATUS")
async def _publish_command(self, device: OasisDevice, payload: str) -> None:
if not self._client:
raise RuntimeError("MQTT client not connected yet")
async def _enqueue_command(self, serial: str, payload: str) -> None:
"""Queue a command to be sent when connected.
serial = device.serial_number
if not serial:
raise RuntimeError("Device has no serial_number set")
If the queue is full, drop the oldest command to make room.
"""
if self._command_queue.full():
try:
dropped = self._command_queue.get_nowait()
self._command_queue.task_done()
_LOGGER.debug(
"Command queue full, dropping oldest command: %s", dropped
)
except asyncio.QueueEmpty:
# race: became empty between full() and get_nowait()
pass
await self._command_queue.put((serial, payload))
_LOGGER.debug("Queued command for %s: %s", serial, payload)
async def _flush_pending_commands(self) -> None:
"""Send any queued commands now that we're connected."""
if not self._client:
return
while not self._command_queue.empty():
try:
serial, payload = self._command_queue.get_nowait()
except asyncio.QueueEmpty:
break
try:
# Skip commands for unknown devices
if serial not in self._devices:
_LOGGER.debug(
"Skipping queued command for unknown device %s: %s",
serial,
payload,
)
self._command_queue.task_done()
continue
topic = f"{serial}/COMMAND/CMD"
_LOGGER.debug("Flushing queued MQTT command %s => %s", topic, payload)
await self._client.publish(topic, payload.encode(), qos=1)
except Exception:
_LOGGER.debug(
"Failed to flush queued command for %s, re-queuing", serial
)
# Put it back and break; we'll try again on next reconnect
await self._enqueue_command(serial, payload)
self._command_queue.task_done()
break
self._command_queue.task_done()
async def _publish_command(
self, device: OasisDevice, payload: str, wake: bool = False
) -> None:
serial = device.serial_number
if not serial:
raise RuntimeError("Device has no serial number set")
if wake and device.is_sleeping:
await self.async_get_all(device)
# If not connected, just queue the command
if not self._client or not self._connected_event.is_set():
_LOGGER.debug(
"MQTT not connected, queueing command for %s: %s", serial, payload
)
await self._enqueue_command(serial, payload)
return
topic = f"{serial}/COMMAND/CMD"
try:
_LOGGER.debug("MQTT publish %s => %s", topic, payload)
await self._client.publish(topic, payload.encode(), qos=1)
except Exception:
_LOGGER.debug(
"MQTT publish failed, queueing command for %s: %s", serial, payload
)
await self._enqueue_command(serial, payload)
async def _mqtt_loop(self) -> None:
loop = asyncio.get_running_loop()
@@ -361,12 +450,7 @@ class OasisMqttClient(OasisClientProtocol):
while not self._stop_event.is_set():
try:
_LOGGER.debug(
"Connecting MQTT WSS to wss://%s:%s/%s",
HOST,
PORT,
PATH,
)
_LOGGER.info("Connecting MQTT WSS to wss://%s:%s/%s", HOST, PORT, PATH)
async with aiomqtt.Client(
hostname=HOST,
@@ -386,6 +470,9 @@ class OasisMqttClient(OasisClientProtocol):
# Subscribe only to STATUS topics for known devices
await self._resubscribe_all()
# Flush any queued commands now that we're connected
await self._flush_pending_commands()
async for msg in client.messages:
if self._stop_event.is_set():
break
@@ -394,13 +481,13 @@ class OasisMqttClient(OasisClientProtocol):
except asyncio.CancelledError:
break
except Exception:
_LOGGER.debug("MQTT connection error")
_LOGGER.info("MQTT connection error")
finally:
if self._connected_event.is_set():
self._connected_event.clear()
if self._connected_at:
_LOGGER.debug(
_LOGGER.info(
"MQTT was connected for %s",
datetime.now(UTC) - self._connected_at,
)
@@ -409,14 +496,13 @@ class OasisMqttClient(OasisClientProtocol):
self._subscribed_serials.clear()
if not self._stop_event.is_set():
_LOGGER.debug(
_LOGGER.info(
"Disconnected from broker, retrying in %.1fs", RECONNECT_INTERVAL
)
await asyncio.sleep(RECONNECT_INTERVAL)
async def _handle_status_message(self, msg: aiomqtt.Message) -> None:
"""Map MQTT STATUS topics → OasisDevice.update_from_status_dict payloads."""
topic_str = str(msg.topic) if msg.topic is not None else ""
payload = msg.payload.decode(errors="replace")
@@ -429,7 +515,6 @@ class OasisMqttClient(OasisClientProtocol):
device = self._devices.get(serial)
if not device:
# Ignore devices we don't know about
_LOGGER.debug("Received MQTT for unknown device %s: %s", serial, topic_str)
return
@@ -451,13 +536,13 @@ class OasisMqttClient(OasisClientProtocol):
elif status_name == "LED_EFFECT":
data["led_effect"] = payload
elif status_name == "LED_EFFECT_COLOR":
data["led_effect_color"] = payload
data["led_color_id"] = payload
elif status_name == "LED_SPEED":
data["led_speed"] = int(payload)
elif status_name == "LED_BRIGHTNESS":
data["brightness"] = int(payload)
elif status_name == "LED_MAX":
data["max_brightness"] = int(payload)
data["brightness_max"] = int(payload)
elif status_name == "LED_EFFECT_PARAM":
data["color"] = payload if payload.startswith("#") else None
elif status_name == "SYSTEM_BUSY":
@@ -467,7 +552,7 @@ class OasisMqttClient(OasisClientProtocol):
elif status_name == "REPEAT_JOB":
data["repeat_playlist"] = payload in ("1", "true", "True")
elif status_name == "WAIT_AFTER_JOB":
data["autoplay"] = AUTOPLAY_MAP.get(payload, payload)
data["autoplay"] = _parse_int(payload)
elif status_name == "AUTO_CLEAN":
data["auto_clean"] = payload in ("1", "true", "True")
elif status_name == "SOFTWARE_VER":
@@ -494,6 +579,9 @@ class OasisMqttClient(OasisClientProtocol):
data["schedule"] = payload
elif status_name == "ENVIRONMENT":
data["environment"] = payload
elif status_name == "FULLSTATUS":
if parsed := device.parse_status_string(payload):
data = parsed
else:
_LOGGER.warning(
"Unknown status received for %s: %s=%s",

View File

@@ -83,3 +83,7 @@ class OasisClientProtocol(Protocol):
async def async_send_stop_command(self, device: OasisDevice) -> None: ...
async def async_send_reboot_command(self, device: OasisDevice) -> None: ...
async def async_get_all(self, device: OasisDevice) -> None: ...
async def async_get_status(self, device: OasisDevice) -> None: ...

View File

@@ -21,6 +21,9 @@ AUTOPLAY_MAP: Final[dict[str, str]] = {
"2": "5 minutes",
"3": "10 minutes",
"4": "30 minutes",
"6": "1 hour",
"7": "6 hours",
"8": "12 hours",
"5": "24 hours",
}
@@ -91,13 +94,14 @@ LED_EFFECTS: Final[dict[str, str]] = {
"41": "Color Comets",
}
STATUS_CODE_SLEEPING: Final = 6
STATUS_CODE_MAP: Final[dict[int, str]] = {
0: "booting",
2: "stopped",
3: "centering",
4: "playing",
5: "paused",
6: "sleeping",
STATUS_CODE_SLEEPING: "sleeping",
9: "error",
11: "updating",
13: "downloading",

View File

@@ -2,12 +2,21 @@
from __future__ import annotations
import asyncio
import logging
from typing import TYPE_CHECKING, Any, Callable, Final, Iterable
from .const import ERROR_CODE_MAP, LED_EFFECTS, STATUS_CODE_MAP, TRACKS
from .const import (
ERROR_CODE_MAP,
LED_EFFECTS,
STATUS_CODE_MAP,
STATUS_CODE_SLEEPING,
TRACKS,
)
from .utils import _bit_to_bool, _parse_int, create_svg, decrypt_svg_content
if TYPE_CHECKING: # avoid runtime circular imports
from .clients import OasisCloudClient
from .clients.transport import OasisClientProtocol
_LOGGER = logging.getLogger(__name__)
@@ -18,6 +27,7 @@ LED_SPEED_MAX: Final = 90
LED_SPEED_MIN: Final = -90
_STATE_FIELDS = (
"auto_clean",
"autoplay",
"ball_speed",
"brightness",
@@ -28,7 +38,6 @@ _STATE_FIELDS = (
"led_effect",
"led_speed",
"mac_address",
"max_brightness",
"playlist",
"playlist_index",
"progress",
@@ -55,10 +64,12 @@ class OasisDevice:
serial_number: str | None = None,
ssid: str | None = None,
ip_address: str | None = None,
cloud: OasisCloudClient | None = None,
client: OasisClientProtocol | None = None,
) -> None:
# Transport
self._client: OasisClientProtocol | None = client
self._cloud = cloud
self._client = client
self._listeners: list[Callable[[], None]] = []
# Details
@@ -69,17 +80,19 @@ class OasisDevice:
# Status
self.auto_clean: bool = False
self.autoplay: str = "off"
self.autoplay: int = 0
self.ball_speed: int = BALL_SPEED_MIN
self.brightness: int = 0
self._brightness: int = 0
self.brightness_max: int = 200
self.brightness_on: int = 0
self.busy: bool = False
self.color: str | None = None
self.download_progress: int = 0
self.error: int = 0
self.led_color_id: str = "0"
self.led_effect: str = "0"
self.led_speed: int = 0
self.mac_address: str | None = None
self.max_brightness: int = 200
self.playlist: list[int] = []
self.playlist_index: int = 0
self.progress: int = 0
@@ -96,8 +109,25 @@ class OasisDevice:
self.environment: str | None = None
self.schedule: Any | None = None
# Track metadata cache (used if you hydrate from cloud)
# Track metadata cache
self._track: dict | None = None
self._track_task: asyncio.Task | None = None
@property
def brightness(self) -> int:
"""Return the brightness."""
return 0 if self.is_sleeping else self._brightness
@brightness.setter
def brightness(self, value: int) -> None:
self._brightness = value
if value:
self.brightness_on = value
@property
def is_sleeping(self) -> bool:
"""Return `True` if the status is set to sleeping."""
return self.status_code == STATUS_CODE_SLEEPING
def attach_client(self, client: OasisClientProtocol) -> None:
"""Attach a transport client (MQTT, HTTP, etc.) to this device."""
@@ -132,16 +162,83 @@ class OasisDevice:
def update_from_status_dict(self, data: dict[str, Any]) -> None:
"""Update device fields from a status payload (from any transport)."""
changed = False
playlist_or_index_changed = False
for key, value in data.items():
if hasattr(self, key):
if self._update_field(key, value):
changed = True
if key in ("playlist", "playlist_index"):
playlist_or_index_changed = True
else:
_LOGGER.warning("Unknown field: %s=%s", key, value)
if playlist_or_index_changed:
self._schedule_track_refresh()
if changed:
self._notify_listeners()
def parse_status_string(self, raw_status: str) -> dict[str, Any] | None:
"""Parse a semicolon-separated status string into a state dict.
Used by:
- HTTP GETSTATUS response
- MQTT FULLSTATUS payload (includes software_version)
"""
if not raw_status:
return None
values = raw_status.split(";")
# We rely on indices 0..17 existing (18 fields)
if (n := len(values)) < 18:
_LOGGER.warning(
"Unexpected status format for %s: %s", self.serial_number, values
)
return None
playlist = [_parse_int(track) for track in values[3].split(",") if track]
try:
status: dict[str, Any] = {
"status_code": _parse_int(values[0]),
"error": _parse_int(values[1]),
"ball_speed": _parse_int(values[2]),
"playlist": playlist,
"playlist_index": min(_parse_int(values[4]), len(playlist)),
"progress": _parse_int(values[5]),
"led_effect": values[6],
"led_color_id": values[7],
"led_speed": _parse_int(values[8]),
"brightness": _parse_int(values[9]),
"color": values[10] if "#" in values[10] else None,
"busy": _bit_to_bool(values[11]),
"download_progress": _parse_int(values[12]),
"brightness_max": _parse_int(values[13]),
"wifi_connected": _bit_to_bool(values[14]),
"repeat_playlist": _bit_to_bool(values[15]),
"autoplay": _parse_int(values[16]),
"auto_clean": _bit_to_bool(values[17]),
}
# Optional trailing field(s)
if n > 18:
status["software_version"] = values[18]
except Exception: # noqa: BLE001
_LOGGER.exception(
"Error parsing status string for %s: %r", self.serial_number, raw_status
)
return None
return status
def update_from_status_string(self, raw_status: str) -> None:
"""Parse and apply a raw status string."""
if status := self.parse_status_string(raw_status):
self.update_from_status_dict(status)
def as_dict(self) -> dict[str, Any]:
"""Return core state as a dict."""
return {field: getattr(self, field) for field in _STATE_FIELDS}
@@ -158,6 +255,13 @@ class OasisDevice:
"""Return human-readable status from status_code."""
return STATUS_CODE_MAP.get(self.status_code, f"Unknown ({self.status_code})")
@property
def track(self) -> dict | None:
"""Return cached track info if it matches the current `track_id`."""
if (track := self._track) and track["id"] == self.track_id:
return track
return TRACKS.get(self.track_id)
@property
def track_id(self) -> int | None:
if not self.playlist:
@@ -166,37 +270,45 @@ class OasisDevice:
return self.playlist[0] if i >= len(self.playlist) else self.playlist[i]
@property
def track(self) -> dict | None:
"""Return cached track info if it matches the current `track_id`."""
if self._track and self._track.get("id") == self.track_id:
return self._track
if track := TRACKS.get(self.track_id):
self._track = track
return self._track
def track_image_url(self) -> str | None:
"""Return the track image url, if any."""
if (track := self.track) and (image := track.get("image")):
return f"https://app.grounded.so/uploads/{image}"
return None
@property
def track_name(self) -> str | None:
"""Return the track name, if any."""
if track := self.track:
return track.get("name", f"Unknown Title (#{self.track_id})")
return None
@property
def drawing_progress(self) -> float | None:
"""Return drawing progress percentage for the current track."""
# if not (self.track and (svg_content := self.track.get("svg_content"))):
# return None
# svg_content = decrypt_svg_content(svg_content)
# paths = svg_content.split("L")
total = self.track.get("reduced_svg_content_new", 0) # or len(paths)
if not (self.track and (svg_content := self.track.get("svg_content"))):
return None
svg_content = decrypt_svg_content(svg_content)
paths = svg_content.split("L")
total = self.track.get("reduced_svg_content_new", 0) or len(paths)
percent = (100 * self.progress) / total
return percent
return min(percent, 100)
@property
def playlist_details(self) -> dict[int, dict[str, str]]:
"""Basic playlist details using built-in TRACKS metadata."""
return {
track_id: TRACKS.get(
track_id: {self.track_id: self.track or {}, **TRACKS}.get(
track_id,
{"name": f"Unknown Title (#{track_id})"},
)
for track_id in self.playlist
}
def create_svg(self) -> str | None:
"""Create the current svg based on track and progress."""
return create_svg(self.track, self.progress)
def add_update_listener(self, listener: Callable[[], None]) -> Callable[[], None]:
"""Register a callback for state changes.
@@ -245,7 +357,7 @@ class OasisDevice:
led_speed: int | None = None,
brightness: int | None = None,
) -> None:
"""Set the Oasis Mini LED (shared validation & attribute updates)."""
"""Set the Oasis device LED (shared validation & attribute updates)."""
if led_effect is None:
led_effect = self.led_effect
if color is None:
@@ -259,7 +371,7 @@ class OasisDevice:
raise ValueError("Invalid led effect specified")
if not LED_SPEED_MIN <= led_speed <= LED_SPEED_MAX:
raise ValueError("Invalid led speed specified")
if not 0 <= brightness <= self.max_brightness:
if not 0 <= brightness <= self.brightness_max:
raise ValueError("Invalid brightness specified")
client = self._require_client()
@@ -325,3 +437,43 @@ class OasisDevice:
async def async_reboot(self) -> None:
client = self._require_client()
await client.async_send_reboot_command(self)
def _schedule_track_refresh(self) -> None:
"""Schedule an async refresh of current track info if track_id changed."""
if not self._cloud:
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
_LOGGER.debug("No running loop; cannot schedule track refresh")
return
if self._track_task and not self._track_task.done():
self._track_task.cancel()
self._track_task = loop.create_task(self._async_refresh_current_track())
async def _async_refresh_current_track(self) -> None:
"""Refresh the current track info."""
if not self._cloud:
return
if (track_id := self.track_id) is None:
self._track = None
return
if self._track and self._track.get("id") == track_id:
return
try:
track = await self._cloud.async_get_track_info(track_id)
except Exception: # noqa: BLE001
_LOGGER.exception("Error fetching track info for %s", track_id)
return
if not track:
return
self._track = track
self._notify_listeners()

View File

@@ -1,4 +1,4 @@
"""Oasis Mini utils."""
"""Oasis control utils."""
from __future__ import annotations
@@ -35,8 +35,8 @@ def _parse_int(val: str) -> int:
return 0
def draw_svg(track: dict, progress: int, model_id: str) -> str | None:
"""Draw SVG."""
def create_svg(track: dict, progress: int) -> str | None:
"""Create an SVG from a track based on progress."""
if track and (svg_content := track.get("svg_content")):
try:
if progress is not None:

View File

@@ -17,6 +17,8 @@ from .entity import OasisDeviceEntity
from .pyoasiscontrol import OasisDevice
from .pyoasiscontrol.const import AUTOPLAY_MAP, TRACKS
AUTOPLAY_MAP_LIST = list(AUTOPLAY_MAP)
def playlists_update_handler(entity: OasisDeviceSelectEntity) -> None:
"""Handle playlists updates."""
@@ -25,7 +27,7 @@ def playlists_update_handler(entity: OasisDeviceSelectEntity) -> None:
counts = defaultdict(int)
options = []
current_option: str | None = None
for playlist in device.playlists:
for playlist in device._cloud.playlists:
name = playlist["name"]
counts[name] += 1
if counts[name] > 1:
@@ -69,18 +71,11 @@ async def async_setup_entry(
) -> None:
"""Set up Oasis device select using config entry."""
coordinator: OasisDeviceCoordinator = entry.runtime_data
entities = [
async_add_entities(
OasisDeviceSelectEntity(coordinator, device, descriptor)
for device in coordinator.data
for descriptor in DESCRIPTORS
]
# if coordinator.device.access_token:
# entities.extend(
# OasisDeviceSelectEntity(coordinator, device, descriptor)
# for device in coordinator.data
# for descriptor in CLOUD_DESCRIPTORS
# )
async_add_entities(entities)
)
@dataclass(frozen=True, kw_only=True)
@@ -96,29 +91,29 @@ DESCRIPTORS = (
OasisDeviceSelectEntityDescription(
key="autoplay",
translation_key="autoplay",
options=list(AUTOPLAY_MAP.values()),
current_value=lambda device: device.autoplay,
select_fn=lambda device, option: device.async_set_autoplay(option),
options=AUTOPLAY_MAP_LIST,
current_value=lambda device: str(device.autoplay),
select_fn=lambda device, index: (
device.async_set_autoplay(AUTOPLAY_MAP_LIST[index])
),
),
OasisDeviceSelectEntityDescription(
key="playlists",
translation_key="playlist",
current_value=lambda device: (device._cloud.playlists, device.playlist.copy()),
select_fn=lambda device, index: device.async_set_playlist(
[pattern["id"] for pattern in device._cloud.playlists[index]["patterns"]]
),
update_handler=playlists_update_handler,
),
OasisDeviceSelectEntityDescription(
key="queue",
translation_key="queue",
current_value=lambda device: (device.playlist.copy(), device.playlist_index),
select_fn=lambda device, option: device.async_change_track(option),
select_fn=lambda device, index: device.async_change_track(index),
update_handler=queue_update_handler,
),
)
CLOUD_DESCRIPTORS = (
OasisDeviceSelectEntityDescription(
key="playlists",
translation_key="playlist",
current_value=lambda device: (device.playlists, device.playlist.copy()),
select_fn=lambda device, option: device.async_set_playlist(
[pattern["id"] for pattern in device.playlists[option]["patterns"]]
),
update_handler=playlists_update_handler,
),
)
class OasisDeviceSelectEntity(OasisDeviceEntity, SelectEntity):
@@ -140,7 +135,6 @@ class OasisDeviceSelectEntity(OasisDeviceEntity, SelectEntity):
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
await self.entity_description.select_fn(self.device, self.options.index(option))
await self.coordinator.async_request_refresh()
@callback
def _handle_coordinator_update(self) -> None:
@@ -152,8 +146,8 @@ class OasisDeviceSelectEntity(OasisDeviceEntity, SelectEntity):
if update_handler := self.entity_description.update_handler:
update_handler(self)
else:
self._attr_current_option = getattr(
self.device, self.entity_description.key
self._attr_current_option = str(
getattr(self.device, self.entity_description.key)
)
if self.hass:
return super()._handle_coordinator_update()

View File

@@ -23,17 +23,11 @@ async def async_setup_entry(
) -> None:
"""Set up Oasis device sensors using config entry."""
coordinator: OasisDeviceCoordinator = entry.runtime_data
entities = [
async_add_entities(
OasisDeviceSensorEntity(coordinator, device, descriptor)
for device in coordinator.data
for descriptor in DESCRIPTORS
]
entities.extend(
OasisDeviceSensorEntity(coordinator, device, descriptor)
for device in coordinator.data
for descriptor in CLOUD_DESCRIPTORS
)
async_add_entities(entities)
DESCRIPTORS = {
@@ -45,19 +39,6 @@ DESCRIPTORS = {
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
} | {
SensorEntityDescription(
key=key,
translation_key=key,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
)
for key in ("error", "status")
# for key in ("error", "led_color_id", "status")
# for key in ("error_message", "led_color_id", "status")
}
CLOUD_DESCRIPTORS = (
SensorEntityDescription(
key="drawing_progress",
translation_key="drawing_progress",
@@ -66,7 +47,16 @@ CLOUD_DESCRIPTORS = (
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
),
} | {
SensorEntityDescription(
key=key,
translation_key=key,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
)
for key in ("error", "led_color_id", "status")
# for key in ("error_message", "led_color_id", "status")
}
class OasisDeviceSensorEntity(OasisDeviceEntity, SensorEntity):

View File

@@ -74,7 +74,18 @@
},
"select": {
"autoplay": {
"name": "Autoplay"
"name": "Autoplay",
"state": {
"0": "on",
"1": "off",
"2": "5 minutes",
"3": "10 minutes",
"4": "30 minutes",
"6": "1 hour",
"7": "6 hours",
"8": "12 hours",
"5": "24 hours"
}
},
"playlist": {
"name": "Playlist"

View File

@@ -74,7 +74,18 @@
},
"select": {
"autoplay": {
"name": "Autoplay"
"name": "Autoplay",
"state": {
"0": "on",
"1": "off",
"2": "5 minutes",
"3": "10 minutes",
"4": "30 minutes",
"6": "1 hour",
"7": "6 hours",
"8": "12 hours",
"5": "24 hours"
}
},
"playlist": {
"name": "Playlist"