1
0
mirror of https://github.com/natekspencer/hacs-oasis_mini.git synced 2025-12-07 02:54:12 -05:00

13 Commits

Author SHA1 Message Date
Nathan Spencer
5dc49b6a68 Merge pull request #106 from natekspencer/media-player-enhancements
Enhance media player entity with browse/search capability
2025-11-25 12:27:48 -07:00
Nathan Spencer
e1599b7c47 Address PR review 2025-11-25 19:08:02 +00:00
Nathan Spencer
c1754ad959 Enhance media_player with browse/search capability 2025-11-25 18:33:32 +00:00
Nathan Spencer
8abd20a4ff Merge pull request #105 from natekspencer/issue-templates
* **Chores**
  * Added structured issue templates for bug reports and feature requests to standardize information collection.
  * Disabled blank issue creation to encourage use of proper templates.
2025-11-25 10:10:39 -07:00
Nathan Spencer
cad03269ef Add issue templates 2025-11-25 17:03:01 +00:00
Nathan Spencer
a06c2b41b6 Merge pull request #104 from natekspencer/dispatcher
* **Performance Improvements**
  * Devices are integrated only after full initialization for more reliable discovery and faster setup.
  * Reduced unnecessary status requests for sleeping/inactive devices to conserve bandwidth and improve efficiency.
  * Improved real-time tracking so device state changes are reflected more quickly.

* **New Features**
  * Newly initialized devices are added dynamically as they come online, improving responsiveness to device additions.
2025-11-25 09:49:09 -07:00
Nathan Spencer
a3d58017b4 Address nitpick comments 2025-11-25 16:40:41 +00:00
Nathan Spencer
eecf5e90dc Don't wait on devices to initialize during coordinator update, implement dispatcher for device initialization/setup 2025-11-25 16:29:36 +00:00
Nathan Spencer
d9fa3b8c9e Merge pull request #102 from natekspencer/device-initialized
Add helper to check for device initialization instead of first status
2025-11-24 13:55:28 -07:00
Nathan Spencer
e4ccee0698 Update coordinator to not fail if no devices have been initialized 2025-11-24 20:48:46 +00:00
Nathan Spencer
e6e84f8984 Add helper to check for device initializtion instead of first status 2025-11-24 20:31:57 +00:00
Nathan Spencer
009cd8cde3 Fix missing dependency for update tracks action (#101) 2025-11-24 12:17:53 -07:00
Nathan Spencer
a3ea4dc05a Add convenience properties and more logging to mqtt client, better mqtt management via coordinator (#100)
* Add convenience properties and more logging to mqtt client, better mqtt management via coordinator

* Address PR comments

* Address PR comments

* Fix
2025-11-24 11:54:04 -07:00
14 changed files with 782 additions and 163 deletions

71
.github/ISSUE_TEMPLATE/bug.yml vendored Normal file
View File

@@ -0,0 +1,71 @@
---
name: "Bug report"
description: "Report a bug with the custom integration"
labels: ["bug"]
body:
- type: markdown
attributes:
value: Before you open a new issue, search through the existing issues (open and closed) to see if others have had the same problem.
- type: input
attributes:
label: "Home Assistant version"
description: "The version of Home Assistant you are using"
placeholder: "2025.11.0"
validations:
required: true
- type: input
attributes:
label: "Integration version"
description: "The version of this custom integration you are using. If you are not running the [latest version](https://github.com/natekspencer/hacs-oasis_mini/releases/latest), stop, update, and then continue if the issue persists. Issues not pertaining to the latest release will be closed."
placeholder: "2.0.0"
validations:
required: true
- type: textarea
attributes:
label: "System Health details"
description: "Paste the data from the System Health card in Home Assistant (https://www.home-assistant.io/more-info/system-health#github-issues)"
validations:
required: false
- type: checkboxes
attributes:
label: Checklist
options:
- label: I have enabled debug logging for my installation.
required: true
- label: I have filled out the issue template to the best of my ability.
required: true
- label: This issue only contains 1 issue (if you have multiple issues, open one issue for each issue).
required: true
- label: This issue is not a duplicate issue of any [previous issues](https://github.com/natekspencer/hacs-oasis_mini/issues?q=is%3Aissue+).
required: true
- type: textarea
attributes:
label: "Describe the issue"
description: "A clear and concise description of what the issue is."
validations:
required: true
- type: textarea
attributes:
label: Reproduction steps
description: "Without steps to reproduce, it will be hard to fix. It is very important that you fill out this part. Issues without it will be closed."
value: |
1.
2.
3.
...
validations:
required: true
- type: textarea
attributes:
label: "Debug logs"
description: "To enable debug logs check this https://www.home-assistant.io/integrations/logger/, this **needs** to include _everything_ from startup of Home Assistant to the point where you encounter the issue."
render: text
validations:
required: true
- type: textarea
attributes:
label: "Diagnostics dump"
description: "Drag the diagnostics dump file here. (see https://www.home-assistant.io/integrations/diagnostics/ for info)"
validations:
required: false

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1 @@
blank_issues_enabled: false

View File

@@ -0,0 +1,47 @@
---
name: "Feature request"
description: "Suggest an idea for this custom integration"
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: Before you open a new feature request, search through the existing feature requests to see if others have had the same idea.
- type: checkboxes
attributes:
label: Checklist
options:
- label: I have filled out the template to the best of my ability.
required: true
- label: This only contains 1 feature request (if you have multiple feature requests, open one feature request for each feature request).
required: true
- label: This issue is not a duplicate feature request of [previous feature requests](https://github.com/natekspencer/hacs-oasis_mini/issues?q=is%3Aissue+label%3A%22enhancement%22+).
required: true
- type: textarea
attributes:
label: "Is your feature request related to a problem? Please describe."
description: "A clear and concise description of what the problem is."
placeholder: "I'm always frustrated when [...]"
validations:
required: true
- type: textarea
attributes:
label: "Describe the solution you'd like"
description: "A clear and concise description of what you want to happen."
validations:
required: true
- type: textarea
attributes:
label: "Describe alternatives you've considered"
description: "A clear and concise description of any alternative solutions or features you've considered."
validations:
required: true
- type: textarea
attributes:
label: "Additional context"
description: "Add any other context or screenshots about the feature request here."
validations:
required: true

View File

@@ -23,7 +23,7 @@ jobs:
python-version: "3.13" python-version: "3.13"
- name: Install dependencies - name: Install dependencies
run: pip install homeassistant run: pip install homeassistant aiomqtt
- name: Update tracks - name: Update tracks
env: env:

View File

@@ -10,15 +10,15 @@ from homeassistant.const import CONF_EMAIL, Platform
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
import homeassistant.helpers.entity_registry as er import homeassistant.helpers.entity_registry as er
import homeassistant.util.dt as dt_util
from .const import DOMAIN from .const import DOMAIN
from .coordinator import OasisDeviceCoordinator from .coordinator import OasisDeviceCoordinator
from .entity import OasisDeviceEntity from .entity import OasisDeviceEntity
from .helpers import create_client from .helpers import create_client
from .pyoasiscontrol import OasisDevice, OasisMqttClient, UnauthenticatedError from .pyoasiscontrol import OasisDevice, UnauthenticatedError
type OasisDeviceConfigEntry = ConfigEntry[OasisDeviceCoordinator] type OasisDeviceConfigEntry = ConfigEntry[OasisDeviceCoordinator]
@@ -56,25 +56,18 @@ def setup_platform_from_coordinator(
update_before_add: If true, entities will be updated before being added. update_before_add: If true, entities will be updated before being added.
""" """
coordinator = entry.runtime_data coordinator = entry.runtime_data
known_serials: set[str] = set() known_serials: set[str] = set()
signal = coordinator._device_initialized_signal
@callback @callback
def _check_devices() -> None: def _check_devices() -> None:
""" """Add entities for any initialized devices not yet seen."""
Detect newly discovered Oasis devices from the coordinator and register their entities.
Scans the coordinator's current device list for devices with a serial number that has not
been seen before. For any newly discovered devices, creates entity instances via
make_entities and adds them to Home Assistant using async_add_entities with the
update_before_add flag. Does not return a value.
"""
devices = coordinator.data or [] devices = coordinator.data or []
new_devices: list[OasisDevice] = [] new_devices: list[OasisDevice] = []
for device in devices: for device in devices:
serial = device.serial_number serial = device.serial_number
if not serial or serial in known_serials: if not device.is_initialized or not serial or serial in known_serials:
continue continue
known_serials.add(serial) known_serials.add(serial)
@@ -86,15 +79,39 @@ def setup_platform_from_coordinator(
if entities := make_entities(new_devices): if entities := make_entities(new_devices):
async_add_entities(entities, update_before_add) async_add_entities(entities, update_before_add)
# Initial population @callback
def _handle_device_initialized(device: OasisDevice) -> None:
"""
Dispatcher callback for when a single device becomes initialized.
Adds entities immediately for that device if we haven't seen it yet.
"""
serial = device.serial_number
if not serial or serial in known_serials or not device.is_initialized:
return
known_serials.add(serial)
if entities := make_entities([device]):
async_add_entities(entities, update_before_add)
# Initial population from current coordinator data
_check_devices() _check_devices()
# Future updates (new devices discovered)
# Future changes: new devices / account re-sync via coordinator
entry.async_on_unload(coordinator.async_add_listener(_check_devices)) entry.async_on_unload(coordinator.async_add_listener(_check_devices))
# Device-level initialization events via dispatcher
entry.async_on_unload(
async_dispatcher_connect(coordinator.hass, signal, _handle_device_initialized)
)
async def async_setup_entry(hass: HomeAssistant, entry: OasisDeviceConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: OasisDeviceConfigEntry) -> bool:
""" """
Initialize Oasis cloud and MQTT integration for a config entry, create and refresh the device coordinator, register update listeners for discovered devices, forward platform setup, and update the entry's metadata as needed. Initialize Oasis cloud for a config entry, create and refresh the device
coordinator, register update listeners for discovered devices, forward platform
setup, and update the entry's metadata as needed.
Returns: Returns:
True if the config entry was set up successfully. True if the config entry was set up successfully.
@@ -109,15 +126,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: OasisDeviceConfigEntry)
await cloud_client.async_close() await cloud_client.async_close()
raise raise
mqtt_client = OasisMqttClient() coordinator = OasisDeviceCoordinator(hass, entry, cloud_client)
coordinator = OasisDeviceCoordinator(hass, entry, cloud_client, mqtt_client)
try: try:
mqtt_client.start()
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()
except Exception: except Exception:
await mqtt_client.async_close() await coordinator.async_close()
await cloud_client.async_close()
raise raise
if entry.unique_id != (user_id := str(user["id"])): if entry.unique_id != (user_id := str(user["id"])):
@@ -128,18 +142,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: OasisDeviceConfigEntry)
entry.runtime_data = coordinator entry.runtime_data = coordinator
def _on_oasis_update() -> None:
"""
Update the coordinator's last-updated timestamp and notify its listeners.
Sets the coordinator's last_updated to the current time and triggers its update listeners so dependent entities and tasks refresh.
"""
coordinator.last_updated = dt_util.now()
coordinator.async_update_listeners()
for device in coordinator.data or []:
device.add_update_listener(_on_oasis_update)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True return True
@@ -151,18 +153,17 @@ async def async_unload_entry(
""" """
Cleanly unload an Oasis device config entry. Cleanly unload an Oasis device config entry.
Closes the MQTT and cloud clients stored on the entry and unloads all supported platforms. Unloads all supported platforms and closes the coordinator connections.
Returns: Returns:
`True` if all platforms were unloaded successfully, `False` otherwise. `True` if all platforms were unloaded successfully, `False` otherwise.
""" """
mqtt_client = entry.runtime_data.mqtt_client unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
await mqtt_client.async_close() try:
await entry.runtime_data.async_close()
cloud_client = entry.runtime_data.cloud_client except Exception:
await cloud_client.async_close() _LOGGER.exception("Error closing Oasis coordinator during unload")
return unload_ok
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_remove_entry( async def async_remove_entry(

View File

@@ -0,0 +1,259 @@
"""Support for media browsing/searching."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.media_player import (
BrowseError,
BrowseMedia,
MediaClass,
MediaType,
SearchError,
SearchMedia,
SearchMediaQuery,
)
from .pyoasiscontrol import OasisCloudClient
from .pyoasiscontrol.const import TRACKS
from .pyoasiscontrol.utils import get_track_ids_from_playlist, get_url_for_image
_LOGGER = logging.getLogger(__name__)
MEDIA_TYPE_OASIS_ROOT = "oasis_library"
MEDIA_TYPE_OASIS_PLAYLISTS = "oasis_playlists"
MEDIA_TYPE_OASIS_PLAYLIST = MediaType.PLAYLIST
MEDIA_TYPE_OASIS_TRACKS = "oasis_tracks"
MEDIA_TYPE_OASIS_TRACK = MediaType.TRACK
async def build_root_response() -> BrowseMedia:
"""Top-level library node that exposes Tracks and Playlists."""
children = [
BrowseMedia(
title="Playlists",
media_class=MediaClass.DIRECTORY,
media_content_id="playlists_root",
media_content_type=MEDIA_TYPE_OASIS_PLAYLISTS,
can_play=False,
can_expand=True,
children_media_class=MediaClass.PLAYLIST,
),
BrowseMedia(
title="Tracks",
media_class=MediaClass.DIRECTORY,
media_content_id="tracks_root",
media_content_type=MEDIA_TYPE_OASIS_TRACKS,
can_play=False,
can_expand=True,
children_media_class=MediaClass.IMAGE,
),
]
return BrowseMedia(
title="Oasis Library",
media_class=MediaClass.DIRECTORY,
media_content_id="oasis_root",
media_content_type=MEDIA_TYPE_OASIS_ROOT,
can_play=False,
can_expand=True,
children=children,
children_media_class=MediaClass.DIRECTORY,
)
async def build_playlists_root(cloud: OasisCloudClient) -> BrowseMedia:
"""Build the 'Playlists' directory from the cloud playlists cache."""
playlists = await cloud.async_get_playlists(personal_only=False)
children = [
BrowseMedia(
title=playlist.get("name") or f"Playlist #{playlist['id']}",
media_class=MediaClass.PLAYLIST,
media_content_id=str(playlist["id"]),
media_content_type=MEDIA_TYPE_OASIS_PLAYLIST,
can_play=True,
can_expand=True,
thumbnail=get_first_image_for_playlist(playlist),
)
for playlist in playlists
if "id" in playlist
]
return BrowseMedia(
title="Playlists",
media_class=MediaClass.DIRECTORY,
media_content_id="playlists_root",
media_content_type=MEDIA_TYPE_OASIS_PLAYLISTS,
can_play=False,
can_expand=True,
children=children,
children_media_class=MediaClass.PLAYLIST,
)
async def build_playlist_item(cloud: OasisCloudClient, playlist_id: int) -> BrowseMedia:
"""Build a single playlist node including its track children."""
playlists = await cloud.async_get_playlists(personal_only=False)
playlist = next((p for p in playlists if p.get("id") == playlist_id), None)
if not playlist:
raise BrowseError(f"Unknown playlist id: {playlist_id}")
title = playlist.get("name") or f"Playlist #{playlist_id}"
track_ids = get_track_ids_from_playlist(playlist)
children = [build_track_item(track_id) for track_id in track_ids]
return BrowseMedia(
title=title,
media_class=MediaClass.PLAYLIST,
media_content_id=str(playlist_id),
media_content_type=MEDIA_TYPE_OASIS_PLAYLIST,
can_play=True,
can_expand=True,
children=children,
children_media_class=MediaClass.IMAGE,
thumbnail=get_first_image_for_playlist(playlist),
)
def build_tracks_root() -> BrowseMedia:
"""Build the 'Tracks' directory based on the TRACKS mapping."""
children = [
BrowseMedia(
title=meta.get("name") or f"Track #{track_id}",
media_class=MediaClass.IMAGE,
media_content_id=str(track_id),
media_content_type=MEDIA_TYPE_OASIS_TRACK,
can_play=True,
can_expand=False,
thumbnail=get_url_for_image(meta.get("image")),
)
for track_id, meta in TRACKS.items()
]
return BrowseMedia(
title="Tracks",
media_class=MediaClass.DIRECTORY,
media_content_id="tracks_root",
media_content_type=MEDIA_TYPE_OASIS_TRACKS,
can_play=False,
can_expand=True,
children=children,
children_media_class=MediaClass.IMAGE,
)
def build_track_item(track_id: int) -> BrowseMedia:
"""Build a single track node for a given track id."""
meta = TRACKS.get(track_id) or {}
return BrowseMedia(
title=meta.get("name") or f"Track #{track_id}",
media_class=MediaClass.IMAGE,
media_content_id=str(track_id),
media_content_type=MEDIA_TYPE_OASIS_TRACK,
can_play=True,
can_expand=False,
thumbnail=get_url_for_image(meta.get("image")),
)
def get_first_image_for_playlist(playlist: dict[str, Any]) -> str | None:
"""Get the first image from a playlist dictionary."""
for track in playlist.get("patterns") or []:
if image := track.get("image"):
return get_url_for_image(image)
return None
async def async_search_media(
cloud: OasisCloudClient,
query: SearchMediaQuery,
) -> SearchMedia:
"""
Search tracks and/or playlists and return a SearchMedia result.
- If media_type == MEDIA_TYPE_OASIS_TRACK: search tracks only
- If media_type == MEDIA_TYPE_OASIS_PLAYLIST: search playlists only
- Otherwise: search both tracks and playlists
"""
try:
search_query = (query.search_query or "").strip().lower()
search_tracks = query.media_content_type in (
None,
"",
MEDIA_TYPE_OASIS_ROOT,
MEDIA_TYPE_OASIS_TRACKS,
MEDIA_TYPE_OASIS_TRACK,
)
search_playlists = query.media_content_type in (
None,
"",
MEDIA_TYPE_OASIS_ROOT,
MEDIA_TYPE_OASIS_PLAYLISTS,
MEDIA_TYPE_OASIS_PLAYLIST,
)
track_children: list[BrowseMedia] = []
playlist_children: list[BrowseMedia] = []
if search_tracks:
for track_id, meta in TRACKS.items():
name = (meta.get("name") or "").lower()
haystack = name.strip()
if search_query in haystack:
track_children.append(build_track_item(track_id))
if search_playlists:
playlists = await cloud.async_get_playlists(personal_only=False)
for pl in playlists:
playlist_id = pl.get("id")
if playlist_id is None:
continue
name = (pl.get("name") or "").lower()
if search_query not in name:
continue
playlist_children.append(
BrowseMedia(
title=pl.get("name") or f"Playlist #{playlist_id}",
media_class=MediaClass.PLAYLIST,
media_content_id=str(playlist_id),
media_content_type=MEDIA_TYPE_OASIS_PLAYLIST,
can_play=True,
can_expand=True,
thumbnail=get_first_image_for_playlist(pl),
)
)
root = BrowseMedia(
title=f"Search results for '{query.search_query}'",
media_class=MediaClass.DIRECTORY,
media_content_id=f"search:{query.search_query}",
media_content_type=MEDIA_TYPE_OASIS_ROOT,
can_play=False,
can_expand=True,
children=[],
)
if playlist_children and not track_children:
root.children_media_class = MediaClass.PLAYLIST
else:
root.children_media_class = MediaClass.IMAGE
root.children.extend(playlist_children)
root.children.extend(track_children)
return SearchMedia(result=root)
except Exception as err:
_LOGGER.debug(
"Search error details for %s: %s", query.search_query, err, exc_info=True
)
raise SearchError(f"Error searching for {query.search_query}") from err

View File

@@ -9,6 +9,7 @@ from typing import TYPE_CHECKING
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.dispatcher import async_dispatcher_send
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
@@ -32,7 +33,6 @@ class OasisDeviceCoordinator(DataUpdateCoordinator[list[OasisDevice]]):
hass: HomeAssistant, hass: HomeAssistant,
config_entry: OasisDeviceConfigEntry, config_entry: OasisDeviceConfigEntry,
cloud_client: OasisCloudClient, cloud_client: OasisCloudClient,
mqtt_client: OasisMqttClient,
) -> None: ) -> None:
""" """
Create an OasisDeviceCoordinator that manages OasisDevice discovery and updates using cloud and MQTT clients. Create an OasisDeviceCoordinator that manages OasisDevice discovery and updates using cloud and MQTT clients.
@@ -40,7 +40,6 @@ class OasisDeviceCoordinator(DataUpdateCoordinator[list[OasisDevice]]):
Parameters: Parameters:
config_entry (OasisDeviceConfigEntry): The config entry whose runtime data contains device serial numbers. config_entry (OasisDeviceConfigEntry): The config entry whose runtime data contains device serial numbers.
cloud_client (OasisCloudClient): Client for communicating with the Oasis cloud API and fetching device data. cloud_client (OasisCloudClient): Client for communicating with the Oasis cloud API and fetching device data.
mqtt_client (OasisMqttClient): Client for registering devices and coordinating MQTT-based readiness/status.
""" """
super().__init__( super().__init__(
hass, hass,
@@ -51,17 +50,58 @@ class OasisDeviceCoordinator(DataUpdateCoordinator[list[OasisDevice]]):
always_update=False, always_update=False,
) )
self.cloud_client = cloud_client self.cloud_client = cloud_client
self.mqtt_client = mqtt_client self.mqtt_client = OasisMqttClient()
# Track which devices are currently considered initialized
self._initialized_serials: set[str] = set()
@property
def _device_initialized_signal(self) -> str:
"""Dispatcher signal name for device initialization events."""
return f"{DOMAIN}_{self.config_entry.entry_id}_device_initialized"
def _attach_device_listeners(self, device: OasisDevice) -> None:
"""Attach a listener so we can fire dispatcher events when a device initializes."""
def _on_device_update() -> None:
serial = device.serial_number
if not serial:
return
initialized = device.is_initialized
was_initialized = serial in self._initialized_serials
if initialized and not was_initialized:
self._initialized_serials.add(serial)
_LOGGER.debug("%s ready for setup; dispatching signal", device.name)
async_dispatcher_send(
self.hass, self._device_initialized_signal, device
)
elif not initialized and was_initialized:
self._initialized_serials.remove(serial)
_LOGGER.debug("Oasis device %s no longer initialized", serial)
self.last_updated = dt_util.now()
self.async_update_listeners()
device.add_update_listener(_on_device_update)
# Seed the initialized set if the device is already initialized
if device.is_initialized and device.serial_number:
self._initialized_serials.add(device.serial_number)
async def _async_update_data(self) -> list[OasisDevice]: async def _async_update_data(self) -> list[OasisDevice]:
""" """
Fetch and assemble the current list of OasisDevice objects, reconcile removed devices in Home Assistant, register discovered devices with MQTT, and verify per-device readiness. Fetch and assemble the current list of OasisDevice objects, reconcile removed
devices in Home Assistant, register discovered devices with MQTT, and
best-effort trigger status updates for uninitialized devices.
Returns: Returns:
A list of OasisDevice instances representing devices currently available for the account. A list of OasisDevice instances representing devices currently available for the account.
Raises: Raises:
UpdateFailed: If no devices can be read after repeated attempts or an unexpected error persists past retry limits. UpdateFailed: If an unexpected error persists past retry limits.
""" """
devices: list[OasisDevice] = [] devices: list[OasisDevice] = []
self.attempt += 1 self.attempt += 1
@@ -88,15 +128,18 @@ class OasisDeviceCoordinator(DataUpdateCoordinator[list[OasisDevice]]):
name=raw.get("name"), name=raw.get("name"),
cloud=self.cloud_client, cloud=self.cloud_client,
) )
self._attach_device_listeners(device)
devices.append(device) devices.append(device)
# Handle devices removed from the account
new_serials = {d.serial_number for d in devices if d.serial_number} new_serials = {d.serial_number for d in devices if d.serial_number}
removed_serials = set(existing_by_serial) - new_serials removed_serials = set(existing_by_serial) - new_serials
if removed_serials: if removed_serials:
device_registry = dr.async_get(self.hass) device_registry = dr.async_get(self.hass)
for serial in removed_serials: for serial in removed_serials:
self._initialized_serials.discard(serial)
_LOGGER.info( _LOGGER.info(
"Oasis device %s removed from account; cleaning up in HA", "Oasis device %s removed from account; cleaning up in HA",
serial, serial,
@@ -110,14 +153,20 @@ class OasisDeviceCoordinator(DataUpdateCoordinator[list[OasisDevice]]):
remove_config_entry_id=self.config_entry.entry_id, remove_config_entry_id=self.config_entry.entry_id,
) )
# ✅ Valid state: logged in but no devices on account # If logged in, but no devices on account, return without starting mqtt
if not devices: if not devices:
_LOGGER.debug("No Oasis devices found for account") _LOGGER.debug("No Oasis devices found for account")
if self.mqtt_client.is_running:
# Close the mqtt client if it was previously started
await self.mqtt_client.async_close()
self.attempt = 0 self.attempt = 0
if devices != self.data: if devices != self.data:
self.last_updated = dt_util.now() self.last_updated = dt_util.now()
return [] return []
# Ensure MQTT is running and devices are registered
if not self.mqtt_client.is_running:
self.mqtt_client.start()
self.mqtt_client.register_devices(devices) self.mqtt_client.register_devices(devices)
# Best-effort playlists # Best-effort playlists
@@ -126,53 +175,28 @@ class OasisDeviceCoordinator(DataUpdateCoordinator[list[OasisDevice]]):
except Exception: except Exception:
_LOGGER.exception("Error fetching playlists from cloud") _LOGGER.exception("Error fetching playlists from cloud")
any_success = False # Best-effort: request status for devices that are not yet initialized
for device in devices: for device in devices:
try: try:
ready = await self.mqtt_client.wait_until_ready( if not device.is_initialized:
device, request_status=True await device.async_get_status()
)
if not ready:
_LOGGER.warning(
"Timeout waiting for Oasis device %s to be ready",
device.serial_number,
)
continue
mac = await device.async_get_mac_address()
if not mac:
_LOGGER.warning(
"Could not get MAC address for Oasis device %s",
device.serial_number,
)
continue
any_success = True
device.schedule_track_refresh() device.schedule_track_refresh()
except Exception: except Exception:
_LOGGER.exception( _LOGGER.exception(
"Error preparing Oasis device %s", device.serial_number "Error requesting status for Oasis device %s; "
"will retry on future updates",
device.serial_number,
) )
if any_success: self.attempt = 0
self.attempt = 0
else:
if self.attempt > 2 or not self.data:
raise UpdateFailed(
"Couldn't read from any Oasis device "
f"after {self.attempt} attempts"
)
except UpdateFailed:
raise
except Exception as ex: except Exception as ex:
if self.attempt > 2 or not (devices or self.data): if self.attempt > 2 or not (devices or self.data):
raise UpdateFailed( raise UpdateFailed(
"Unexpected error talking to Oasis devices " "Unexpected error talking to Oasis devices "
f"after {self.attempt} attempts" f"after {self.attempt} attempts"
) from ex ) from ex
_LOGGER.warning( _LOGGER.warning(
"Error updating Oasis devices; reusing previous data", exc_info=ex "Error updating Oasis devices; reusing previous data", exc_info=ex
) )
@@ -182,3 +206,11 @@ class OasisDeviceCoordinator(DataUpdateCoordinator[list[OasisDevice]]):
self.last_updated = dt_util.now() self.last_updated = dt_util.now()
return devices return devices
async def async_close(self) -> None:
"""Close client connections."""
await asyncio.gather(
self.mqtt_client.async_close(),
self.cloud_client.async_close(),
return_exceptions=True,
)

View File

@@ -121,13 +121,15 @@ class OasisDeviceLightEntity(OasisDeviceEntity, LightEntity):
""" """
Turn the light on and set its LED state. Turn the light on and set its LED state.
Processes optional keyword arguments to compute the device-specific LED parameters, then updates the device's LEDs with the resulting brightness, color, and effect. Processes optional keyword arguments to compute the device-specific LED
parameters, then updates the device's LEDs with the resulting brightness, color,
and effect.
Parameters: Parameters:
kwargs: Optional control parameters recognized by the method: kwargs: Optional control parameters recognized by the method:
ATTR_BRIGHTNESS (int): Brightness in the 0-255 Home Assistant scale. When provided, ATTR_BRIGHTNESS (int): Brightness in the 0-255 Home Assistant scale. When provided,
it is converted and rounded up to the device's brightness scale (1..device.brightness_max). it is converted and rounded up to the device's brightness scale (1..device.brightness_max).
When omitted, uses self.device.brightness or self.device.brightness_on. When omitted, uses self.device.brightness_on (last non-zero brightness).
ATTR_RGB_COLOR (tuple[int, int, int]): RGB tuple (R, G, B). When provided, it is ATTR_RGB_COLOR (tuple[int, int, int]): RGB tuple (R, G, B). When provided, it is
converted to a hex color string prefixed with '#'. converted to a hex color string prefixed with '#'.
ATTR_EFFECT (str): Human-readable effect name. When provided, it is mapped to the ATTR_EFFECT (str): Human-readable effect name. When provided, it is mapped to the
@@ -140,7 +142,7 @@ class OasisDeviceLightEntity(OasisDeviceEntity, LightEntity):
scale = (1, self.device.brightness_max) scale = (1, self.device.brightness_max)
brightness = math.ceil(brightness_to_value(scale, brightness)) brightness = math.ceil(brightness_to_value(scale, brightness))
else: else:
brightness = self.device.brightness or self.device.brightness_on brightness = self.device.brightness_on
if color := kwargs.get(ATTR_RGB_COLOR): if color := kwargs.get(ATTR_RGB_COLOR):
color = f"#{color_rgb_to_hex(*color)}" color = f"#{color_rgb_to_hex(*color)}"

View File

@@ -3,9 +3,12 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
import logging
from typing import Any from typing import Any
from homeassistant.components.media_player import ( from homeassistant.components.media_player import (
BrowseError,
BrowseMedia,
MediaPlayerEnqueue, MediaPlayerEnqueue,
MediaPlayerEntity, MediaPlayerEntity,
MediaPlayerEntityDescription, MediaPlayerEntityDescription,
@@ -13,12 +16,27 @@ from homeassistant.components.media_player import (
MediaPlayerState, MediaPlayerState,
MediaType, MediaType,
RepeatMode, RepeatMode,
SearchMedia,
SearchMediaQuery,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import OasisDeviceConfigEntry, setup_platform_from_coordinator from . import OasisDeviceConfigEntry, setup_platform_from_coordinator
from .browse_media import (
MEDIA_TYPE_OASIS_PLAYLIST,
MEDIA_TYPE_OASIS_PLAYLISTS,
MEDIA_TYPE_OASIS_ROOT,
MEDIA_TYPE_OASIS_TRACK,
MEDIA_TYPE_OASIS_TRACKS,
async_search_media,
build_playlist_item,
build_playlists_root,
build_root_response,
build_track_item,
build_tracks_root,
)
from .const import DOMAIN from .const import DOMAIN
from .entity import OasisDeviceEntity from .entity import OasisDeviceEntity
from .helpers import get_track_id from .helpers import get_track_id
@@ -33,6 +51,9 @@ from .pyoasiscontrol.const import (
STATUS_STOPPED, STATUS_STOPPED,
STATUS_UPDATING, STATUS_UPDATING,
) )
from .pyoasiscontrol.utils import get_track_ids_from_playlist
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry( async def async_setup_entry(
@@ -66,6 +87,7 @@ DESCRIPTOR = MediaPlayerEntityDescription(key="oasis_mini", name=None)
class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity): class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity):
"""Oasis device media player entity.""" """Oasis device media player entity."""
_attr_media_content_type = MediaType.IMAGE
_attr_media_image_remotely_accessible = True _attr_media_image_remotely_accessible = True
_attr_supported_features = ( _attr_supported_features = (
MediaPlayerEntityFeature.PAUSE MediaPlayerEntityFeature.PAUSE
@@ -77,13 +99,10 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity):
| MediaPlayerEntityFeature.MEDIA_ENQUEUE | MediaPlayerEntityFeature.MEDIA_ENQUEUE
| MediaPlayerEntityFeature.CLEAR_PLAYLIST | MediaPlayerEntityFeature.CLEAR_PLAYLIST
| MediaPlayerEntityFeature.REPEAT_SET | MediaPlayerEntityFeature.REPEAT_SET
| MediaPlayerEntityFeature.BROWSE_MEDIA
| MediaPlayerEntityFeature.SEARCH_MEDIA
) )
@property
def media_content_type(self) -> MediaType:
"""Content type of current playing media."""
return MediaType.IMAGE
@property @property
def media_duration(self) -> int | None: def media_duration(self) -> int | None:
"""Duration of current playing media in seconds.""" """Duration of current playing media in seconds."""
@@ -244,24 +263,69 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity):
""" """
Play or enqueue one or more Oasis tracks on the device. Play or enqueue one or more Oasis tracks on the device.
Validates the media type and parses one or more track identifiers from `media_id`, then updates the device playlist according to `enqueue`. Depending on the enqueue mode the method can replace the playlist, append tracks, move appended tracks to the next play position, and optionally start playback. Validates the media type and parses one or more track identifiers from
`media_id`, then updates the device playlist according to `enqueue`. Depending
on the enqueue mode the method can replace the playlist, append tracks, move
appended tracks to the next play position, and optionally start playback.
Parameters: Parameters:
media_type (MediaType | str): The media type being requested. media_type (MediaType | str): The media type being requested.
media_id (str): A comma-separated string of track identifiers. media_id (str): A comma-separated string of track identifiers.
enqueue (MediaPlayerEnqueue | None): How to insert the tracks into the playlist; if omitted defaults to NEXT. enqueue (MediaPlayerEnqueue | None): How to insert the tracks into the playlist; if omitted defaults to PLAY.
Raises: Raises:
ServiceValidationError: If the device is busy, if `media_type` is a playlist (playlists are unsupported), or if `media_id` does not contain any valid track identifiers. ServiceValidationError: If the device is busy or if `media_id` does not contain any valid media identifiers.
""" """
self.abort_if_busy() self.abort_if_busy()
if media_type == MediaType.PLAYLIST:
raise ServiceValidationError( track_ids: list[int] = []
translation_domain=DOMAIN, translation_key="playlists_unsupported"
) # Entire playlist from browse
if media_type == MEDIA_TYPE_OASIS_PLAYLIST:
try:
playlist_id = int(media_id)
except (TypeError, ValueError) as err:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_media",
translation_placeholders={"media": f"playlist {media_id}"},
) from err
playlists = await self.coordinator.cloud_client.async_get_playlists()
playlist = next((p for p in playlists if p.get("id") == playlist_id), None)
if not playlist:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_media",
translation_placeholders={"media": f"playlist {playlist_id}"},
)
track_ids = get_track_ids_from_playlist(playlist)
if not track_ids:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_media",
translation_placeholders={
"media": f"playlist {playlist_id} is empty"
},
)
elif media_type == MEDIA_TYPE_OASIS_TRACK:
try:
track_id = int(media_id)
except (TypeError, ValueError) as err:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_media",
translation_placeholders={"media": f"track {media_id}"},
) from err
track_ids = [track_id]
else: else:
track = list(filter(None, map(get_track_id, media_id.split(",")))) track_ids = list(filter(None, map(get_track_id, media_id.split(","))))
if not track: if not track_ids:
raise ServiceValidationError( raise ServiceValidationError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="invalid_media", translation_key="invalid_media",
@@ -269,29 +333,33 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity):
) )
device = self.device device = self.device
enqueue = MediaPlayerEnqueue.NEXT if not enqueue else enqueue enqueue = MediaPlayerEnqueue.PLAY if not enqueue else enqueue
if enqueue == MediaPlayerEnqueue.ADD:
await device.async_add_track_to_playlist(track_ids)
return
if enqueue == MediaPlayerEnqueue.REPLACE: if enqueue == MediaPlayerEnqueue.REPLACE:
await device.async_set_playlist(track) await device.async_stop()
else: await device.async_set_playlist(track_ids)
await device.async_add_track_to_playlist(track) await device.async_play()
return
if enqueue in (MediaPlayerEnqueue.NEXT, MediaPlayerEnqueue.PLAY): insert_at = (device.playlist_index or 0) + 1
# Move track to next item in the playlist original_len = len(device.playlist)
new_tracks = 1 if isinstance(track, int) else len(track) await device.async_add_track_to_playlist(track_ids)
if (index := (len(device.playlist) - new_tracks)) != device.playlist_index:
if index != (
_next := min(
device.playlist_index + 1, len(device.playlist) - new_tracks
)
):
await device.async_move_track(index, _next)
if enqueue == MediaPlayerEnqueue.PLAY:
await device.async_change_track(_next)
if ( # Move each newly-added track into the desired position
enqueue in (MediaPlayerEnqueue.PLAY, MediaPlayerEnqueue.REPLACE) for offset, _track_id in enumerate(track_ids):
and device.status_code != 4 from_index = original_len + offset # position at end after append
to_index = insert_at + offset # target position in playlist
if from_index > to_index:
await device.async_move_track(from_index, to_index)
if enqueue == MediaPlayerEnqueue.PLAY or (
enqueue == MediaPlayerEnqueue.NEXT and device.status_code != STATUS_PLAYING
): ):
await device.async_change_track(min(insert_at, original_len))
await device.async_play() await device.async_play()
async def async_clear_playlist(self) -> None: async def async_clear_playlist(self) -> None:
@@ -303,3 +371,71 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity):
""" """
self.abort_if_busy() self.abort_if_busy()
await self.device.async_clear_playlist() await self.device.async_clear_playlist()
async def async_browse_media(
self,
media_content_type: MediaType | str | None = None,
media_content_id: str | None = None,
) -> BrowseMedia:
"""
Provide a browse tree for Oasis playlists and tracks.
Root (`None` or oasis_root):
- Playlists folder
- Tracks folder
"""
# Root
if media_content_id in (None, "", "oasis_root") or media_content_type in (
None,
MEDIA_TYPE_OASIS_ROOT,
):
return await build_root_response()
# Playlists folder
if (
media_content_type == MEDIA_TYPE_OASIS_PLAYLISTS
or media_content_id == "playlists_root"
):
return await build_playlists_root(self.coordinator.cloud_client)
# Single playlist
if media_content_type == MEDIA_TYPE_OASIS_PLAYLIST:
try:
playlist_id = int(media_content_id)
except (TypeError, ValueError) as err:
raise BrowseError(f"Invalid playlist id: {media_content_id}") from err
return await build_playlist_item(self.coordinator.cloud_client, playlist_id)
# Tracks folder
if (
media_content_type == MEDIA_TYPE_OASIS_TRACKS
or media_content_id == "tracks_root"
):
return build_tracks_root()
# Single track
if media_content_type == MEDIA_TYPE_OASIS_TRACK:
try:
track_id = int(media_content_id)
except (TypeError, ValueError) as err:
raise BrowseError(f"Invalid track id: {media_content_id}") from err
return build_track_item(track_id)
raise BrowseError(
f"Unsupported media_content_type/id: {media_content_type}/{media_content_id}"
)
async def async_search_media(
self,
query: SearchMediaQuery,
) -> SearchMedia:
"""
Search tracks and/or playlists and return a BrowseMedia tree of matches.
- If media_type == MEDIA_TYPE_OASIS_TRACK: search tracks only
- If media_type == MEDIA_TYPE_OASIS_PLAYLIST: search playlists only
- Otherwise: search both tracks and playlists
"""
return await async_search_media(self.coordinator.cloud_client, query)

View File

@@ -54,7 +54,7 @@ class OasisMqttClient(OasisClientProtocol):
_connected_event: Event signaled when a connection is established. _connected_event: Event signaled when a connection is established.
_stop_event: Event signaled to request the loop to stop. _stop_event: Event signaled to request the loop to stop.
_devices: Mapping of device serial to OasisDevice instances. _devices: Mapping of device serial to OasisDevice instances.
_first_status_events: Per-serial events signaled on receiving the first STATUS message. _initialized_events: Per-serial events signaled on receiving the full device initialization.
_mac_events: Per-serial events signaled when a device MAC address is received. _mac_events: Per-serial events signaled when a device MAC address is received.
_subscribed_serials: Set of serials currently subscribed to STATUS topics. _subscribed_serials: Set of serials currently subscribed to STATUS topics.
_subscription_lock: Lock protecting subscribe/unsubscribe operations. _subscription_lock: Lock protecting subscribe/unsubscribe operations.
@@ -71,7 +71,7 @@ class OasisMqttClient(OasisClientProtocol):
self._devices: dict[str, OasisDevice] = {} self._devices: dict[str, OasisDevice] = {}
# Per-device events # Per-device events
self._first_status_events: dict[str, asyncio.Event] = {} self._initialized_events: dict[str, asyncio.Event] = {}
self._mac_events: dict[str, asyncio.Event] = {} self._mac_events: dict[str, asyncio.Event] = {}
# Subscription bookkeeping # Subscription bookkeeping
@@ -83,11 +83,25 @@ class OasisMqttClient(OasisClientProtocol):
maxsize=MAX_PENDING_COMMANDS maxsize=MAX_PENDING_COMMANDS
) )
@property
def is_running(self) -> bool:
"""Return `True` if the MQTT loop has been started and is not stopped."""
return (
self._loop_task is not None
and not self._loop_task.done()
and not self._stop_event.is_set()
)
@property
def is_connected(self) -> bool:
"""Return `True` if the MQTT client is currently connected."""
return self._connected_event.is_set()
def register_device(self, device: OasisDevice) -> None: def register_device(self, device: OasisDevice) -> None:
""" """
Register an OasisDevice so MQTT messages for its serial are routed to that device. Register an OasisDevice so MQTT messages for its serial are routed to that device.
Ensures the device has a serial_number (raises ValueError if not), stores the device in the client's registry, creates per-device asyncio.Events for first-status and MAC-address arrival, attaches this client to the device if it has no client, and schedules a subscription for the device's STATUS topics if the MQTT client is currently connected. Ensures the device has a serial_number (raises ValueError if not), stores the device in the client's registry, creates per-device asyncio.Events for device initialization and MAC-address arrival, attaches this client to the device if it has no client, and schedules a subscription for the device's STATUS topics if the MQTT client is currently connected.
Parameters: Parameters:
device (OasisDevice): The device instance to register. device (OasisDevice): The device instance to register.
@@ -102,7 +116,7 @@ class OasisMqttClient(OasisClientProtocol):
self._devices[serial] = device self._devices[serial] = device
# Ensure we have per-device events # Ensure we have per-device events
self._first_status_events.setdefault(serial, asyncio.Event()) self._initialized_events.setdefault(serial, asyncio.Event())
self._mac_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 # Attach ourselves as the client if the device doesn't already have one
@@ -134,7 +148,7 @@ class OasisMqttClient(OasisClientProtocol):
""" """
Unregisters a device from MQTT routing and cleans up related per-device state. Unregisters a device from MQTT routing and cleans up related per-device state.
Removes the device's registration, first-status and MAC events. If there is an active MQTT client and the device's serial is currently subscribed, schedules an asynchronous unsubscription task. If the device has no serial_number, the call is a no-op. Removes the device's registration, initialization and MAC events. If there is an active MQTT client and the device's serial is currently subscribed, schedules an asynchronous unsubscription task. If the device has no serial_number, the call is a no-op.
Parameters: Parameters:
device (OasisDevice): The device to unregister; must have `serial_number` set. device (OasisDevice): The device to unregister; must have `serial_number` set.
@@ -144,7 +158,7 @@ class OasisMqttClient(OasisClientProtocol):
return return
self._devices.pop(serial, None) self._devices.pop(serial, None)
self._first_status_events.pop(serial, None) self._initialized_events.pop(serial, None)
self._mac_events.pop(serial, None) self._mac_events.pop(serial, None)
# If connected and we were subscribed, unsubscribe # If connected and we were subscribed, unsubscribe
@@ -201,7 +215,8 @@ class OasisMqttClient(OasisClientProtocol):
self._subscribed_serials.clear() self._subscribed_serials.clear()
for serial, device in self._devices.items(): for serial, device in self._devices.items():
await self._subscribe_serial(serial) await self._subscribe_serial(serial)
await self.async_get_all(device) if not device.is_sleeping:
await self.async_get_all(device)
def start(self) -> None: def start(self) -> None:
"""Start MQTT connection loop.""" """Start MQTT connection loop."""
@@ -218,32 +233,49 @@ class OasisMqttClient(OasisClientProtocol):
""" """
Stop the MQTT client and clean up resources. Stop the MQTT client and clean up resources.
Signals the background MQTT loop to stop, cancels the loop task, disconnects the MQTT client if connected, and clears any pending commands from the internal command queue. Signals the background MQTT loop to stop, cancels the loop task,
disconnects the MQTT client if connected, and drops any pending commands.
""" """
_LOGGER.debug("MQTT stop() called - beginning shutdown sequence")
self._stop_event.set() self._stop_event.set()
if self._loop_task: if self._loop_task:
_LOGGER.debug(
"Cancelling MQTT background task (task=%s, done=%s)",
self._loop_task,
self._loop_task.done(),
)
self._loop_task.cancel() self._loop_task.cancel()
try: try:
await self._loop_task await self._loop_task
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
_LOGGER.debug("MQTT background task cancelled")
if self._client: if self._client:
_LOGGER.debug("Disconnecting MQTT client from broker")
try: try:
await self._client.disconnect() await self._client.disconnect()
_LOGGER.debug("MQTT client disconnected")
except Exception: except Exception:
_LOGGER.exception("Error disconnecting MQTT client") _LOGGER.exception("Error disconnecting MQTT client")
finally: finally:
self._client = None self._client = None
# Drop pending commands on stop # Drop queued commands
while not self._command_queue.empty(): if not self._command_queue.empty():
try: _LOGGER.debug("Dropping queued commands")
self._command_queue.get_nowait() dropped = 0
self._command_queue.task_done() while not self._command_queue.empty():
except asyncio.QueueEmpty: try:
break self._command_queue.get_nowait()
self._command_queue.task_done()
dropped += 1
except asyncio.QueueEmpty:
break
_LOGGER.debug("MQTT dropped %s queued command(s)", dropped)
_LOGGER.debug("MQTT shutdown sequence complete")
async def wait_until_ready( async def wait_until_ready(
self, device: OasisDevice, timeout: float = 10.0, request_status: bool = True self, device: OasisDevice, timeout: float = 10.0, request_status: bool = True
@@ -255,11 +287,11 @@ class OasisMqttClient(OasisClientProtocol):
Parameters: Parameters:
device (OasisDevice): The device to wait for; must have `serial_number` set. device (OasisDevice): The device to wait for; must have `serial_number` set.
timeout (float): Maximum seconds to wait for connection and for the first STATUS message. timeout (float): Maximum seconds to wait for connection and for the device to be initialized.
request_status (bool): If True, issue a status refresh after connection to encourage a STATUS update. request_status (bool): If True, issue a status refresh after connection to encourage a STATUS update.
Returns: Returns:
bool: `True` if the device's first STATUS message was observed within the timeout, `False` otherwise. bool: `True` if the device was initialized within the timeout, `False` otherwise.
Raises: Raises:
RuntimeError: If the provided device does not have a `serial_number`. RuntimeError: If the provided device does not have a `serial_number`.
@@ -268,7 +300,7 @@ class OasisMqttClient(OasisClientProtocol):
if not serial: if not serial:
raise RuntimeError("Device has no serial_number set") raise RuntimeError("Device has no serial_number set")
first_status_event = self._first_status_events.setdefault( is_initialized_event = self._initialized_events.setdefault(
serial, asyncio.Event() serial, asyncio.Event()
) )
@@ -286,7 +318,6 @@ class OasisMqttClient(OasisClientProtocol):
# Optionally request a status refresh # Optionally request a status refresh
if request_status: if request_status:
try: try:
first_status_event.clear()
await self.async_get_status(device) await self.async_get_status(device)
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
_LOGGER.debug( _LOGGER.debug(
@@ -294,17 +325,18 @@ class OasisMqttClient(OasisClientProtocol):
serial, serial,
) )
# Wait for first status # Wait for initialization
try: try:
await asyncio.wait_for(first_status_event.wait(), timeout=timeout) await asyncio.wait_for(is_initialized_event.wait(), timeout=timeout)
return True
except asyncio.TimeoutError: except asyncio.TimeoutError:
_LOGGER.debug( _LOGGER.debug(
"Timeout (%.1fs) waiting for first STATUS message from %s", "Timeout (%.1fs) waiting for initialization from %s",
timeout, timeout,
serial, serial,
) )
return False return False
else:
return True
async def async_get_mac_address(self, device: OasisDevice) -> str | None: async def async_get_mac_address(self, device: OasisDevice) -> str | None:
""" """
@@ -586,6 +618,9 @@ class OasisMqttClient(OasisClientProtocol):
return return
while not self._command_queue.empty(): while not self._command_queue.empty():
if not self._client:
break
try: try:
serial, payload = self._command_queue.get_nowait() serial, payload = self._command_queue.get_nowait()
except asyncio.QueueEmpty: except asyncio.QueueEmpty:
@@ -599,7 +634,6 @@ class OasisMqttClient(OasisClientProtocol):
serial, serial,
payload, payload,
) )
self._command_queue.task_done()
continue continue
topic = f"{serial}/COMMAND/CMD" topic = f"{serial}/COMMAND/CMD"
@@ -609,12 +643,11 @@ class OasisMqttClient(OasisClientProtocol):
_LOGGER.debug( _LOGGER.debug(
"Failed to flush queued command for %s, re-queuing", serial "Failed to flush queued command for %s, re-queuing", serial
) )
# Put it back and break; we'll try again on next reconnect # Put it back; we'll try again on next reconnect
await self._enqueue_command(serial, payload) await self._enqueue_command(serial, payload)
finally:
# Ensure we always balance the get(), even on cancellation
self._command_queue.task_done() self._command_queue.task_done()
break
self._command_queue.task_done()
async def _publish_command( async def _publish_command(
self, device: OasisDevice, payload: str, wake: bool = False self, device: OasisDevice, payload: str, wake: bool = False
@@ -659,9 +692,15 @@ class OasisMqttClient(OasisClientProtocol):
async def _mqtt_loop(self) -> None: async def _mqtt_loop(self) -> None:
""" """
Run the MQTT WebSocket connection loop that maintains connection, subscriptions, and message handling. Run the MQTT WebSocket connection loop that maintains connection, subscriptions,
and message handling.
This background coroutine establishes a persistent WSS MQTT connection to the configured broker, sets connection state on successful connect, resubscribes to known device STATUS topics, flushes any queued outbound commands, and dispatches incoming MQTT messages to the status handler. On disconnect or error it clears connection state and subscription tracking, and retries connecting after the configured backoff interval until the client is stopped. This background coroutine establishes a persistent WSS MQTT connection to the
configured broker, sets connection state on successful connect, resubscribes to
known device STATUS topics, flushes any queued outbound commands, and dispatches
incoming MQTT messages to the status handler. On disconnect or error it clears
connection state and subscription tracking, and retries connecting after the
configured backoff interval until the client is stopped.
""" """
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
tls_context = await loop.run_in_executor(None, ssl.create_default_context) tls_context = await loop.run_in_executor(None, ssl.create_default_context)
@@ -727,7 +766,7 @@ class OasisMqttClient(OasisClientProtocol):
If the topic corresponds to a registered device, extracts the relevant status field and calls If the topic corresponds to a registered device, extracts the relevant status field and calls
the device's update_from_status_dict with a mapping of the parsed values. For the "MAC_ADDRESS" the device's update_from_status_dict with a mapping of the parsed values. For the "MAC_ADDRESS"
status, sets the per-device MAC event to signal arrival of the MAC address. Always sets the status, sets the per-device MAC event to signal arrival of the MAC address. Always sets the
per-device first-status event once any status is processed for that serial. per-device initialization event once the appropriate messages are processed for that serial.
Parameters: Parameters:
msg (aiomqtt.Message): Incoming MQTT message; topic identifies device serial and status. msg (aiomqtt.Message): Incoming MQTT message; topic identifies device serial and status.
@@ -757,7 +796,11 @@ class OasisMqttClient(OasisClientProtocol):
elif status_name == "OASIS_SPEEED": elif status_name == "OASIS_SPEEED":
data["ball_speed"] = int(payload) data["ball_speed"] = int(payload)
elif status_name == "JOBLIST": elif status_name == "JOBLIST":
data["playlist"] = [int(x) for x in payload.split(",") if x] data["playlist"] = [
track_id
for track_str in payload.split(",")
if (track_id := _parse_int(track_str))
]
elif status_name == "CURRENTJOB": elif status_name == "CURRENTJOB":
data["playlist_index"] = int(payload) data["playlist_index"] = int(payload)
elif status_name == "CURRENTLINE": elif status_name == "CURRENTLINE":
@@ -827,8 +870,8 @@ class OasisMqttClient(OasisClientProtocol):
if data: if data:
device.update_from_status_dict(data) device.update_from_status_dict(data)
first_status_event = self._first_status_events.setdefault( is_initialized_event = self._initialized_events.setdefault(
serial, asyncio.Event() serial, asyncio.Event()
) )
if not first_status_event.is_set(): if not is_initialized_event.is_set() and device.is_initialized:
first_status_event.set() is_initialized_event.set()

View File

@@ -14,7 +14,13 @@ from .const import (
STATUS_SLEEPING, 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,
get_url_for_image,
)
if TYPE_CHECKING: # avoid runtime circular imports if TYPE_CHECKING: # avoid runtime circular imports
from .clients import OasisCloudClient from .clients import OasisCloudClient
@@ -70,7 +76,6 @@ class OasisDevice:
cloud: OasisCloudClient | None = None, cloud: OasisCloudClient | None = None,
client: OasisClientProtocol | None = None, client: OasisClientProtocol | None = None,
) -> None: ) -> None:
# Transport
""" """
Initialize an OasisDevice with identification, network, transport references, and default state fields. Initialize an OasisDevice with identification, network, transport references, and default state fields.
@@ -156,6 +161,11 @@ class OasisDevice:
if value: if value:
self.brightness_on = value self.brightness_on = value
@property
def is_initialized(self) -> bool:
"""Return `True` if the device is fully identified."""
return bool(self.serial_number and self.mac_address and self.software_version)
@property @property
def is_sleeping(self) -> bool: def is_sleeping(self) -> bool:
""" """
@@ -278,7 +288,11 @@ class OasisDevice:
) )
return None return None
playlist = [_parse_int(track) for track in values[3].split(",") if track] playlist = [
track_id
for track_str in values[3].split(",")
if (track_id := _parse_int(track_str))
]
try: try:
status: dict[str, Any] = { status: dict[str, Any] = {
@@ -391,10 +405,10 @@ class OasisDevice:
Get the full HTTPS URL for the current track's image if available. Get the full HTTPS URL for the current track's image if available.
Returns: Returns:
str: Full URL to the track image (https://app.grounded.so/uploads/<image>), or `None` if no image is available. str: Full URL to the track image or `None` if no image is available.
""" """
if (track := self.track) and (image := track.get("image")): if track := self.track:
return f"https://app.grounded.so/uploads/{image}" return get_url_for_image(track.get("image"))
return None return None
@property @property
@@ -491,6 +505,11 @@ class OasisDevice:
except Exception: except Exception:
_LOGGER.exception("Error in update listener") _LOGGER.exception("Error in update listener")
async def async_get_status(self) -> None:
"""Request that the device update its current status."""
client = self._require_client()
await client.async_get_status(self)
async def async_get_mac_address(self) -> str | None: async def async_get_mac_address(self) -> str | None:
""" """
Get the device MAC address, requesting it from the attached transport client if not already known. Get the device MAC address, requesting it from the attached transport client if not already known.
@@ -607,6 +626,10 @@ class OasisDevice:
client = self._require_client() client = self._require_client()
await client.async_send_change_track_command(self, index) await client.async_send_change_track_command(self, index)
async def async_clear_playlist(self) -> None:
"""Clear the playlist."""
await self.async_set_playlist([])
async def async_add_track_to_playlist(self, track: int | Iterable[int]) -> None: async def async_add_track_to_playlist(self, track: int | Iterable[int]) -> None:
""" """
Add one or more tracks to the device's playlist via the attached client. Add one or more tracks to the device's playlist via the attached client.

View File

@@ -200,5 +200,15 @@ def decrypt_svg_content(svg_content: dict[str, str]):
return decrypted return decrypted
def get_track_ids_from_playlist(playlist: dict[str, Any]) -> list[int]:
"""Get a list of track ids from a playlist."""
return [track["id"] for track in (playlist.get("patterns") or []) if "id" in track]
def get_url_for_image(image: str | None) -> str | None:
"""Get the full URL for an image."""
return f"https://app.grounded.so/uploads/{image}" if image else None
def now() -> datetime: def now() -> datetime:
return datetime.now(UTC) return datetime.now(UTC)

View File

@@ -157,9 +157,6 @@
}, },
"invalid_media": { "invalid_media": {
"message": "Invalid media: {media}" "message": "Invalid media: {media}"
},
"playlists_unsupported": {
"message": "Playlists are not currently supported"
} }
} }
} }

View File

@@ -157,9 +157,6 @@
}, },
"invalid_media": { "invalid_media": {
"message": "Invalid media: {media}" "message": "Invalid media: {media}"
},
"playlists_unsupported": {
"message": "Playlists are not currently supported"
} }
} }
} }