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

1 Commits

Author SHA1 Message Date
Nathan Spencer
379b6f67f2 Swap out direct HTTP connection with server MQTT connection to handle firmware 2.60+ (#98)
* Switch to using mqtt

* Better mqtt handling when connection is interrupted

* Get track info from the cloud when playlist or index changes

* Add additional helpers

* Dynamically handle devices and other enhancements

* 📝 Add docstrings to `mqtt`

Docstrings generation was requested by @natekspencer.

* https://github.com/natekspencer/hacs-oasis_mini/pull/98#issuecomment-3568450288

The following files were modified:

* `custom_components/oasis_mini/__init__.py`
* `custom_components/oasis_mini/binary_sensor.py`
* `custom_components/oasis_mini/button.py`
* `custom_components/oasis_mini/config_flow.py`
* `custom_components/oasis_mini/coordinator.py`
* `custom_components/oasis_mini/entity.py`
* `custom_components/oasis_mini/helpers.py`
* `custom_components/oasis_mini/image.py`
* `custom_components/oasis_mini/light.py`
* `custom_components/oasis_mini/media_player.py`
* `custom_components/oasis_mini/number.py`
* `custom_components/oasis_mini/pyoasiscontrol/clients/cloud_client.py`
* `custom_components/oasis_mini/pyoasiscontrol/clients/http_client.py`
* `custom_components/oasis_mini/pyoasiscontrol/clients/mqtt_client.py`
* `custom_components/oasis_mini/pyoasiscontrol/clients/transport.py`
* `custom_components/oasis_mini/pyoasiscontrol/device.py`
* `custom_components/oasis_mini/pyoasiscontrol/utils.py`
* `custom_components/oasis_mini/select.py`
* `custom_components/oasis_mini/sensor.py`
* `custom_components/oasis_mini/switch.py`
* `custom_components/oasis_mini/update.py`
* `update_tracks.py`

* Fix formatting in transport.py

* Replace tabs with spaces

* Use tuples instead of sets for descriptors

* Encode svg in image entity

* Fix iot_class

* Fix tracks list url

* Ensure update_tracks closes the connection

* Fix number typing and docstring

* Fix docstring in update_tracks

* Cache playlist based on type

* Fix formatting in device.py

* Add missing async_send_auto_clean_command to http client

* Propagate UnauthenticatedError from async_get_track_info

* Adjust exceptions

* Move create_client outside of try block in config_flow

* Formatting

* Address PR comments

* Formatting

* Add noqa: ARG001 on unused hass

* Close cloud/MQTT clients if initial coordinator refresh fails.

* Address PR again

* PR fixes

* Pass config entry to coordinator

* Remove async_timeout (thanks ChatGPT... not)

* Address PR

* Replace magic numbers for status code

* Update autoplay wording/ordering

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-11-24 01:09:23 -07:00
8 changed files with 82 additions and 60 deletions

View File

@@ -2,12 +2,11 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from datetime import datetime, timedelta from datetime import datetime, timedelta
import logging import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import async_timeout
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -68,7 +67,7 @@ class OasisDeviceCoordinator(DataUpdateCoordinator[list[OasisDevice]]):
self.attempt += 1 self.attempt += 1
try: try:
async with async_timeout.timeout(30): async with asyncio.timeout(30):
raw_devices = await self.cloud_client.async_get_devices() raw_devices = await self.cloud_client.async_get_devices()
existing_by_serial = { existing_by_serial = {

View File

@@ -6,14 +6,12 @@ import asyncio
import logging import logging
from typing import Any from typing import Any
import async_timeout
from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .pyoasiscontrol import OasisCloudClient, OasisDevice from .pyoasiscontrol import OasisCloudClient, OasisDevice
from .pyoasiscontrol.const import TRACKS from .pyoasiscontrol.const import STATUS_PLAYING, TRACKS
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -44,24 +42,26 @@ async def add_and_play_track(device: OasisDevice, track: int) -> None:
track (int): The track id to add and play. track (int): The track id to add and play.
Raises: Raises:
async_timeout.TimeoutError: If the operation does not complete within 10 seconds. TimeoutError: If the operation does not complete within 10 seconds.
""" """
async with async_timeout.timeout(10): async with asyncio.timeout(10):
if track not in device.playlist: if track not in device.playlist:
await device.async_add_track_to_playlist(track) await device.async_add_track_to_playlist(track)
# Wait for device state to reflect the newly added track
while track not in device.playlist: while track not in device.playlist:
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
# Move track to next item in the playlist and then select it # Ensure the track is positioned immediately after the current track and select it
if (index := device.playlist.index(track)) != device.playlist_index: if (index := device.playlist.index(track)) != device.playlist_index:
# Calculate the position after the current track
if index != ( if index != (
_next := min(device.playlist_index + 1, len(device.playlist) - 1) _next := min(device.playlist_index + 1, len(device.playlist) - 1)
): ):
await device.async_move_track(index, _next) await device.async_move_track(index, _next)
await device.async_change_track(_next) await device.async_change_track(_next)
if device.status_code != 4: if device.status_code != STATUS_PLAYING:
await device.async_play() await device.async_play()

View File

@@ -23,6 +23,16 @@ from .const import DOMAIN
from .entity import OasisDeviceEntity from .entity import OasisDeviceEntity
from .helpers import get_track_id from .helpers import get_track_id
from .pyoasiscontrol import OasisDevice from .pyoasiscontrol import OasisDevice
from .pyoasiscontrol.const import (
STATUS_CENTERING,
STATUS_DOWNLOADING,
STATUS_ERROR,
STATUS_LIVE,
STATUS_PAUSED,
STATUS_PLAYING,
STATUS_STOPPED,
STATUS_UPDATING,
)
async def async_setup_entry( async def async_setup_entry(
@@ -130,17 +140,17 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, 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 in (9, 11): if self.device.error or status_code in (STATUS_ERROR, STATUS_UPDATING):
return MediaPlayerState.OFF return MediaPlayerState.OFF
if status_code == 2: if status_code == STATUS_STOPPED:
return MediaPlayerState.IDLE return MediaPlayerState.IDLE
if status_code in (3, 13): if status_code in (STATUS_CENTERING, STATUS_DOWNLOADING):
return MediaPlayerState.BUFFERING return MediaPlayerState.BUFFERING
if status_code == 4: if status_code == STATUS_PLAYING:
return MediaPlayerState.PLAYING return MediaPlayerState.PLAYING
if status_code == 5: if status_code == STATUS_PAUSED:
return MediaPlayerState.PAUSED return MediaPlayerState.PAUSED
if status_code == 15: if status_code == STATUS_LIVE:
return MediaPlayerState.ON return MediaPlayerState.ON
return MediaPlayerState.IDLE return MediaPlayerState.IDLE

View File

@@ -12,19 +12,19 @@ try:
TRACKS: Final[dict[int, dict[str, Any]]] = { TRACKS: Final[dict[int, dict[str, Any]]] = {
int(k): v for k, v in json.load(file).items() int(k): v for k, v in json.load(file).items()
} }
except Exception: # ignore: broad-except except (FileNotFoundError, json.JSONDecodeError, OSError):
TRACKS = {} TRACKS = {}
AUTOPLAY_MAP: Final[dict[str, str]] = { AUTOPLAY_MAP: Final[dict[str, str]] = {
"0": "on", "1": "Off", # display off (disabled) first
"1": "off", "0": "Immediately",
"2": "5 minutes", "2": "After 5 minutes",
"3": "10 minutes", "3": "After 10 minutes",
"4": "30 minutes", "4": "After 30 minutes",
"6": "1 hour", "6": "After 1 hour",
"7": "6 hours", "7": "After 6 hours",
"8": "12 hours", "8": "After 12 hours",
"5": "24 hours", "5": "After 24 hours", # purposefully placed so time is incrementally displayed
} }
ERROR_CODE_MAP: Final[dict[int, str]] = { ERROR_CODE_MAP: Final[dict[int, str]] = {
@@ -94,17 +94,28 @@ LED_EFFECTS: Final[dict[str, str]] = {
"41": "Color Comets", "41": "Color Comets",
} }
STATUS_CODE_SLEEPING: Final = 6 STATUS_BOOTING: Final[int] = 0
STATUS_STOPPED: Final[int] = 2
STATUS_CENTERING: Final[int] = 3
STATUS_PLAYING: Final[int] = 4
STATUS_PAUSED: Final[int] = 5
STATUS_SLEEPING: Final[int] = 6
STATUS_ERROR: Final[int] = 9
STATUS_UPDATING: Final[int] = 11
STATUS_DOWNLOADING: Final[int] = 13
STATUS_BUSY: Final[int] = 14
STATUS_LIVE: Final[int] = 15
STATUS_CODE_MAP: Final[dict[int, str]] = { STATUS_CODE_MAP: Final[dict[int, str]] = {
0: "booting", STATUS_BOOTING: "booting",
2: "stopped", STATUS_STOPPED: "stopped",
3: "centering", STATUS_CENTERING: "centering",
4: "playing", STATUS_PLAYING: "playing",
5: "paused", STATUS_PAUSED: "paused",
STATUS_CODE_SLEEPING: "sleeping", STATUS_SLEEPING: "sleeping",
9: "error", STATUS_ERROR: "error",
11: "updating", STATUS_UPDATING: "updating",
13: "downloading", STATUS_DOWNLOADING: "downloading",
14: "busy", STATUS_BUSY: "busy",
15: "live", STATUS_LIVE: "live",
} }

View File

@@ -10,7 +10,8 @@ from .const import (
ERROR_CODE_MAP, ERROR_CODE_MAP,
LED_EFFECTS, LED_EFFECTS,
STATUS_CODE_MAP, STATUS_CODE_MAP,
STATUS_CODE_SLEEPING, STATUS_ERROR,
STATUS_SLEEPING,
TRACKS, TRACKS,
) )
from .utils import _bit_to_bool, _parse_int, create_svg, decrypt_svg_content from .utils import _bit_to_bool, _parse_int, create_svg, decrypt_svg_content
@@ -163,7 +164,7 @@ class OasisDevice:
Returns: Returns:
`true` if the device is sleeping, `false` otherwise. `true` if the device is sleeping, `false` otherwise.
""" """
return self.status_code == STATUS_CODE_SLEEPING return self.status_code == STATUS_SLEEPING
def attach_client(self, client: OasisClientProtocol) -> None: def attach_client(self, client: OasisClientProtocol) -> None:
"""Attach a transport client (MQTT, HTTP, etc.) to this device.""" """Attach a transport client (MQTT, HTTP, etc.) to this device."""
@@ -343,7 +344,7 @@ class OasisDevice:
Returns: Returns:
str: The mapped error message when the device status indicates an error (status code 9); `None` otherwise. str: The mapped error message when the device status indicates an error (status code 9); `None` otherwise.
""" """
if self.status_code == 9: if self.status_code == STATUS_ERROR:
return ERROR_CODE_MAP.get(self.error, f"Unknown ({self.error})") return ERROR_CODE_MAP.get(self.error, f"Unknown ({self.error})")
return None return None

View File

@@ -76,15 +76,15 @@
"autoplay": { "autoplay": {
"name": "Autoplay", "name": "Autoplay",
"state": { "state": {
"0": "on", "1": "Off",
"1": "off", "0": "Immediately",
"2": "5 minutes", "2": "After 5 minutes",
"3": "10 minutes", "3": "After 10 minutes",
"4": "30 minutes", "4": "After 30 minutes",
"6": "1 hour", "6": "After 1 hour",
"7": "6 hours", "7": "After 6 hours",
"8": "12 hours", "8": "After 12 hours",
"5": "24 hours" "5": "After 24 hours"
} }
}, },
"playlist": { "playlist": {

View File

@@ -76,15 +76,15 @@
"autoplay": { "autoplay": {
"name": "Autoplay", "name": "Autoplay",
"state": { "state": {
"0": "on", "1": "Off",
"1": "off", "0": "Immediately",
"2": "5 minutes", "2": "After 5 minutes",
"3": "10 minutes", "3": "After 10 minutes",
"4": "30 minutes", "4": "After 30 minutes",
"6": "1 hour", "6": "After 1 hour",
"7": "6 hours", "7": "After 6 hours",
"8": "12 hours", "8": "After 12 hours",
"5": "24 hours" "5": "After 24 hours"
} }
}, },
"playlist": { "playlist": {

View File

@@ -18,6 +18,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import OasisDeviceConfigEntry, setup_platform_from_coordinator from . import OasisDeviceConfigEntry, setup_platform_from_coordinator
from .entity import OasisDeviceEntity from .entity import OasisDeviceEntity
from .pyoasiscontrol import OasisDevice from .pyoasiscontrol import OasisDevice
from .pyoasiscontrol.const import STATUS_UPDATING
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -71,7 +72,7 @@ class OasisDeviceUpdateEntity(OasisDeviceEntity, UpdateEntity):
@property @property
def in_progress(self) -> bool | int: def in_progress(self) -> bool | int:
"""Update installation progress.""" """Update installation progress."""
if self.device.status_code == 11: if self.device.status_code == STATUS_UPDATING:
return self.device.download_progress return self.device.download_progress
return False return False