mirror of
https://github.com/natekspencer/hacs-oasis_mini.git
synced 2025-12-06 18:44:14 -05:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b32199c334 | ||
|
|
5c49119ae5 | ||
|
|
fbb3012379 | ||
|
|
ac005c70c2 | ||
|
|
873d2d4bb0 | ||
|
|
04be6626a7 | ||
|
|
14223bd1c9 | ||
|
|
1d521bcc18 | ||
|
|
2994e73187 | ||
|
|
e4f6cd2803 | ||
|
|
1cc3585653 | ||
|
|
2f28f7c4bd | ||
|
|
81668c595a | ||
|
|
c17d1682d0 | ||
|
|
f0669c7f63 | ||
|
|
8abfc047f9 | ||
|
|
0df118d18d | ||
|
|
0ebab392fb | ||
|
|
a15548e387 | ||
|
|
b459e3eb9d | ||
|
|
a6ecd740be | ||
|
|
aa7abc2174 | ||
|
|
a548ac5fe2 | ||
|
|
ce22238ae6 | ||
|
|
4ef28fc741 | ||
|
|
cf21a5d995 | ||
|
|
83de1d5606 | ||
|
|
2a92212aad | ||
|
|
ecad472bbd | ||
|
|
886d7598f3 |
71
.github/ISSUE_TEMPLATE/bug.yml
vendored
71
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -1,71 +0,0 @@
|
||||
---
|
||||
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
1
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1 +0,0 @@
|
||||
blank_issues_enabled: false
|
||||
47
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
47
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -1,47 +0,0 @@
|
||||
---
|
||||
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
|
||||
2
.github/workflows/update-tracks.yml
vendored
2
.github/workflows/update-tracks.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install homeassistant aiomqtt
|
||||
run: pip install homeassistant
|
||||
|
||||
- name: Update tracks
|
||||
env:
|
||||
|
||||
17
.github/workflows/validate.yaml
vendored
17
.github/workflows/validate.yaml
vendored
@@ -1,25 +1,22 @@
|
||||
name: Validate repo
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: "0 0 * * *"
|
||||
|
||||
jobs:
|
||||
hassfest:
|
||||
name: Validate with hassfest
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: "ubuntu-latest"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: home-assistant/actions/hassfest@master
|
||||
|
||||
- uses: "actions/checkout@v4"
|
||||
- uses: "home-assistant/actions/hassfest@master"
|
||||
hacs:
|
||||
name: Validate with HACS
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: "ubuntu-latest"
|
||||
steps:
|
||||
- uses: hacs/action@main
|
||||
- uses: "hacs/action@main"
|
||||
with:
|
||||
category: integration
|
||||
category: "integration"
|
||||
|
||||
@@ -10,15 +10,15 @@ from homeassistant.const import CONF_EMAIL, Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
import homeassistant.helpers.entity_registry as er
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import OasisDeviceCoordinator
|
||||
from .entity import OasisDeviceEntity
|
||||
from .helpers import create_client
|
||||
from .pyoasiscontrol import OasisDevice, UnauthenticatedError
|
||||
from .pyoasiscontrol import OasisDevice, OasisMqttClient, UnauthenticatedError
|
||||
|
||||
type OasisDeviceConfigEntry = ConfigEntry[OasisDeviceCoordinator]
|
||||
|
||||
@@ -56,18 +56,25 @@ def setup_platform_from_coordinator(
|
||||
update_before_add: If true, entities will be updated before being added.
|
||||
"""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
known_serials: set[str] = set()
|
||||
signal = coordinator._device_initialized_signal
|
||||
|
||||
@callback
|
||||
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 []
|
||||
new_devices: list[OasisDevice] = []
|
||||
|
||||
for device in devices:
|
||||
serial = device.serial_number
|
||||
if not device.is_initialized or not serial or serial in known_serials:
|
||||
if not serial or serial in known_serials:
|
||||
continue
|
||||
|
||||
known_serials.add(serial)
|
||||
@@ -79,39 +86,15 @@ def setup_platform_from_coordinator(
|
||||
if entities := make_entities(new_devices):
|
||||
async_add_entities(entities, update_before_add)
|
||||
|
||||
@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
|
||||
# Initial population
|
||||
_check_devices()
|
||||
|
||||
# Future changes: new devices / account re-sync via coordinator
|
||||
# Future updates (new devices discovered)
|
||||
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:
|
||||
"""
|
||||
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.
|
||||
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.
|
||||
|
||||
Returns:
|
||||
True if the config entry was set up successfully.
|
||||
@@ -126,12 +109,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: OasisDeviceConfigEntry)
|
||||
await cloud_client.async_close()
|
||||
raise
|
||||
|
||||
coordinator = OasisDeviceCoordinator(hass, entry, cloud_client)
|
||||
mqtt_client = OasisMqttClient()
|
||||
coordinator = OasisDeviceCoordinator(hass, entry, cloud_client, mqtt_client)
|
||||
|
||||
try:
|
||||
mqtt_client.start()
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
except Exception:
|
||||
await coordinator.async_close()
|
||||
await mqtt_client.async_close()
|
||||
await cloud_client.async_close()
|
||||
raise
|
||||
|
||||
if entry.unique_id != (user_id := str(user["id"])):
|
||||
@@ -142,6 +128,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: OasisDeviceConfigEntry)
|
||||
|
||||
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)
|
||||
|
||||
return True
|
||||
@@ -153,17 +151,18 @@ async def async_unload_entry(
|
||||
"""
|
||||
Cleanly unload an Oasis device config entry.
|
||||
|
||||
Unloads all supported platforms and closes the coordinator connections.
|
||||
Closes the MQTT and cloud clients stored on the entry and unloads all supported platforms.
|
||||
|
||||
Returns:
|
||||
`True` if all platforms were unloaded successfully, `False` otherwise.
|
||||
"""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
try:
|
||||
await entry.runtime_data.async_close()
|
||||
except Exception:
|
||||
_LOGGER.exception("Error closing Oasis coordinator during unload")
|
||||
return unload_ok
|
||||
mqtt_client = entry.runtime_data.mqtt_client
|
||||
await mqtt_client.async_close()
|
||||
|
||||
cloud_client = entry.runtime_data.cloud_client
|
||||
await cloud_client.async_close()
|
||||
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def async_remove_entry(
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
"""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_image_url_from_track, get_track_ids_from_playlist
|
||||
|
||||
_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_image_url_from_track(meta),
|
||||
)
|
||||
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_image_url_from_track(meta),
|
||||
)
|
||||
|
||||
|
||||
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 := get_image_url_from_track(track):
|
||||
return 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
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import async_timeout
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
@@ -33,6 +33,7 @@ class OasisDeviceCoordinator(DataUpdateCoordinator[list[OasisDevice]]):
|
||||
hass: HomeAssistant,
|
||||
config_entry: OasisDeviceConfigEntry,
|
||||
cloud_client: OasisCloudClient,
|
||||
mqtt_client: OasisMqttClient,
|
||||
) -> None:
|
||||
"""
|
||||
Create an OasisDeviceCoordinator that manages OasisDevice discovery and updates using cloud and MQTT clients.
|
||||
@@ -40,6 +41,7 @@ class OasisDeviceCoordinator(DataUpdateCoordinator[list[OasisDevice]]):
|
||||
Parameters:
|
||||
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.
|
||||
mqtt_client (OasisMqttClient): Client for registering devices and coordinating MQTT-based readiness/status.
|
||||
"""
|
||||
super().__init__(
|
||||
hass,
|
||||
@@ -50,64 +52,23 @@ class OasisDeviceCoordinator(DataUpdateCoordinator[list[OasisDevice]]):
|
||||
always_update=False,
|
||||
)
|
||||
self.cloud_client = cloud_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)
|
||||
self.mqtt_client = mqtt_client
|
||||
|
||||
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
|
||||
best-effort trigger status updates for uninitialized devices.
|
||||
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.
|
||||
|
||||
Returns:
|
||||
A list of OasisDevice instances representing devices currently available for the account.
|
||||
|
||||
Raises:
|
||||
UpdateFailed: If an unexpected error persists past retry limits.
|
||||
UpdateFailed: If no devices can be read after repeated attempts or an unexpected error persists past retry limits.
|
||||
"""
|
||||
devices: list[OasisDevice] = []
|
||||
self.attempt += 1
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(30):
|
||||
async with async_timeout.timeout(30):
|
||||
raw_devices = await self.cloud_client.async_get_devices()
|
||||
|
||||
existing_by_serial = {
|
||||
@@ -128,18 +89,15 @@ class OasisDeviceCoordinator(DataUpdateCoordinator[list[OasisDevice]]):
|
||||
name=raw.get("name"),
|
||||
cloud=self.cloud_client,
|
||||
)
|
||||
self._attach_device_listeners(device)
|
||||
|
||||
devices.append(device)
|
||||
|
||||
# Handle devices removed from the account
|
||||
new_serials = {d.serial_number for d in devices if d.serial_number}
|
||||
removed_serials = set(existing_by_serial) - new_serials
|
||||
|
||||
if removed_serials:
|
||||
device_registry = dr.async_get(self.hass)
|
||||
for serial in removed_serials:
|
||||
self._initialized_serials.discard(serial)
|
||||
_LOGGER.info(
|
||||
"Oasis device %s removed from account; cleaning up in HA",
|
||||
serial,
|
||||
@@ -153,20 +111,14 @@ class OasisDeviceCoordinator(DataUpdateCoordinator[list[OasisDevice]]):
|
||||
remove_config_entry_id=self.config_entry.entry_id,
|
||||
)
|
||||
|
||||
# If logged in, but no devices on account, return without starting mqtt
|
||||
# ✅ Valid state: logged in but no devices on account
|
||||
if not devices:
|
||||
_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
|
||||
if devices != self.data:
|
||||
self.last_updated = dt_util.now()
|
||||
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)
|
||||
|
||||
# Best-effort playlists
|
||||
@@ -175,28 +127,53 @@ class OasisDeviceCoordinator(DataUpdateCoordinator[list[OasisDevice]]):
|
||||
except Exception:
|
||||
_LOGGER.exception("Error fetching playlists from cloud")
|
||||
|
||||
# Best-effort: request status for devices that are not yet initialized
|
||||
any_success = False
|
||||
|
||||
for device in devices:
|
||||
try:
|
||||
if not device.is_initialized:
|
||||
await device.async_get_status()
|
||||
device.schedule_track_refresh()
|
||||
except Exception:
|
||||
_LOGGER.exception(
|
||||
"Error requesting status for Oasis device %s; "
|
||||
"will retry on future updates",
|
||||
ready = await self.mqtt_client.wait_until_ready(
|
||||
device, request_status=True
|
||||
)
|
||||
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()
|
||||
|
||||
except Exception:
|
||||
_LOGGER.exception(
|
||||
"Error preparing Oasis device %s", device.serial_number
|
||||
)
|
||||
|
||||
if any_success:
|
||||
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:
|
||||
if self.attempt > 2 or not (devices or self.data):
|
||||
raise UpdateFailed(
|
||||
"Unexpected error talking to Oasis devices "
|
||||
f"after {self.attempt} attempts"
|
||||
) from ex
|
||||
|
||||
_LOGGER.warning(
|
||||
"Error updating Oasis devices; reusing previous data", exc_info=ex
|
||||
)
|
||||
@@ -206,11 +183,3 @@ class OasisDeviceCoordinator(DataUpdateCoordinator[list[OasisDevice]]):
|
||||
self.last_updated = dt_util.now()
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -6,12 +6,14 @@ 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
|
||||
|
||||
from .pyoasiscontrol import OasisCloudClient, OasisDevice
|
||||
from .pyoasiscontrol.const import STATUS_PLAYING, TRACKS
|
||||
from .pyoasiscontrol.const import TRACKS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -42,26 +44,24 @@ async def add_and_play_track(device: OasisDevice, track: int) -> None:
|
||||
track (int): The track id to add and play.
|
||||
|
||||
Raises:
|
||||
TimeoutError: If the operation does not complete within 10 seconds.
|
||||
async_timeout.TimeoutError: If the operation does not complete within 10 seconds.
|
||||
"""
|
||||
async with asyncio.timeout(10):
|
||||
async with async_timeout.timeout(10):
|
||||
if track not in device.playlist:
|
||||
await device.async_add_track_to_playlist(track)
|
||||
|
||||
# Wait for device state to reflect the newly added track
|
||||
while track not in device.playlist:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Ensure the track is positioned immediately after the current track and select it
|
||||
# Move track to next item in the playlist and then select it
|
||||
if (index := device.playlist.index(track)) != device.playlist_index:
|
||||
# Calculate the position after the current track
|
||||
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)
|
||||
|
||||
if device.status_code != STATUS_PLAYING:
|
||||
if device.status_code != 4:
|
||||
await device.async_play()
|
||||
|
||||
|
||||
|
||||
@@ -121,15 +121,13 @@ class OasisDeviceLightEntity(OasisDeviceEntity, LightEntity):
|
||||
"""
|
||||
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:
|
||||
kwargs: Optional control parameters recognized by the method:
|
||||
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).
|
||||
When omitted, uses self.device.brightness_on (last non-zero brightness).
|
||||
When omitted, uses self.device.brightness or self.device.brightness_on.
|
||||
ATTR_RGB_COLOR (tuple[int, int, int]): RGB tuple (R, G, B). When provided, it is
|
||||
converted to a hex color string prefixed with '#'.
|
||||
ATTR_EFFECT (str): Human-readable effect name. When provided, it is mapped to the
|
||||
@@ -142,7 +140,7 @@ class OasisDeviceLightEntity(OasisDeviceEntity, LightEntity):
|
||||
scale = (1, self.device.brightness_max)
|
||||
brightness = math.ceil(brightness_to_value(scale, brightness))
|
||||
else:
|
||||
brightness = self.device.brightness_on
|
||||
brightness = self.device.brightness or self.device.brightness_on
|
||||
|
||||
if color := kwargs.get(ATTR_RGB_COLOR):
|
||||
color = f"#{color_rgb_to_hex(*color)}"
|
||||
|
||||
@@ -3,12 +3,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
BrowseError,
|
||||
BrowseMedia,
|
||||
MediaPlayerEnqueue,
|
||||
MediaPlayerEntity,
|
||||
MediaPlayerEntityDescription,
|
||||
@@ -16,44 +13,16 @@ from homeassistant.components.media_player import (
|
||||
MediaPlayerState,
|
||||
MediaType,
|
||||
RepeatMode,
|
||||
SearchMedia,
|
||||
SearchMediaQuery,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
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 .entity import OasisDeviceEntity
|
||||
from .helpers import get_track_id
|
||||
from .pyoasiscontrol import OasisDevice
|
||||
from .pyoasiscontrol.const import (
|
||||
STATUS_CENTERING,
|
||||
STATUS_DOWNLOADING,
|
||||
STATUS_ERROR,
|
||||
STATUS_LIVE,
|
||||
STATUS_PAUSED,
|
||||
STATUS_PLAYING,
|
||||
STATUS_STOPPED,
|
||||
STATUS_UPDATING,
|
||||
)
|
||||
from .pyoasiscontrol.utils import get_track_ids_from_playlist
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -87,7 +56,6 @@ DESCRIPTOR = MediaPlayerEntityDescription(key="oasis_mini", name=None)
|
||||
class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity):
|
||||
"""Oasis device media player entity."""
|
||||
|
||||
_attr_media_content_type = MediaType.IMAGE
|
||||
_attr_media_image_remotely_accessible = True
|
||||
_attr_supported_features = (
|
||||
MediaPlayerEntityFeature.PAUSE
|
||||
@@ -99,10 +67,13 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity):
|
||||
| MediaPlayerEntityFeature.MEDIA_ENQUEUE
|
||||
| MediaPlayerEntityFeature.CLEAR_PLAYLIST
|
||||
| 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
|
||||
def media_duration(self) -> int | None:
|
||||
"""Duration of current playing media in seconds."""
|
||||
@@ -159,17 +130,17 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity):
|
||||
def state(self) -> MediaPlayerState:
|
||||
"""State of the player."""
|
||||
status_code = self.device.status_code
|
||||
if self.device.error or status_code in (STATUS_ERROR, STATUS_UPDATING):
|
||||
if self.device.error or status_code in (9, 11):
|
||||
return MediaPlayerState.OFF
|
||||
if status_code == STATUS_STOPPED:
|
||||
if status_code == 2:
|
||||
return MediaPlayerState.IDLE
|
||||
if status_code in (STATUS_CENTERING, STATUS_DOWNLOADING):
|
||||
if status_code in (3, 13):
|
||||
return MediaPlayerState.BUFFERING
|
||||
if status_code == STATUS_PLAYING:
|
||||
if status_code == 4:
|
||||
return MediaPlayerState.PLAYING
|
||||
if status_code == STATUS_PAUSED:
|
||||
if status_code == 5:
|
||||
return MediaPlayerState.PAUSED
|
||||
if status_code == STATUS_LIVE:
|
||||
if status_code == 15:
|
||||
return MediaPlayerState.ON
|
||||
return MediaPlayerState.IDLE
|
||||
|
||||
@@ -263,69 +234,24 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity):
|
||||
"""
|
||||
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:
|
||||
media_type (MediaType | str): The media type being requested.
|
||||
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 PLAY.
|
||||
enqueue (MediaPlayerEnqueue | None): How to insert the tracks into the playlist; if omitted defaults to NEXT.
|
||||
|
||||
Raises:
|
||||
ServiceValidationError: If the device is busy or if `media_id` does not contain any valid media identifiers.
|
||||
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.
|
||||
"""
|
||||
self.abort_if_busy()
|
||||
|
||||
track_ids: list[int] = []
|
||||
|
||||
# Entire playlist from browse
|
||||
if media_type == MEDIA_TYPE_OASIS_PLAYLIST:
|
||||
try:
|
||||
playlist_id = int(media_id)
|
||||
except (TypeError, ValueError) as err:
|
||||
if media_type == MediaType.PLAYLIST:
|
||||
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}"},
|
||||
translation_domain=DOMAIN, translation_key="playlists_unsupported"
|
||||
)
|
||||
|
||||
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:
|
||||
track_ids = list(filter(None, map(get_track_id, media_id.split(","))))
|
||||
if not track_ids:
|
||||
track = list(filter(None, map(get_track_id, media_id.split(","))))
|
||||
if not track:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_media",
|
||||
@@ -333,31 +259,29 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity):
|
||||
)
|
||||
|
||||
device = self.device
|
||||
enqueue = MediaPlayerEnqueue.PLAY if not enqueue else enqueue
|
||||
|
||||
if enqueue == MediaPlayerEnqueue.ADD:
|
||||
await device.async_add_track_to_playlist(track_ids)
|
||||
return
|
||||
|
||||
enqueue = MediaPlayerEnqueue.NEXT if not enqueue else enqueue
|
||||
if enqueue == MediaPlayerEnqueue.REPLACE:
|
||||
await device.async_set_playlist(track_ids, start_playing=True)
|
||||
return
|
||||
await device.async_set_playlist(track)
|
||||
else:
|
||||
await device.async_add_track_to_playlist(track)
|
||||
|
||||
insert_at = (device.playlist_index or 0) + 1
|
||||
original_len = len(device.playlist)
|
||||
await device.async_add_track_to_playlist(track_ids)
|
||||
|
||||
# Move each newly-added track into the desired position
|
||||
for offset, _track_id in enumerate(track_ids):
|
||||
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
|
||||
if enqueue in (MediaPlayerEnqueue.NEXT, MediaPlayerEnqueue.PLAY):
|
||||
# Move track to next item in the playlist
|
||||
new_tracks = 1 if isinstance(track, int) else len(track)
|
||||
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 (
|
||||
enqueue in (MediaPlayerEnqueue.PLAY, MediaPlayerEnqueue.REPLACE)
|
||||
and device.status_code != 4
|
||||
):
|
||||
await device.async_change_track(min(insert_at, original_len))
|
||||
await device.async_play()
|
||||
|
||||
async def async_clear_playlist(self) -> None:
|
||||
@@ -369,71 +293,3 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity):
|
||||
"""
|
||||
self.abort_if_busy()
|
||||
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)
|
||||
|
||||
@@ -54,7 +54,7 @@ class OasisMqttClient(OasisClientProtocol):
|
||||
_connected_event: Event signaled when a connection is established.
|
||||
_stop_event: Event signaled to request the loop to stop.
|
||||
_devices: Mapping of device serial to OasisDevice instances.
|
||||
_initialized_events: Per-serial events signaled on receiving the full device initialization.
|
||||
_first_status_events: Per-serial events signaled on receiving the first STATUS message.
|
||||
_mac_events: Per-serial events signaled when a device MAC address is received.
|
||||
_subscribed_serials: Set of serials currently subscribed to STATUS topics.
|
||||
_subscription_lock: Lock protecting subscribe/unsubscribe operations.
|
||||
@@ -71,7 +71,7 @@ class OasisMqttClient(OasisClientProtocol):
|
||||
self._devices: dict[str, OasisDevice] = {}
|
||||
|
||||
# Per-device events
|
||||
self._initialized_events: dict[str, asyncio.Event] = {}
|
||||
self._first_status_events: dict[str, asyncio.Event] = {}
|
||||
self._mac_events: dict[str, asyncio.Event] = {}
|
||||
|
||||
# Subscription bookkeeping
|
||||
@@ -83,25 +83,11 @@ class OasisMqttClient(OasisClientProtocol):
|
||||
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:
|
||||
"""
|
||||
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 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.
|
||||
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.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): The device instance to register.
|
||||
@@ -116,7 +102,7 @@ class OasisMqttClient(OasisClientProtocol):
|
||||
self._devices[serial] = device
|
||||
|
||||
# Ensure we have per-device events
|
||||
self._initialized_events.setdefault(serial, asyncio.Event())
|
||||
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
|
||||
@@ -148,7 +134,7 @@ class OasisMqttClient(OasisClientProtocol):
|
||||
"""
|
||||
Unregisters a device from MQTT routing and cleans up related per-device state.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): The device to unregister; must have `serial_number` set.
|
||||
@@ -158,7 +144,7 @@ class OasisMqttClient(OasisClientProtocol):
|
||||
return
|
||||
|
||||
self._devices.pop(serial, None)
|
||||
self._initialized_events.pop(serial, None)
|
||||
self._first_status_events.pop(serial, None)
|
||||
self._mac_events.pop(serial, None)
|
||||
|
||||
# If connected and we were subscribed, unsubscribe
|
||||
@@ -215,7 +201,6 @@ class OasisMqttClient(OasisClientProtocol):
|
||||
self._subscribed_serials.clear()
|
||||
for serial, device in self._devices.items():
|
||||
await self._subscribe_serial(serial)
|
||||
if not device.is_sleeping:
|
||||
await self.async_get_all(device)
|
||||
|
||||
def start(self) -> None:
|
||||
@@ -233,49 +218,32 @@ class OasisMqttClient(OasisClientProtocol):
|
||||
"""
|
||||
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 drops any pending commands.
|
||||
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.
|
||||
"""
|
||||
_LOGGER.debug("MQTT stop() called - beginning shutdown sequence")
|
||||
self._stop_event.set()
|
||||
|
||||
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()
|
||||
try:
|
||||
await self._loop_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
_LOGGER.debug("MQTT background task cancelled")
|
||||
|
||||
if self._client:
|
||||
_LOGGER.debug("Disconnecting MQTT client from broker")
|
||||
try:
|
||||
await self._client.disconnect()
|
||||
_LOGGER.debug("MQTT client disconnected")
|
||||
except Exception:
|
||||
_LOGGER.exception("Error disconnecting MQTT client")
|
||||
finally:
|
||||
self._client = None
|
||||
|
||||
# Drop queued commands
|
||||
if not self._command_queue.empty():
|
||||
_LOGGER.debug("Dropping queued commands")
|
||||
dropped = 0
|
||||
# Drop pending commands on stop
|
||||
while not self._command_queue.empty():
|
||||
try:
|
||||
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(
|
||||
self, device: OasisDevice, timeout: float = 10.0, request_status: bool = True
|
||||
@@ -287,11 +255,11 @@ class OasisMqttClient(OasisClientProtocol):
|
||||
|
||||
Parameters:
|
||||
device (OasisDevice): The device to wait for; must have `serial_number` set.
|
||||
timeout (float): Maximum seconds to wait for connection and for the device to be initialized.
|
||||
timeout (float): Maximum seconds to wait for connection and for the first STATUS message.
|
||||
request_status (bool): If True, issue a status refresh after connection to encourage a STATUS update.
|
||||
|
||||
Returns:
|
||||
bool: `True` if the device was initialized within the timeout, `False` otherwise.
|
||||
bool: `True` if the device's first STATUS message was observed within the timeout, `False` otherwise.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the provided device does not have a `serial_number`.
|
||||
@@ -300,7 +268,7 @@ class OasisMqttClient(OasisClientProtocol):
|
||||
if not serial:
|
||||
raise RuntimeError("Device has no serial_number set")
|
||||
|
||||
is_initialized_event = self._initialized_events.setdefault(
|
||||
first_status_event = self._first_status_events.setdefault(
|
||||
serial, asyncio.Event()
|
||||
)
|
||||
|
||||
@@ -318,6 +286,7 @@ class OasisMqttClient(OasisClientProtocol):
|
||||
# Optionally request a status refresh
|
||||
if request_status:
|
||||
try:
|
||||
first_status_event.clear()
|
||||
await self.async_get_status(device)
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.debug(
|
||||
@@ -325,18 +294,17 @@ class OasisMqttClient(OasisClientProtocol):
|
||||
serial,
|
||||
)
|
||||
|
||||
# Wait for initialization
|
||||
# Wait for first status
|
||||
try:
|
||||
await asyncio.wait_for(is_initialized_event.wait(), timeout=timeout)
|
||||
await asyncio.wait_for(first_status_event.wait(), timeout=timeout)
|
||||
return True
|
||||
except asyncio.TimeoutError:
|
||||
_LOGGER.debug(
|
||||
"Timeout (%.1fs) waiting for initialization from %s",
|
||||
"Timeout (%.1fs) waiting for first STATUS message from %s",
|
||||
timeout,
|
||||
serial,
|
||||
)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
async def async_get_mac_address(self, device: OasisDevice) -> str | None:
|
||||
"""
|
||||
@@ -618,9 +586,6 @@ class OasisMqttClient(OasisClientProtocol):
|
||||
return
|
||||
|
||||
while not self._command_queue.empty():
|
||||
if not self._client:
|
||||
break
|
||||
|
||||
try:
|
||||
serial, payload = self._command_queue.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
@@ -634,6 +599,7 @@ class OasisMqttClient(OasisClientProtocol):
|
||||
serial,
|
||||
payload,
|
||||
)
|
||||
self._command_queue.task_done()
|
||||
continue
|
||||
|
||||
topic = f"{serial}/COMMAND/CMD"
|
||||
@@ -643,10 +609,11 @@ class OasisMqttClient(OasisClientProtocol):
|
||||
_LOGGER.debug(
|
||||
"Failed to flush queued command for %s, re-queuing", serial
|
||||
)
|
||||
# Put it back; we'll try again on next reconnect
|
||||
# Put it back and break; we'll try again on next reconnect
|
||||
await self._enqueue_command(serial, payload)
|
||||
finally:
|
||||
# Ensure we always balance the get(), even on cancellation
|
||||
self._command_queue.task_done()
|
||||
break
|
||||
|
||||
self._command_queue.task_done()
|
||||
|
||||
async def _publish_command(
|
||||
@@ -692,15 +659,9 @@ class OasisMqttClient(OasisClientProtocol):
|
||||
|
||||
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()
|
||||
tls_context = await loop.run_in_executor(None, ssl.create_default_context)
|
||||
@@ -766,7 +727,7 @@ class OasisMqttClient(OasisClientProtocol):
|
||||
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"
|
||||
status, sets the per-device MAC event to signal arrival of the MAC address. Always sets the
|
||||
per-device initialization event once the appropriate messages are processed for that serial.
|
||||
per-device first-status event once any status is processed for that serial.
|
||||
|
||||
Parameters:
|
||||
msg (aiomqtt.Message): Incoming MQTT message; topic identifies device serial and status.
|
||||
@@ -796,11 +757,7 @@ class OasisMqttClient(OasisClientProtocol):
|
||||
elif status_name == "OASIS_SPEEED":
|
||||
data["ball_speed"] = int(payload)
|
||||
elif status_name == "JOBLIST":
|
||||
data["playlist"] = [
|
||||
track_id
|
||||
for track_str in payload.split(",")
|
||||
if (track_id := _parse_int(track_str))
|
||||
]
|
||||
data["playlist"] = [int(x) for x in payload.split(",") if x]
|
||||
elif status_name == "CURRENTJOB":
|
||||
data["playlist_index"] = int(payload)
|
||||
elif status_name == "CURRENTLINE":
|
||||
@@ -870,8 +827,8 @@ class OasisMqttClient(OasisClientProtocol):
|
||||
if data:
|
||||
device.update_from_status_dict(data)
|
||||
|
||||
is_initialized_event = self._initialized_events.setdefault(
|
||||
first_status_event = self._first_status_events.setdefault(
|
||||
serial, asyncio.Event()
|
||||
)
|
||||
if not is_initialized_event.is_set() and device.is_initialized:
|
||||
is_initialized_event.set()
|
||||
if not first_status_event.is_set():
|
||||
first_status_event.set()
|
||||
|
||||
@@ -12,19 +12,19 @@ try:
|
||||
TRACKS: Final[dict[int, dict[str, Any]]] = {
|
||||
int(k): v for k, v in json.load(file).items()
|
||||
}
|
||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||
except Exception: # ignore: broad-except
|
||||
TRACKS = {}
|
||||
|
||||
AUTOPLAY_MAP: Final[dict[str, str]] = {
|
||||
"1": "Off", # display off (disabled) first
|
||||
"0": "Immediately",
|
||||
"2": "After 5 minutes",
|
||||
"3": "After 10 minutes",
|
||||
"4": "After 30 minutes",
|
||||
"6": "After 1 hour",
|
||||
"7": "After 6 hours",
|
||||
"8": "After 12 hours",
|
||||
"5": "After 24 hours", # purposefully placed so time is incrementally displayed
|
||||
"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",
|
||||
}
|
||||
|
||||
ERROR_CODE_MAP: Final[dict[int, str]] = {
|
||||
@@ -94,28 +94,17 @@ LED_EFFECTS: Final[dict[str, str]] = {
|
||||
"41": "Color Comets",
|
||||
}
|
||||
|
||||
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_SLEEPING: Final = 6
|
||||
STATUS_CODE_MAP: Final[dict[int, str]] = {
|
||||
STATUS_BOOTING: "booting",
|
||||
STATUS_STOPPED: "stopped",
|
||||
STATUS_CENTERING: "centering",
|
||||
STATUS_PLAYING: "playing",
|
||||
STATUS_PAUSED: "paused",
|
||||
STATUS_SLEEPING: "sleeping",
|
||||
STATUS_ERROR: "error",
|
||||
STATUS_UPDATING: "updating",
|
||||
STATUS_DOWNLOADING: "downloading",
|
||||
STATUS_BUSY: "busy",
|
||||
STATUS_LIVE: "live",
|
||||
0: "booting",
|
||||
2: "stopped",
|
||||
3: "centering",
|
||||
4: "playing",
|
||||
5: "paused",
|
||||
STATUS_CODE_SLEEPING: "sleeping",
|
||||
9: "error",
|
||||
11: "updating",
|
||||
13: "downloading",
|
||||
14: "busy",
|
||||
15: "live",
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, Callable, Final, Iterable
|
||||
|
||||
@@ -11,19 +10,10 @@ from .const import (
|
||||
ERROR_CODE_MAP,
|
||||
LED_EFFECTS,
|
||||
STATUS_CODE_MAP,
|
||||
STATUS_ERROR,
|
||||
STATUS_PLAYING,
|
||||
STATUS_SLEEPING,
|
||||
STATUS_CODE_SLEEPING,
|
||||
TRACKS,
|
||||
)
|
||||
from .utils import (
|
||||
_bit_to_bool,
|
||||
_parse_int,
|
||||
create_svg,
|
||||
decrypt_svg_content,
|
||||
get_image_url_from_track,
|
||||
now,
|
||||
)
|
||||
from .utils import _bit_to_bool, _parse_int, create_svg, decrypt_svg_content
|
||||
|
||||
if TYPE_CHECKING: # avoid runtime circular imports
|
||||
from .clients import OasisCloudClient
|
||||
@@ -79,6 +69,7 @@ class OasisDevice:
|
||||
cloud: OasisCloudClient | None = None,
|
||||
client: OasisClientProtocol | None = None,
|
||||
) -> None:
|
||||
# Transport
|
||||
"""
|
||||
Initialize an OasisDevice with identification, network, transport references, and default state fields.
|
||||
|
||||
@@ -142,9 +133,6 @@ class OasisDevice:
|
||||
self._track: dict | None = None
|
||||
self._track_task: asyncio.Task | None = None
|
||||
|
||||
# Diagnostic metadata
|
||||
self.last_updated: datetime | None = None
|
||||
|
||||
@property
|
||||
def brightness(self) -> int:
|
||||
"""
|
||||
@@ -167,11 +155,6 @@ class OasisDevice:
|
||||
if 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
|
||||
def is_sleeping(self) -> bool:
|
||||
"""
|
||||
@@ -180,7 +163,7 @@ class OasisDevice:
|
||||
Returns:
|
||||
`true` if the device is sleeping, `false` otherwise.
|
||||
"""
|
||||
return self.status_code == STATUS_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."""
|
||||
@@ -264,8 +247,6 @@ class OasisDevice:
|
||||
if changed:
|
||||
self._notify_listeners()
|
||||
|
||||
self.last_updated = now()
|
||||
|
||||
def parse_status_string(self, raw_status: str) -> dict[str, Any] | None:
|
||||
"""
|
||||
Parse a semicolon-separated device status string into a structured state dictionary.
|
||||
@@ -296,11 +277,7 @@ class OasisDevice:
|
||||
)
|
||||
return None
|
||||
|
||||
playlist = [
|
||||
track_id
|
||||
for track_str in values[3].split(",")
|
||||
if (track_id := _parse_int(track_str))
|
||||
]
|
||||
playlist = [_parse_int(track) for track in values[3].split(",") if track]
|
||||
|
||||
try:
|
||||
status: dict[str, Any] = {
|
||||
@@ -366,7 +343,7 @@ class OasisDevice:
|
||||
Returns:
|
||||
str: The mapped error message when the device status indicates an error (status code 9); `None` otherwise.
|
||||
"""
|
||||
if self.status_code == STATUS_ERROR:
|
||||
if self.status_code == 9:
|
||||
return ERROR_CODE_MAP.get(self.error, f"Unknown ({self.error})")
|
||||
return None
|
||||
|
||||
@@ -413,9 +390,11 @@ class OasisDevice:
|
||||
Get the full HTTPS URL for the current track's image if available.
|
||||
|
||||
Returns:
|
||||
str: Full URL to the track image or `None` if no image is available.
|
||||
str: Full URL to the track image (https://app.grounded.so/uploads/<image>), or `None` if no image is available.
|
||||
"""
|
||||
return get_image_url_from_track(self.track)
|
||||
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:
|
||||
@@ -511,11 +490,6 @@ class OasisDevice:
|
||||
except Exception:
|
||||
_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:
|
||||
"""
|
||||
Get the device MAC address, requesting it from the attached transport client if not already known.
|
||||
@@ -632,10 +606,6 @@ class OasisDevice:
|
||||
client = self._require_client()
|
||||
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:
|
||||
"""
|
||||
Add one or more tracks to the device's playlist via the attached client.
|
||||
@@ -653,33 +623,21 @@ class OasisDevice:
|
||||
client = self._require_client()
|
||||
await client.async_send_add_joblist_command(self, tracks)
|
||||
|
||||
async def async_set_playlist(
|
||||
self, playlist: int | Iterable[int], *, start_playing: bool | None = None
|
||||
) -> None:
|
||||
async def async_set_playlist(self, playlist: int | Iterable[int]) -> None:
|
||||
"""
|
||||
Set the device's playlist to the provided track or tracks.
|
||||
|
||||
Accepts a single track ID or an iterable of track IDs, stops the device,
|
||||
replaces the playlist, and resumes playback based on the `start_playing`
|
||||
parameter or, if unspecified, the device's prior playing state.
|
||||
Accepts a single track ID or an iterable of track IDs and replaces the device's playlist by sending the corresponding command to the attached client.
|
||||
|
||||
Parameters:
|
||||
playlist (int | Iterable[int]):
|
||||
A single track ID or an iterable of track IDs to set as the new playlist.
|
||||
start_playing (bool | None, keyword-only):
|
||||
Whether to start playback after updating the playlist. If None (default),
|
||||
playback will resume only if the device was previously playing and the
|
||||
new playlist is non-empty.
|
||||
playlist (int | Iterable[int]): A single track ID or an iterable of track IDs to set as the new playlist.
|
||||
"""
|
||||
playlist = [playlist] if isinstance(playlist, int) else list(playlist)
|
||||
if start_playing is None:
|
||||
start_playing = self.status_code == STATUS_PLAYING
|
||||
|
||||
if isinstance(playlist, int):
|
||||
playlist_list = [playlist]
|
||||
else:
|
||||
playlist_list = list(playlist)
|
||||
client = self._require_client()
|
||||
await client.async_send_stop_command(self) # needed before replacing playlist
|
||||
await client.async_send_set_playlist_command(self, playlist)
|
||||
if start_playing and playlist:
|
||||
await client.async_send_play_command(self)
|
||||
await client.async_send_set_playlist_command(self, playlist_list)
|
||||
|
||||
async def async_set_repeat_playlist(self, repeat: bool) -> None:
|
||||
"""
|
||||
|
||||
@@ -22,8 +22,6 @@ COLOR_LIGHT_SHADE = ("#FFFFFF", "#86888F")
|
||||
COLOR_MEDIUM_SHADE = ("#E5E2DE", "#86888F")
|
||||
COLOR_MEDIUM_TINT = ("#B8B8B8", "#FFFFFF")
|
||||
|
||||
IMAGE_URL = "https://app.grounded.so/uploads/{image}"
|
||||
|
||||
|
||||
def _bit_to_bool(val: str) -> bool:
|
||||
"""Convert a bit string to bool."""
|
||||
@@ -202,17 +200,5 @@ def decrypt_svg_content(svg_content: dict[str, str]):
|
||||
return decrypted
|
||||
|
||||
|
||||
def get_image_url_from_track(track: dict[str, Any] | None) -> str | None:
|
||||
"""Get the image URL from a track."""
|
||||
if not isinstance(track, dict):
|
||||
return None
|
||||
return IMAGE_URL.format(image=image) if (image := track.get("image")) else None
|
||||
|
||||
|
||||
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 now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
@@ -2,10 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
@@ -71,13 +68,6 @@ DESCRIPTORS = [
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="last_updated",
|
||||
translation_key="last_updated",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
]
|
||||
DESCRIPTORS.extend(
|
||||
SensorEntityDescription(
|
||||
@@ -94,6 +84,11 @@ class OasisDeviceSensorEntity(OasisDeviceEntity, SensorEntity):
|
||||
"""Oasis device sensor entity."""
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | int | float | datetime | None:
|
||||
"""Provide the current sensor value from the underlying device."""
|
||||
def native_value(self) -> str | None:
|
||||
"""
|
||||
Provide the current sensor value from the underlying device.
|
||||
|
||||
Returns:
|
||||
`str` with the sensor's current value, or `None` if the attribute is not present or has no value. The value is taken from the device attribute named by the entity description's `key`.
|
||||
"""
|
||||
return getattr(self.device, self.entity_description.key)
|
||||
|
||||
@@ -76,15 +76,15 @@
|
||||
"autoplay": {
|
||||
"name": "Autoplay",
|
||||
"state": {
|
||||
"1": "Off",
|
||||
"0": "Immediately",
|
||||
"2": "After 5 minutes",
|
||||
"3": "After 10 minutes",
|
||||
"4": "After 30 minutes",
|
||||
"6": "After 1 hour",
|
||||
"7": "After 6 hours",
|
||||
"8": "After 12 hours",
|
||||
"5": "After 24 hours"
|
||||
"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": {
|
||||
@@ -125,9 +125,6 @@
|
||||
"18": "Error while downloading the job file"
|
||||
}
|
||||
},
|
||||
"last_updated": {
|
||||
"name": "Last updated"
|
||||
},
|
||||
"led_color_id": {
|
||||
"name": "LED color ID"
|
||||
},
|
||||
@@ -160,6 +157,9 @@
|
||||
},
|
||||
"invalid_media": {
|
||||
"message": "Invalid media: {media}"
|
||||
},
|
||||
"playlists_unsupported": {
|
||||
"message": "Playlists are not currently supported"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,15 +76,15 @@
|
||||
"autoplay": {
|
||||
"name": "Autoplay",
|
||||
"state": {
|
||||
"1": "Off",
|
||||
"0": "Immediately",
|
||||
"2": "After 5 minutes",
|
||||
"3": "After 10 minutes",
|
||||
"4": "After 30 minutes",
|
||||
"6": "After 1 hour",
|
||||
"7": "After 6 hours",
|
||||
"8": "After 12 hours",
|
||||
"5": "After 24 hours"
|
||||
"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": {
|
||||
@@ -125,9 +125,6 @@
|
||||
"18": "Error while downloading the job file"
|
||||
}
|
||||
},
|
||||
"last_updated": {
|
||||
"name": "Last updated"
|
||||
},
|
||||
"led_color_id": {
|
||||
"name": "LED color ID"
|
||||
},
|
||||
@@ -160,6 +157,9 @@
|
||||
},
|
||||
"invalid_media": {
|
||||
"message": "Invalid media: {media}"
|
||||
},
|
||||
"playlists_unsupported": {
|
||||
"message": "Playlists are not currently supported"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from . import OasisDeviceConfigEntry, setup_platform_from_coordinator
|
||||
from .entity import OasisDeviceEntity
|
||||
from .pyoasiscontrol import OasisDevice
|
||||
from .pyoasiscontrol.const import STATUS_UPDATING
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -72,7 +71,7 @@ class OasisDeviceUpdateEntity(OasisDeviceEntity, UpdateEntity):
|
||||
@property
|
||||
def in_progress(self) -> bool | int:
|
||||
"""Update installation progress."""
|
||||
if self.device.status_code == STATUS_UPDATING:
|
||||
if self.device.status_code == 11:
|
||||
return self.device.download_progress
|
||||
return False
|
||||
|
||||
|
||||
Reference in New Issue
Block a user