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

26 Commits

Author SHA1 Message Date
Nathan Spencer
505fca3635 Merge pull request #110 from natekspencer/last-updated-sensor
Add an additional last updated diagnostic sensor for devices
2025-11-26 15:04:19 -07:00
Nathan Spencer
cdca084212 Address PR comments 2025-11-26 22:01:02 +00:00
Nathan Spencer
dfaeb382da Add an additional last updated diagnostic sensor for devices 2025-11-26 21:53:56 +00:00
Nathan Spencer
8a2dc8e9bc Merge pull request #109 from natekspencer/set-playlist
Make device own required steps when setting playlist
2025-11-26 14:52:24 -07:00
Nathan Spencer
8467c50215 Address PR 2025-11-26 21:47:15 +00:00
Nathan Spencer
7d7675dcb1 Address PR comments 2025-11-26 21:36:23 +00:00
Nathan Spencer
fb360be616 Add additional keyword argument to set playlist to allow play control 2025-11-26 21:01:18 +00:00
Nathan Spencer
4336f658c4 Make device own required steps when setting playlist 2025-11-26 20:32:12 +00:00
Nathan Spencer
50773c582c Merge pull request #108 from natekspencer/validate-workflow
Updates the GitHub Actions validation workflow with configuration refinements
2025-11-26 13:08:50 -07:00
Nathan Spencer
461165673c Update validate workflow 2025-11-26 20:03:08 +00:00
Nathan Spencer
8d3cc00ebc Merge pull request #107 from natekspencer/track-image-url
Use helper to get image from track dictionary
2025-11-26 12:52:09 -07:00
Nathan Spencer
c4fd6a7ef6 Use helper to get image from track dictionary 2025-11-26 19:46:02 +00:00
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
Nathan Spencer
379b6f67f2 Swap out direct HTTP connection with server MQTT connection to handle firmware 2.60+ (#98)
* Switch to using mqtt

* Better mqtt handling when connection is interrupted

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

* Add additional helpers

* Dynamically handle devices and other enhancements

* 📝 Add docstrings to `mqtt`

Docstrings generation was requested by @natekspencer.

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

The following files were modified:

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

* Fix formatting in transport.py

* Replace tabs with spaces

* Use tuples instead of sets for descriptors

* Encode svg in image entity

* Fix iot_class

* Fix tracks list url

* Ensure update_tracks closes the connection

* Fix number typing and docstring

* Fix docstring in update_tracks

* Cache playlist based on type

* Fix formatting in device.py

* Add missing async_send_auto_clean_command to http client

* Propagate UnauthenticatedError from async_get_track_info

* Adjust exceptions

* Move create_client outside of try block in config_flow

* Formatting

* Address PR comments

* Formatting

* Add noqa: ARG001 on unused hass

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

* Address PR again

* PR fixes

* Pass config entry to coordinator

* Remove async_timeout (thanks ChatGPT... not)

* Address PR

* Replace magic numbers for status code

* Update autoplay wording/ordering

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-11-24 01:09:23 -07:00
37 changed files with 2658 additions and 445 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

@@ -1,22 +1,25 @@
name: Validate repo name: Validate repo
on: on:
push:
pull_request: pull_request:
push:
branches:
- main
schedule: schedule:
- cron: "0 0 * * *" - cron: "0 0 * * *"
jobs: jobs:
hassfest: hassfest:
name: Validate with hassfest name: Validate with hassfest
runs-on: "ubuntu-latest" runs-on: ubuntu-latest
steps: steps:
- uses: "actions/checkout@v4" - uses: actions/checkout@v6
- uses: "home-assistant/actions/hassfest@master" - uses: home-assistant/actions/hassfest@master
hacs: hacs:
name: Validate with HACS name: Validate with HACS
runs-on: "ubuntu-latest" runs-on: ubuntu-latest
steps: steps:
- uses: "hacs/action@main" - uses: hacs/action@main
with: with:
category: "integration" category: integration

View File

@@ -1,7 +1,7 @@
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version. # Ruff version.
rev: v0.9.10 rev: v0.14.6
hooks: hooks:
# Run the linter. # Run the linter.
- id: ruff - id: ruff

View File

@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2024 Nathan Spencer Copyright (c) Nathan Spencer
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@@ -58,7 +58,7 @@ After this integration is set up, you can configure the integration to connect t
# Actions # Actions
The media player entity supports various actions, including managing the playlist queue. You can specify a track by its ID or name. If using a track name, it must match an entry in the [tracks list](custom_components/oasis_mini/pyoasismini/tracks.json). To specify multiple tracks, separate them with commas. An example is below: The media player entity supports various actions, including managing the playlist queue. You can specify a track by its ID or name. If using a track name, it must match an entry in the [tracks list](custom_components/oasis_mini/pyoasiscontrol/tracks.json). To specify multiple tracks, separate them with commas. An example is below:
```yaml ```yaml
action: media_player.play_media action: media_player.play_media

151
custom_components/oasis_mini/__init__.py Executable file → Normal file
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]
@@ -41,22 +41,33 @@ PLATFORMS = [
def setup_platform_from_coordinator( def setup_platform_from_coordinator(
entry: OasisDeviceConfigEntry, entry: OasisDeviceConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
make_entities: Callable[[OasisDevice], Iterable[OasisDeviceEntity]], make_entities: Callable[[Iterable[OasisDevice]], Iterable[OasisDeviceEntity]],
update_before_add: bool = False, update_before_add: bool = False,
) -> None: ) -> None:
"""Generic pattern: add entities per device, including newly discovered ones.""" """
coordinator = entry.runtime_data Populate entities for devices managed by the coordinator and add entities for any devices discovered later.
This registers a listener on the coordinator to detect newly discovered devices by serial number and calls `make_entities` to construct entity objects for those devices, passing them to `async_add_entities`. The initial device set is processed immediately; subsequent discoveries are handled via the coordinator listener.
Parameters:
entry: Config entry containing the coordinator in its `runtime_data`.
async_add_entities: Home Assistant callback to add entities to the platform.
make_entities: Callable that accepts an iterable of `OasisDevice` objects and returns an iterable of `OasisDeviceEntity` instances to add.
update_before_add: If true, entities will be updated before being added.
"""
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."""
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)
@@ -68,29 +79,60 @@ 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:
"""Set up Oasis devices from a config entry.""" """
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:
True if the config entry was set up successfully.
"""
cloud_client = create_client(hass, entry.data) cloud_client = create_client(hass, entry.data)
try: try:
user = await cloud_client.async_get_user() user = await cloud_client.async_get_user()
except UnauthenticatedError as err: except UnauthenticatedError as err:
await cloud_client.async_close()
raise ConfigEntryAuthFailed(err) from err raise ConfigEntryAuthFailed(err) from err
except Exception:
await cloud_client.async_close()
raise
mqtt_client = OasisMqttClient() coordinator = OasisDeviceCoordinator(hass, entry, cloud_client)
mqtt_client.start()
coordinator = OasisDeviceCoordinator(hass, cloud_client, mqtt_client)
try: try:
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()
except Exception as ex: except Exception:
_LOGGER.exception(ex) await coordinator.async_close()
raise
if entry.unique_id != (user_id := str(user["id"])): if entry.unique_id != (user_id := str(user["id"])):
hass.config_entries.async_update_entry(entry, unique_id=user_id) hass.config_entries.async_update_entry(entry, unique_id=user_id)
@@ -100,13 +142,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: OasisDeviceConfigEntry)
entry.runtime_data = coordinator entry.runtime_data = coordinator
def _on_oasis_update() -> None:
coordinator.last_updated = dt_util.now()
coordinator.async_update_listeners()
for device in coordinator.data:
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
@@ -115,30 +150,55 @@ async def async_setup_entry(hass: HomeAssistant, entry: OasisDeviceConfigEntry)
async def async_unload_entry( async def async_unload_entry(
hass: HomeAssistant, entry: OasisDeviceConfigEntry hass: HomeAssistant, entry: OasisDeviceConfigEntry
) -> bool: ) -> bool:
"""Unload a config entry.""" """
mqtt_client = entry.runtime_data.mqtt_client Cleanly unload an Oasis device config entry.
await mqtt_client.async_close()
cloud_client = entry.runtime_data.cloud_client Unloads all supported platforms and closes the coordinator connections.
await cloud_client.async_close()
return await hass.config_entries.async_unload_platforms(entry, 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
async def async_remove_entry( async def async_remove_entry(
hass: HomeAssistant, entry: OasisDeviceConfigEntry hass: HomeAssistant, entry: OasisDeviceConfigEntry
) -> None: ) -> None:
"""Handle removal of an entry.""" """
Perform logout and cleanup for the cloud client associated with the config entry.
Attempts to call the cloud client's logout method and logs any exception encountered, then ensures the client is closed.
"""
cloud_client = create_client(hass, entry.data) cloud_client = create_client(hass, entry.data)
try: try:
await cloud_client.async_logout() await cloud_client.async_logout()
except Exception as ex: except Exception:
_LOGGER.exception(ex) _LOGGER.exception("Error attempting to logout from the cloud")
await cloud_client.async_close() await cloud_client.async_close()
async def async_migrate_entry(hass: HomeAssistant, entry: OasisDeviceConfigEntry): async def async_migrate_entry(
"""Migrate old entry.""" hass: HomeAssistant, entry: OasisDeviceConfigEntry
) -> bool:
"""
Migrate an Oasis config entry to the current schema (minor version 3).
Performs in-place migrations for older entries:
- Renames select entity unique IDs ending with `-playlist` to `-queue`.
- When migrating to the auth-required schema, moves relevant options into entry data and clears options.
- Updates the config entry's data, options, minor_version, title (from CONF_EMAIL or "Oasis Control"), unique_id, and version.
Parameters:
entry: The config entry to migrate.
Returns:
`True` if migration succeeded, `False` if migration could not be performed (e.g., entry.version is greater than supported).
"""
_LOGGER.debug( _LOGGER.debug(
"Migrating configuration from version %s.%s", entry.version, entry.minor_version "Migrating configuration from version %s.%s", entry.version, entry.minor_version
) )
@@ -157,7 +217,15 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OasisDeviceConfigEntry
def migrate_unique_id( def migrate_unique_id(
entity_entry: er.RegistryEntry, entity_entry: er.RegistryEntry,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
"""Migrate the playlist unique ID to queue.""" """
Update a registry entry's unique_id suffix from "-playlist" to "-queue" when applicable.
Parameters:
entity_entry (er.RegistryEntry): Registry entry to inspect.
Returns:
dict[str, Any] | None: A mapping {"new_unique_id": <new id>} if the entry is in the "select" domain and its unique_id ends with "-playlist"; otherwise `None`.
"""
if entity_entry.domain == "select" and entity_entry.unique_id.endswith( if entity_entry.domain == "select" and entity_entry.unique_id.endswith(
"-playlist" "-playlist"
): ):
@@ -192,9 +260,20 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OasisDeviceConfigEntry
async def async_remove_config_entry_device( async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: OasisDeviceConfigEntry, device_entry: DeviceEntry hass: HomeAssistant, # noqa: ARG001
config_entry: OasisDeviceConfigEntry,
device_entry: DeviceEntry,
) -> bool: ) -> bool:
"""Remove a config entry from a device.""" """
Determine whether the config entry is no longer associated with the given device.
Parameters:
config_entry (OasisDeviceConfigEntry): The config entry whose runtime data contains device serial numbers.
device_entry (DeviceEntry): The device registry entry to check for matching identifiers.
Returns:
bool: `true` if none of the device's identifiers match serial numbers present in the config entry's runtime data, `false` otherwise.
"""
current_serials = {d.serial_number for d in (config_entry.runtime_data.data or [])} current_serials = {d.serial_number for d in (config_entry.runtime_data.data or [])}
return not any( return not any(
identifier identifier

View File

@@ -17,13 +17,29 @@ from .pyoasiscontrol import OasisDevice
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant, # noqa: ARG001
entry: OasisDeviceConfigEntry, entry: OasisDeviceConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Oasis device sensors using config entry.""" """
Set up Oasis device binary sensor entities for a config entry.
Registers a factory that creates an OasisDeviceBinarySensorEntity for each device and descriptor defined in DESCRIPTORS, and forwards those entities to Home Assistant via the provided add-entities callback.
Parameters:
entry (OasisDeviceConfigEntry): Configuration entry for the Oasis integration containing runtime data and coordinator used to create entities.
"""
def make_entities(new_devices: list[OasisDevice]): def make_entities(new_devices: list[OasisDevice]):
"""
Create binary sensor entity instances for each provided Oasis device using the module's descriptors.
Parameters:
new_devices (list[OasisDevice]): Devices to generate entities for.
Returns:
list[OasisDeviceBinarySensorEntity]: A list of binary sensor entities pairing each device with every descriptor in DESCRIPTORS.
"""
return [ return [
OasisDeviceBinarySensorEntity(entry.runtime_data, device, descriptor) OasisDeviceBinarySensorEntity(entry.runtime_data, device, descriptor)
for device in new_devices for device in new_devices
@@ -33,7 +49,7 @@ async def async_setup_entry(
setup_platform_from_coordinator(entry, async_add_entities, make_entities) setup_platform_from_coordinator(entry, async_add_entities, make_entities)
DESCRIPTORS = { DESCRIPTORS = (
BinarySensorEntityDescription( BinarySensorEntityDescription(
key="busy", key="busy",
translation_key="busy", translation_key="busy",
@@ -47,7 +63,7 @@ DESCRIPTORS = {
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
), ),
} )
class OasisDeviceBinarySensorEntity(OasisDeviceEntity, BinarySensorEntity): class OasisDeviceBinarySensorEntity(OasisDeviceEntity, BinarySensorEntity):
@@ -55,5 +71,10 @@ class OasisDeviceBinarySensorEntity(OasisDeviceEntity, BinarySensorEntity):
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:
"""Return true if the binary sensor is on.""" """
Indicates whether the binary sensor is currently active.
Returns:
bool: True if the sensor is on, False otherwise.
"""
return getattr(self.device, self.entity_description.key) return getattr(self.device, self.entity_description.key)

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

View File

@@ -24,13 +24,28 @@ from .pyoasiscontrol.const import TRACKS
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant, # noqa: ARG001
entry: OasisDeviceConfigEntry, entry: OasisDeviceConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Oasis device button using config entry.""" """
Create and add button entities for each Oasis device defined in the config entry.
Parameters:
entry (OasisDeviceConfigEntry): Config entry containing runtime data and registered Oasis devices.
async_add_entities (AddEntitiesCallback): Callback used to register the created entities with Home Assistant.
"""
def make_entities(new_devices: list[OasisDevice]): def make_entities(new_devices: list[OasisDevice]):
"""
Create button entities for each provided Oasis device using the module descriptors.
Parameters:
new_devices (list[OasisDevice]): Devices to create button entities for.
Returns:
list[OasisDeviceButtonEntity]: Button entity instances created for each device and each descriptor in DESCRIPTORS.
"""
return [ return [
OasisDeviceButtonEntity(entry.runtime_data, device, descriptor) OasisDeviceButtonEntity(entry.runtime_data, device, descriptor)
for device in new_devices for device in new_devices
@@ -41,7 +56,17 @@ async def async_setup_entry(
async def play_random_track(device: OasisDevice) -> None: async def play_random_track(device: OasisDevice) -> None:
"""Play random track.""" """
Play a random track on the given Oasis device.
Selects a track at random from the available TRACKS and attempts to add it to the device's queue and play it. Raises HomeAssistantError if adding the track times out.
Parameters:
device: The Oasis device on which to play the track.
Raises:
HomeAssistantError: If adding the selected track to the device's queue times out.
"""
track = random.choice(list(TRACKS)) track = random.choice(list(TRACKS))
try: try:
await add_and_play_track(device, track) await add_and_play_track(device, track)
@@ -82,5 +107,9 @@ class OasisDeviceButtonEntity(OasisDeviceEntity, ButtonEntity):
entity_description: OasisDeviceButtonEntityDescription entity_description: OasisDeviceButtonEntityDescription
async def async_press(self) -> None: async def async_press(self) -> None:
"""Press the button.""" """
Trigger the button's configured action on the associated device.
Calls the entity description's `press_fn` with the device to perform the button's effect.
"""
await self.entity_description.press_fn(self.device) await self.entity_description.press_fn(self.device)

66
custom_components/oasis_mini/config_flow.py Executable file → Normal file
View File

@@ -33,13 +33,28 @@ class OasisDeviceConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_reauth( async def async_step_reauth(
self, entry_data: Mapping[str, Any] self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Perform reauth upon an API authentication error.""" """
Begin the reauthentication flow for an existing config entry.
Parameters:
entry_data (Mapping[str, Any]): Data from the existing config entry that triggered the reauthentication flow.
Returns:
ConfigFlowResult: Result that presents the reauthentication confirmation dialog to the user.
"""
return await self.async_step_reauth_confirm() return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm( async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Dialog that informs the user that reauth is required.""" """
Present a reauthentication confirmation form to the user.
If `user_input` is provided it will be used as the form values; otherwise the existing entry's data are used as suggested values.
Returns:
ConfigFlowResult: Result of the config flow step that renders the reauthentication form or advances the flow.
"""
entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
assert entry assert entry
@@ -51,7 +66,15 @@ class OasisDeviceConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle the initial step.""" """
Handle the initial user configuration step for the Oasis integration.
Parameters:
user_input (dict[str, Any] | None): Optional prefilled values (e.g., `email`, `password`) submitted by the user.
Returns:
ConfigFlowResult: Result of the "user" step — a form prompting for credentials, an abort, or a created/updated config entry.
"""
return await self._async_step( return await self._async_step(
"user", STEP_USER_DATA_SCHEMA, user_input, user_input "user", STEP_USER_DATA_SCHEMA, user_input, user_input
) )
@@ -75,7 +98,20 @@ class OasisDeviceConfigFlow(ConfigFlow, domain=DOMAIN):
user_input: dict[str, Any] | None = None, user_input: dict[str, Any] | None = None,
suggested_values: dict[str, Any] | None = None, suggested_values: dict[str, Any] | None = None,
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle step setup.""" """
Handle a single config flow step: validate input, create or update entries, or render the form.
If valid credentials are provided, this will create a new config entry (title set to the provided email) or update an existing entry and trigger a reload. The step will abort if the validated account conflicts with an existing entry's unique ID. If no input is provided or validation fails, the flow returns a form populated with the given schema, any suggested values, and validation errors.
Parameters:
step_id: Identifier of the flow step to render or process.
schema: Voluptuous schema used to build the form.
user_input: Submitted values from the form; when present, used for validation and entry creation/update.
suggested_values: Values to pre-fill into the form schema when rendering.
Returns:
A ConfigFlowResult representing either a created entry, an update-and-reload abort, an abort due to a unique-id conflict, or a form to display with errors and suggested values.
"""
errors = {} errors = {}
if user_input is not None: if user_input is not None:
@@ -105,11 +141,25 @@ class OasisDeviceConfigFlow(ConfigFlow, domain=DOMAIN):
) )
async def validate_client(self, user_input: dict[str, Any]) -> dict[str, str]: async def validate_client(self, user_input: dict[str, Any]) -> dict[str, str]:
"""Validate client setup.""" """
Validate provided credentials by attempting to authenticate with the Oasis API and retrieve the user's identity.
Parameters:
user_input (dict[str, Any]): Mutable credential mapping containing at least `email` and `password`.
On success, this mapping will be updated with `CONF_ACCESS_TOKEN` (the received access token)
and the `password` key will be removed.
Returns:
dict[str, str]: A mapping of form field names to error keys. Common keys:
- `"base": "invalid_auth"` when credentials are incorrect or connection refused.
- `"base": "timeout_connect"` when the authentication request times out.
- `"base": "unknown"` for unexpected errors.
- `"base": "<http error text>"` when the server returns an HTTP error.
"""
errors = {} errors = {}
client = create_client(self.hass, user_input)
try: try:
async with asyncio.timeout(10): async with asyncio.timeout(10):
client = create_client(self.hass, user_input)
await client.async_login( await client.async_login(
email=user_input[CONF_EMAIL], password=user_input[CONF_PASSWORD] email=user_input[CONF_EMAIL], password=user_input[CONF_PASSWORD]
) )
@@ -129,8 +179,8 @@ class OasisDeviceConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "invalid_auth" errors["base"] = "invalid_auth"
except HTTPStatusError as err: except HTTPStatusError as err:
errors["base"] = str(err) errors["base"] = str(err)
except Exception as ex: # pylint: disable=broad-except except Exception:
_LOGGER.error(ex) _LOGGER.exception("Error while attempting to validate client")
errors["base"] = "unknown" errors["base"] = "unknown"
finally: finally:
await client.async_close() await client.async_close()

View File

@@ -2,19 +2,23 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from datetime import datetime, timedelta from datetime import datetime, timedelta
import logging import logging
from typing import TYPE_CHECKING
import async_timeout
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.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
from .const import DOMAIN from .const import DOMAIN
from .pyoasiscontrol import OasisCloudClient, OasisDevice, OasisMqttClient from .pyoasiscontrol import OasisCloudClient, OasisDevice, OasisMqttClient
if TYPE_CHECKING:
from . import OasisDeviceConfigEntry
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -27,27 +31,83 @@ class OasisDeviceCoordinator(DataUpdateCoordinator[list[OasisDevice]]):
def __init__( def __init__(
self, self,
hass: HomeAssistant, hass: HomeAssistant,
config_entry: OasisDeviceConfigEntry,
cloud_client: OasisCloudClient, cloud_client: OasisCloudClient,
mqtt_client: OasisMqttClient,
) -> None: ) -> None:
"""Initialize.""" """
Create an OasisDeviceCoordinator that manages OasisDevice discovery and updates using cloud and MQTT clients.
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.
"""
super().__init__( super().__init__(
hass, hass,
_LOGGER, _LOGGER,
config_entry=config_entry,
name=DOMAIN, name=DOMAIN,
update_interval=timedelta(minutes=10), update_interval=timedelta(minutes=10),
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]:
"""Update the data.""" """
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:
A list of OasisDevice instances representing devices currently available for the account.
Raises:
UpdateFailed: If an unexpected error persists past retry limits.
"""
devices: list[OasisDevice] = [] devices: list[OasisDevice] = []
self.attempt += 1 self.attempt += 1
try: try:
async with async_timeout.timeout(30): async with asyncio.timeout(30):
raw_devices = await self.cloud_client.async_get_devices() raw_devices = await self.cloud_client.async_get_devices()
existing_by_serial = { existing_by_serial = {
@@ -68,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,
@@ -90,71 +153,64 @@ 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
try: try:
await self.cloud_client.async_get_playlists() await self.cloud_client.async_get_playlists()
except Exception: # noqa: BLE001 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, timeout=3, 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: # noqa: BLE001
_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: except Exception as ex:
raise
except Exception as ex: # noqa: BLE001
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(
"Error updating Oasis devices; reusing previous data", exc_info=ex
)
return self.data or devices
if devices != self.data: if devices != self.data:
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

@@ -22,7 +22,16 @@ class OasisDeviceEntity(CoordinatorEntity[OasisDeviceCoordinator]):
device: OasisDevice, device: OasisDevice,
description: EntityDescription, description: EntityDescription,
) -> None: ) -> None:
"""Construct an Oasis device entity.""" """
Initialize an entity representing an Oasis device.
Sets the entity's unique_id from the device serial number and the provided description key, stores the given device on the entity, and constructs DeviceInfo containing identifiers, name, manufacturer, model, software version, and a network MAC connection if the device exposes a MAC address.
Parameters:
coordinator: The coordinator responsible for updating the device state.
device: OasisDevice instance providing metadata and identifiers (serial_number, mac_address, name, manufacturer, model, software_version).
description: EntityDescription used to derive the entity key for the unique_id.
"""
super().__init__(coordinator) super().__init__(coordinator)
self.device = device self.device = device
self.entity_description = description self.entity_description = description

45
custom_components/oasis_mini/helpers.py Executable file → Normal file
View File

@@ -6,49 +6,74 @@ import asyncio
import logging import logging
from typing import Any from typing import Any
import async_timeout
from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .pyoasiscontrol import OasisCloudClient, OasisDevice from .pyoasiscontrol import OasisCloudClient, OasisDevice
from .pyoasiscontrol.const import TRACKS from .pyoasiscontrol.const import STATUS_PLAYING, TRACKS
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
def create_client(hass: HomeAssistant, data: dict[str, Any]) -> OasisCloudClient: def create_client(hass: HomeAssistant, data: dict[str, Any]) -> OasisCloudClient:
"""Create a Oasis cloud client.""" """
Create an Oasis cloud client configured with the Home Assistant HTTP session and access token.
Parameters:
hass (HomeAssistant): Home Assistant instance used to obtain the shared HTTP client session.
data (dict[str, Any]): Configuration mapping; the function reads the `CONF_ACCESS_TOKEN` key for the cloud access token.
Returns:
An `OasisCloudClient` initialized with the Home Assistant HTTP session and the configured access token.
"""
session = async_get_clientsession(hass) session = async_get_clientsession(hass)
return OasisCloudClient(session=session, access_token=data.get(CONF_ACCESS_TOKEN)) return OasisCloudClient(session=session, access_token=data.get(CONF_ACCESS_TOKEN))
async def add_and_play_track(device: OasisDevice, track: int) -> None: async def add_and_play_track(device: OasisDevice, track: int) -> None:
"""Add and play a track.""" """
async with async_timeout.timeout(10): Ensure a track is present in the device playlist, position it as the next item, select it, and start playback if necessary.
Adds the specified track to the device playlist if missing, waits up to 10 seconds for the track to appear, moves it to be the next item after the current playlist index if needed, selects that track, and starts playback when the device is not already playing.
Parameters:
device (OasisDevice): The target Oasis device.
track (int): The track id to add and play.
Raises:
TimeoutError: If the operation does not complete within 10 seconds.
"""
async with asyncio.timeout(10):
if track not in device.playlist: if track not in device.playlist:
await device.async_add_track_to_playlist(track) await device.async_add_track_to_playlist(track)
# Wait for device state to reflect the newly added track
while track not in device.playlist: while track not in device.playlist:
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
# Move track to next item in the playlist and then select it # Ensure the track is positioned immediately after the current track and select it
if (index := device.playlist.index(track)) != device.playlist_index: if (index := device.playlist.index(track)) != device.playlist_index:
# Calculate the position after the current track
if index != ( if index != (
_next := min(device.playlist_index + 1, len(device.playlist) - 1) _next := min(device.playlist_index + 1, len(device.playlist) - 1)
): ):
await device.async_move_track(index, _next) await device.async_move_track(index, _next)
await device.async_change_track(_next) await device.async_change_track(_next)
if device.status_code != 4: if device.status_code != STATUS_PLAYING:
await device.async_play() await device.async_play()
def get_track_id(track: str) -> int | None: def get_track_id(track: str) -> int | None:
"""Get a track id. """
Convert a track identifier or title to its integer track id.
`track` can be either an id or title Parameters:
track: A track reference, either a numeric id as a string or a track title.
Returns:
The integer track id if the input is a valid id or matches a known title, `None` if the input is invalid.
""" """
track = track.lower().strip() track = track.lower().strip()
if track not in map(str, TRACKS): if track not in map(str, TRACKS):

View File

@@ -44,4 +44,4 @@
} }
} }
} }
} }

View File

@@ -15,13 +15,31 @@ from .pyoasiscontrol import OasisDevice
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant, # noqa: ARG001
entry: OasisDeviceConfigEntry, entry: OasisDeviceConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Oasis device image using config entry.""" """
Set up image entities for Oasis devices from a config entry.
Creates an OasisDeviceImageEntity for each device in the entry's runtime data and registers them with Home Assistant.
Parameters:
hass (HomeAssistant): Home Assistant core instance.
entry (OasisDeviceConfigEntry): Config entry containing runtime data and device registrations.
async_add_entities (AddEntitiesCallback): Callback to add created entities to Home Assistant.
"""
def make_entities(new_devices: list[OasisDevice]): def make_entities(new_devices: list[OasisDevice]):
"""
Create an Image entity for each OasisDevice using the enclosing config entry's runtime data.
Parameters:
new_devices (list[OasisDevice]): Devices to create image entities for.
Returns:
list[OasisDeviceImageEntity]: A list of image entity instances, one per device.
"""
return [ return [
OasisDeviceImageEntity(entry.runtime_data, device, IMAGE) OasisDeviceImageEntity(entry.runtime_data, device, IMAGE)
for device in new_devices for device in new_devices
@@ -45,25 +63,45 @@ class OasisDeviceImageEntity(OasisDeviceEntity, ImageEntity):
device: OasisDevice, device: OasisDevice,
description: ImageEntityDescription, description: ImageEntityDescription,
) -> None: ) -> None:
"""Initialize the entity.""" """
Create an Oasis device image entity tied to a coordinator and a specific device.
Initializes the entity with the provided coordinator, device, and image description and synchronizes its initial state from the coordinator.
Parameters:
coordinator (OasisDeviceCoordinator): Coordinator providing updates and Home Assistant context.
device (OasisDevice): The Oasis device this entity represents.
description (ImageEntityDescription): Metadata describing the image entity.
"""
super().__init__(coordinator, device, description) super().__init__(coordinator, device, description)
ImageEntity.__init__(self, coordinator.hass) ImageEntity.__init__(self, coordinator.hass)
self._handle_coordinator_update() self._handle_coordinator_update()
def image(self) -> bytes | None: def image(self) -> bytes | None:
"""Return bytes of image.""" """
Provide the entity's image bytes, generating and caching an SVG from the device when available.
If the device cannot produce an SVG, the entity's image URL and last-updated timestamp are set and no bytes are returned. When an SVG is produced, the content type is set to "image/svg+xml" and the SVG bytes are cached for future calls.
Returns:
bytes: The image content bytes, or `None` if no image is available yet.
"""
if not self._cached_image: if not self._cached_image:
if (svg := self.device.create_svg()) is None: if (svg := self.device.create_svg()) is None:
self._attr_image_url = self.device.track_image_url self._attr_image_url = self.device.track_image_url
self._attr_image_last_updated = dt_util.now() self._attr_image_last_updated = dt_util.now()
return None return None
self._attr_content_type = "image/svg+xml" self._attr_content_type = "image/svg+xml"
self._cached_image = Image(self.content_type, svg) self._cached_image = Image(self.content_type, svg.encode())
return self._cached_image.content return self._cached_image.content
@callback @callback
def _handle_coordinator_update(self) -> None: def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator.""" """
Update image metadata and cached image when the coordinator reports changes to the device's track or progress.
If the device's track_id or progress changed and updates are allowed (the device is playing or there is no cached image), update image last-updated timestamp, record the new track_id and progress, clear the cached image to force regeneration, and set the image URL to UNDEFINED when the track contains inline SVG content or to the device's track_image_url otherwise. When Home Assistant is available, propagate the update to the base class handler.
"""
device = self.device device = self.device
track_changed = self._track_id != device.track_id track_changed = self._track_id != device.track_id

View File

@@ -30,13 +30,22 @@ from .pyoasiscontrol.const import LED_EFFECTS
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant, # noqa: ARG001
entry: OasisDeviceConfigEntry, entry: OasisDeviceConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Oasis device lights using config entry.""" """Set up Oasis device lights using config entry."""
def make_entities(new_devices: list[OasisDevice]): def make_entities(new_devices: list[OasisDevice]):
"""
Create OasisDeviceLightEntity instances for each provided Oasis device.
Parameters:
new_devices (list[OasisDevice]): Devices to wrap as light entities.
Returns:
list[OasisDeviceLightEntity]: A list of light entity instances corresponding to the input devices.
"""
return [ return [
OasisDeviceLightEntity(entry.runtime_data, device, DESCRIPTOR) OasisDeviceLightEntity(entry.runtime_data, device, DESCRIPTOR)
for device in new_devices for device in new_devices
@@ -55,7 +64,12 @@ class OasisDeviceLightEntity(OasisDeviceEntity, LightEntity):
@property @property
def brightness(self) -> int: def brightness(self) -> int:
"""Return the brightness of this light between 0..255.""" """
Get the light's brightness on a 0-255 scale.
Returns:
int: Brightness value between 0 and 255.
"""
scale = (1, self.device.brightness_max) scale = (1, self.device.brightness_max)
return value_to_brightness(scale, self.device.brightness) return value_to_brightness(scale, self.device.brightness)
@@ -104,12 +118,31 @@ class OasisDeviceLightEntity(OasisDeviceEntity, LightEntity):
await self.device.async_set_led(brightness=0) await self.device.async_set_led(brightness=0)
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on.""" """
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.
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).
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
device's internal effect key; if no mapping exists, `None` is used.
Side effects:
Updates the underlying device LED state with the computed `brightness`, `color`, and `led_effect`.
"""
if brightness := kwargs.get(ATTR_BRIGHTNESS): if brightness := kwargs.get(ATTR_BRIGHTNESS):
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

@@ -6,7 +6,7 @@
"dhcp": [{ "registered_devices": true }], "dhcp": [{ "registered_devices": true }],
"documentation": "https://github.com/natekspencer/hacs-oasis_mini", "documentation": "https://github.com/natekspencer/hacs-oasis_mini",
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_polling", "iot_class": "cloud_push",
"issue_tracker": "https://github.com/natekspencer/hacs-oasis_mini/issues", "issue_tracker": "https://github.com/natekspencer/hacs-oasis_mini/issues",
"loggers": ["custom_components.oasis_mini"], "loggers": ["custom_components.oasis_mini"],
"requirements": ["aiomqtt"], "requirements": ["aiomqtt"],

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,26 +16,63 @@ 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
from .pyoasiscontrol import OasisDevice from .pyoasiscontrol import OasisDevice
from .pyoasiscontrol.const import (
STATUS_CENTERING,
STATUS_DOWNLOADING,
STATUS_ERROR,
STATUS_LIVE,
STATUS_PAUSED,
STATUS_PLAYING,
STATUS_STOPPED,
STATUS_UPDATING,
)
from .pyoasiscontrol.utils import get_track_ids_from_playlist
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant, # noqa: ARG001
entry: OasisDeviceConfigEntry, entry: OasisDeviceConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Oasis device media_players using config entry.""" """Set up Oasis device media_players using config entry."""
def make_entities(new_devices: list[OasisDevice]): def make_entities(new_devices: list[OasisDevice]):
"""
Create media player entities for the given Oasis devices.
Parameters:
new_devices (list[OasisDevice]): Devices to wrap as media player entities.
Returns:
list[OasisDeviceMediaPlayerEntity]: Media player entities corresponding to each device.
"""
return [ return [
OasisDeviceMediaPlayerEntity(entry.runtime_data, device, DESCRIPTOR) OasisDeviceMediaPlayerEntity(entry.runtime_data, device, DESCRIPTOR)
for device in new_devices for device in new_devices
@@ -47,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
@@ -58,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."""
@@ -74,12 +112,22 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity):
@property @property
def media_image_url(self) -> str | None: def media_image_url(self) -> str | None:
"""Image url of current playing media.""" """
URL of the image representing the currently playing media.
Returns:
The image URL as a string, or `None` if no image is available.
"""
return self.device.track_image_url return self.device.track_image_url
@property @property
def media_position(self) -> int: def media_position(self) -> int:
"""Position of current playing media in seconds.""" """
Playback position of the current media in seconds.
Returns:
int: Position in seconds of the currently playing media.
"""
return self.device.progress return self.device.progress
@property @property
@@ -89,29 +137,39 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity):
@property @property
def media_title(self) -> str | None: def media_title(self) -> str | None:
"""Title of current playing media.""" """
Provide the title of the currently playing track.
Returns:
str | None: The track title, or None if no title is available.
"""
return self.device.track_name return self.device.track_name
@property @property
def repeat(self) -> RepeatMode: def repeat(self) -> RepeatMode:
"""Return current repeat mode.""" """
Get the current repeat mode for the device.
Returns:
`RepeatMode.ALL` if the device is configured to repeat the playlist, `RepeatMode.OFF` otherwise.
"""
return RepeatMode.ALL if self.device.repeat_playlist else RepeatMode.OFF return RepeatMode.ALL if self.device.repeat_playlist else RepeatMode.OFF
@property @property
def state(self) -> MediaPlayerState: def state(self) -> MediaPlayerState:
"""State of the player.""" """State of the player."""
status_code = self.device.status_code status_code = self.device.status_code
if self.device.error or status_code in (9, 11): if self.device.error or status_code in (STATUS_ERROR, STATUS_UPDATING):
return MediaPlayerState.OFF return MediaPlayerState.OFF
if status_code == 2: if status_code == STATUS_STOPPED:
return MediaPlayerState.IDLE return MediaPlayerState.IDLE
if status_code in (3, 13): if status_code in (STATUS_CENTERING, STATUS_DOWNLOADING):
return MediaPlayerState.BUFFERING return MediaPlayerState.BUFFERING
if status_code == 4: if status_code == STATUS_PLAYING:
return MediaPlayerState.PLAYING return MediaPlayerState.PLAYING
if status_code == 5: if status_code == STATUS_PAUSED:
return MediaPlayerState.PAUSED return MediaPlayerState.PAUSED
if status_code == 15: if status_code == STATUS_LIVE:
return MediaPlayerState.ON return MediaPlayerState.ON
return MediaPlayerState.IDLE return MediaPlayerState.IDLE
@@ -125,36 +183,71 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity):
) )
async def async_media_pause(self) -> None: async def async_media_pause(self) -> None:
"""Send pause command.""" """
Pause playback on the device.
Raises:
ServiceValidationError: If the device is busy and cannot accept commands.
"""
self.abort_if_busy() self.abort_if_busy()
await self.device.async_pause() await self.device.async_pause()
async def async_media_play(self) -> None: async def async_media_play(self) -> None:
"""Send play command.""" """
Start playback on the device.
Raises:
ServiceValidationError: If the device is currently busy.
"""
self.abort_if_busy() self.abort_if_busy()
await self.device.async_play() await self.device.async_play()
async def async_media_stop(self) -> None: async def async_media_stop(self) -> None:
"""Send stop command.""" """
Stop playback on the Oasis device.
Raises:
ServiceValidationError: If the device is currently busy.
"""
self.abort_if_busy() self.abort_if_busy()
await self.device.async_stop() await self.device.async_stop()
async def async_set_repeat(self, repeat: RepeatMode) -> None: async def async_set_repeat(self, repeat: RepeatMode) -> None:
"""Set repeat mode.""" """
Set the device playlist repeat behavior.
Enables or disables looping of the playlist according to the provided RepeatMode:
- RepeatMode.OFF disables playlist repeat.
- RepeatMode.ALL enables playlist repeat for the entire playlist.
- RepeatMode.ONE enables single-track repeat, except when the device is currently repeating the entire playlist; in that case the playlist repeat is disabled to preserve single-track semantics.
Parameters:
repeat (RepeatMode): The desired repeat mode to apply to the device playlist.
"""
await self.device.async_set_repeat_playlist( await self.device.async_set_repeat_playlist(
repeat != RepeatMode.OFF repeat != RepeatMode.OFF
and not (repeat == RepeatMode.ONE and self.repeat == RepeatMode.ALL) and not (repeat == RepeatMode.ONE and self.repeat == RepeatMode.ALL)
) )
async def async_media_previous_track(self) -> None: async def async_media_previous_track(self) -> None:
"""Send previous track command.""" """
Move playback to the previous track in the device's playlist, wrapping to the last track when currently at the first.
Raises:
ServiceValidationError: If the device is busy.
"""
self.abort_if_busy() self.abort_if_busy()
if (index := self.device.playlist_index - 1) < 0: if (index := self.device.playlist_index - 1) < 0:
index = len(self.device.playlist) - 1 index = len(self.device.playlist) - 1
await self.device.async_change_track(index) await self.device.async_change_track(index)
async def async_media_next_track(self) -> None: async def async_media_next_track(self) -> None:
"""Send next track command.""" """
Advance the device to the next track in its playlist, wrapping to the first track when at the end.
Raises:
ServiceValidationError: if the device is busy.
"""
self.abort_if_busy() self.abort_if_busy()
if (index := self.device.playlist_index + 1) >= len(self.device.playlist): if (index := self.device.playlist_index + 1) >= len(self.device.playlist):
index = 0 index = 0
@@ -167,15 +260,72 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity):
enqueue: MediaPlayerEnqueue | None = None, enqueue: MediaPlayerEnqueue | None = None,
**kwargs: Any, **kwargs: Any,
) -> None: ) -> None:
"""Play a piece of media.""" """
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.
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.
Raises:
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",
@@ -183,32 +333,107 @@ 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_set_playlist(track_ids, start_playing=True)
else: return
await device.async_add_track_to_playlist(track)
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:
"""Clear players playlist.""" """
Clear the device's playlist.
Raises:
ServiceValidationError: If the device is busy and cannot accept commands.
"""
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

@@ -23,13 +23,31 @@ from .pyoasiscontrol.device import (
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant, # noqa: ARG001
entry: OasisDeviceConfigEntry, entry: OasisDeviceConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Oasis device numbers using config entry.""" """
Set up number entities for Oasis devices from a configuration entry.
Creates number entities for each discovered Oasis device and each descriptor in DESCRIPTORS, then registers those entities with the platform coordinator so they are added to Home Assistant.
Parameters:
hass (HomeAssistant): Home Assistant core object.
entry (OasisDeviceConfigEntry): Configuration entry containing runtime data and devices to expose.
async_add_entities (AddEntitiesCallback): Callback to add created entities to Home Assistant.
"""
def make_entities(new_devices: list[OasisDevice]): def make_entities(new_devices: list[OasisDevice]):
"""
Create number entity instances for each provided Oasis device using the module's DESCRIPTORS.
Parameters:
new_devices (list[OasisDevice]): Devices to create entities for.
Returns:
list[OasisDeviceNumberEntity]: A flat list of number entities (one per descriptor for each device).
"""
return [ return [
OasisDeviceNumberEntity(entry.runtime_data, device, descriptor) OasisDeviceNumberEntity(entry.runtime_data, device, descriptor)
for device in new_devices for device in new_devices
@@ -39,7 +57,7 @@ async def async_setup_entry(
setup_platform_from_coordinator(entry, async_add_entities, make_entities) setup_platform_from_coordinator(entry, async_add_entities, make_entities)
DESCRIPTORS = { DESCRIPTORS = (
NumberEntityDescription( NumberEntityDescription(
key="ball_speed", key="ball_speed",
translation_key="ball_speed", translation_key="ball_speed",
@@ -56,19 +74,31 @@ DESCRIPTORS = {
native_max_value=LED_SPEED_MAX, native_max_value=LED_SPEED_MAX,
native_min_value=LED_SPEED_MIN, native_min_value=LED_SPEED_MIN,
), ),
} )
class OasisDeviceNumberEntity(OasisDeviceEntity, NumberEntity): class OasisDeviceNumberEntity(OasisDeviceEntity, NumberEntity):
"""Oasis device number entity.""" """Oasis device number entity."""
@property @property
def native_value(self) -> str | None: def native_value(self) -> float | None:
"""Return the value reported by the number.""" """
Get the current value of the number entity from the underlying device.
Returns:
float | None: The current value as a float, or `None` if the device has no value.
"""
return getattr(self.device, self.entity_description.key) return getattr(self.device, self.entity_description.key)
async def async_set_native_value(self, value: float) -> None: async def async_set_native_value(self, value: float) -> None:
"""Set new value.""" """
Set the configured numeric value on the underlying Oasis device.
The provided value is converted to an integer and applied to the device property indicated by this entity's description key: if the key is "ball_speed" the device's ball speed is updated; if the key is "led_speed" the device's LED speed is updated.
Parameters:
value (float): New numeric value to apply; will be converted to an integer.
"""
value = int(value) value = int(value)
if self.entity_description.key == "ball_speed": if self.entity_description.key == "ball_speed":
await self.device.async_set_ball_speed(value) await self.device.async_set_ball_speed(value)

View File

@@ -40,45 +40,93 @@ class OasisCloudClient:
session: ClientSession | None = None, session: ClientSession | None = None,
access_token: str | None = None, access_token: str | None = None,
) -> None: ) -> None:
"""
Initialize the OasisCloudClient.
Sets the optional aiohttp session and access token, records whether the client owns the session, and initializes caches and asyncio locks for playlists and software metadata.
Parameters:
session (ClientSession | None): Optional aiohttp ClientSession to use. If None, the client will create and own a session later.
access_token (str | None): Optional initial access token for authenticated requests.
"""
self._session = session self._session = session
self._owns_session = session is None self._owns_session = session is None
self._access_token = access_token self._access_token = access_token
# playlists cache now_dt = now()
self.playlists: list[dict[str, Any]] = []
self._playlists_next_refresh = now()
self._playlists_lock = asyncio.Lock()
self._playlist_details: dict[int, dict[str, str]] = {} # playlists cache
self._playlists_cache: dict[bool, list[dict[str, Any]]] = {False: [], True: []}
self._playlists_next_refresh = {False: now_dt, True: now_dt}
self._playlists_lock = asyncio.Lock()
# software metadata cache # software metadata cache
self._software_details: dict[str, int | str] | None = None self._software_details: dict[str, int | str] | None = None
self._software_next_refresh = now() self._software_next_refresh = now()
self._software_lock = asyncio.Lock() self._software_lock = asyncio.Lock()
@property
def playlists(self) -> list[dict]:
"""Return all cached playlists, deduplicated by ID."""
seen = set()
merged: list[dict] = []
for items in self._playlists_cache.values():
for pl in items:
if (pid := pl.get("id")) not in seen:
seen.add(pid)
merged.append(pl)
return merged
@property @property
def session(self) -> ClientSession: def session(self) -> ClientSession:
"""Return (or lazily create) the aiohttp ClientSession.""" """
Get the active aiohttp ClientSession, creating and owning a new session if none exists or the existing session is closed.
Returns:
ClientSession: The active aiohttp ClientSession; a new session is created and marked as owned by this client when necessary.
"""
if self._session is None or self._session.closed: if self._session is None or self._session.closed:
self._session = ClientSession() self._session = ClientSession()
self._owns_session = True self._owns_session = True
return self._session return self._session
async def async_close(self) -> None: async def async_close(self) -> None:
"""Close owned session (call from HA unload / cleanup).""" """
Close the aiohttp ClientSession owned by this client if it exists and is open.
This should be called during teardown when the client is responsible for the session.
"""
if self._session and not self._session.closed and self._owns_session: if self._session and not self._session.closed and self._owns_session:
await self._session.close() await self._session.close()
@property @property
def access_token(self) -> str | None: def access_token(self) -> str | None:
"""
Access token used for authenticated requests or None if not set.
Returns:
The current access token string, or `None` if no token is stored.
"""
return self._access_token return self._access_token
@access_token.setter @access_token.setter
def access_token(self, value: str | None) -> None: def access_token(self, value: str | None) -> None:
"""
Set the access token used for authenticated requests.
Parameters:
value (str | None): The bearer token to store; pass None to clear the stored token.
"""
self._access_token = value self._access_token = value
async def async_login(self, email: str, password: str) -> None: async def async_login(self, email: str, password: str) -> None:
"""Login via the cloud and store the access token.""" """
Log in to the Oasis cloud and store the received access token on the client.
Performs an authentication request using the provided credentials and saves the returned access token to self.access_token for use in subsequent authenticated requests.
"""
response = await self._async_request( response = await self._async_request(
"POST", "POST",
urljoin(BASE_URL, "api/auth/login"), urljoin(BASE_URL, "api/auth/login"),
@@ -89,35 +137,70 @@ class OasisCloudClient:
_LOGGER.debug("Cloud login succeeded, token set: %s", bool(token)) _LOGGER.debug("Cloud login succeeded, token set: %s", bool(token))
async def async_logout(self) -> None: async def async_logout(self) -> None:
"""Logout from the cloud.""" """
End the current authenticated session with the Oasis cloud.
Performs a logout request and clears the stored access token on success.
"""
await self._async_auth_request("GET", "api/auth/logout") await self._async_auth_request("GET", "api/auth/logout")
self.access_token = None self.access_token = None
async def async_get_user(self) -> dict: async def async_get_user(self) -> dict:
"""Get current user info.""" """
Return information about the currently authenticated user.
Returns:
dict: A mapping containing the user's details as returned by the cloud API.
Raises:
UnauthenticatedError: If no access token is available or the request is unauthorized.
"""
return await self._async_auth_request("GET", "api/auth/user") return await self._async_auth_request("GET", "api/auth/user")
async def async_get_devices(self) -> list[dict[str, Any]]: async def async_get_devices(self) -> list[dict[str, Any]]:
"""Get user devices (raw JSON from API).""" """
Retrieve the current user's devices from the cloud API.
Returns:
list[dict[str, Any]]: A list of device objects as returned by the API.
"""
return await self._async_auth_request("GET", "api/user/devices") return await self._async_auth_request("GET", "api/user/devices")
async def async_get_playlists( async def async_get_playlists(
self, personal_only: bool = False self, personal_only: bool = False
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Get playlists from the cloud (cached by PLAYLISTS_REFRESH_LIMITER).""" """
Retrieve playlists from the Oasis cloud, optionally limited to the authenticated user's personal playlists.
The result is cached and will be refreshed according to PLAYLISTS_REFRESH_LIMITER to avoid frequent network requests.
Parameters:
personal_only (bool): If True, return only playlists owned by the authenticated user; otherwise return all available playlists.
Returns:
list[dict[str, Any]]: A list of playlist objects represented as dictionaries; an empty list if no playlists are available.
"""
now_dt = now() now_dt = now()
def _is_cache_valid() -> bool: def _is_cache_valid() -> bool:
return self._playlists_next_refresh > now_dt and bool(self.playlists) """
Determine whether the playlists cache is still valid.
Returns:
`true` if the playlists cache contains data and the next refresh timestamp is later than the current time, `false` otherwise.
"""
cache = self._playlists_cache[personal_only]
next_refresh = self._playlists_next_refresh[personal_only]
return bool(cache) and next_refresh > now_dt
if _is_cache_valid(): if _is_cache_valid():
return self.playlists return self._playlists_cache[personal_only]
async with self._playlists_lock: async with self._playlists_lock:
# Double-check in case another task just refreshed it # Double-check in case another task just refreshed it
now_dt = now() now_dt = now()
if _is_cache_valid(): if _is_cache_valid():
return self.playlists return self._playlists_cache[personal_only]
params = {"my_playlists": str(personal_only).lower()} params = {"my_playlists": str(personal_only).lower()}
playlists = await self._async_auth_request( playlists = await self._async_auth_request(
@@ -127,26 +210,44 @@ class OasisCloudClient:
if not isinstance(playlists, list): if not isinstance(playlists, list):
playlists = [] playlists = []
self.playlists = playlists self._playlists_cache[personal_only] = playlists
self._playlists_next_refresh = now_dt + PLAYLISTS_REFRESH_LIMITER self._playlists_next_refresh[personal_only] = (
now_dt + PLAYLISTS_REFRESH_LIMITER
)
return self.playlists return playlists
async def async_get_track_info(self, track_id: int) -> dict[str, Any] | None: async def async_get_track_info(self, track_id: int) -> dict[str, Any] | None:
"""Get single track info from the cloud.""" """
Retrieve information for a single track from the cloud.
Returns:
dict: Track detail dictionary. If the track is not found (HTTP 404), returns a dict with keys `id` and `name` where `name` is "Unknown Title (#{id})". Returns `None` on other failures.
"""
try: try:
return await self._async_auth_request("GET", f"api/track/{track_id}") return await self._async_auth_request("GET", f"api/track/{track_id}")
except ClientResponseError as err: except ClientResponseError as err:
if err.status == 404: if err.status == 404:
return {"id": track_id, "name": f"Unknown Title (#{track_id})"} return {"id": track_id, "name": f"Unknown Title (#{track_id})"}
except Exception as ex: # noqa: BLE001 raise
_LOGGER.exception("Error fetching track %s: %s", track_id, ex) except UnauthenticatedError:
raise
except Exception:
_LOGGER.exception("Error fetching track %s", track_id)
return None return None
async def async_get_tracks( async def async_get_tracks(
self, tracks: list[int] | None = None self, tracks: list[int] | None = None
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Get multiple tracks info from the cloud (handles pagination).""" """
Retrieve track details for the given track IDs, following pagination until all pages are fetched.
Parameters:
tracks (list[int] | None): Optional list of track IDs to request. If omitted or None, an empty list is sent to the API.
Returns:
list[dict[str, Any]]: A list of track detail dictionaries returned by the cloud, aggregated across all pages (may be empty).
"""
response = await self._async_auth_request( response = await self._async_auth_request(
"GET", "GET",
"api/track", "api/track",
@@ -163,10 +264,24 @@ class OasisCloudClient:
async def async_get_latest_software_details( async def async_get_latest_software_details(
self, *, force_refresh: bool = False self, *, force_refresh: bool = False
) -> dict[str, int | str] | None: ) -> dict[str, int | str] | None:
"""Get latest software metadata from cloud (cached).""" """
Retrieve the latest software metadata from the cloud, using an internal cache to limit requests.
Parameters:
force_refresh (bool): If True, bypass the cache and fetch fresh metadata from the cloud.
Returns:
details (dict[str, int | str] | None): A mapping of software metadata keys to integer or string values, or `None` if no metadata is available.
"""
now_dt = now() now_dt = now()
def _is_cache_valid() -> bool: def _is_cache_valid() -> bool:
"""
Determine whether the cached software metadata should be used instead of fetching fresh data.
Returns:
True if the software cache exists, has not expired, and a force refresh was not requested; False otherwise.
"""
return ( return (
not force_refresh not force_refresh
and self._software_details is not None and self._software_details is not None
@@ -193,7 +308,23 @@ class OasisCloudClient:
return self._software_details return self._software_details
async def _async_auth_request(self, method: str, url: str, **kwargs: Any) -> Any: async def _async_auth_request(self, method: str, url: str, **kwargs: Any) -> Any:
"""Perform an authenticated cloud request.""" """
Perform a cloud API request using the stored access token.
If `url` is relative it will be joined with the module `BASE_URL`. The method will
inject an `Authorization: Bearer <token>` header into the request.
Parameters:
method (str): HTTP method (e.g., "GET", "POST").
url (str): Absolute URL or path relative to `BASE_URL`.
**kwargs: Passed through to the underlying request helper.
Returns:
The parsed response value (JSON object, text, or None) as returned by the underlying request helper.
Raises:
UnauthenticatedError: If no access token is set.
"""
if not self.access_token: if not self.access_token:
raise UnauthenticatedError("Unauthenticated") raise UnauthenticatedError("Unauthenticated")
@@ -208,7 +339,30 @@ class OasisCloudClient:
) )
async def _async_request(self, method: str, url: str, **kwargs: Any) -> Any: async def _async_request(self, method: str, url: str, **kwargs: Any) -> Any:
"""Low-level HTTP helper for both cloud and (if desired) device HTTP.""" """
Perform a single HTTP request and return a normalized response value.
Performs the request using the client's session and:
- If the response status is 200:
- returns parsed JSON for `application/json`.
- returns text for `text/plain`.
- if `text/html` and the URL targets the cloud base URL and contains a login page, raises UnauthenticatedError.
- returns `None` for other content types.
- If the response status is 401, raises UnauthenticatedError.
- For other non-200 statuses, re-raises the response's HTTP error.
Parameters:
method: HTTP method to use (e.g., "GET", "POST").
url: Request URL or path.
**kwargs: Passed through to the session request (e.g., `params`, `json`, `headers`).
Returns:
The parsed JSON object, response text, or `None` depending on the response content type.
Raises:
UnauthenticatedError: when the server indicates the client is unauthenticated (401) or a cloud login page is returned.
aiohttp.ClientResponseError: for other non-success HTTP statuses raised by `response.raise_for_status()`.
"""
session = self.session session = self.session
_LOGGER.debug( _LOGGER.debug(
"%s %s", "%s %s",

View File

@@ -22,29 +22,64 @@ class OasisHttpClient(OasisClientProtocol):
""" """
def __init__(self, host: str, session: ClientSession | None = None) -> None: def __init__(self, host: str, session: ClientSession | None = None) -> None:
"""
Initialize the HTTP client for a specific device host.
Parameters:
host (str): Hostname or IP address of the target device (used to build the base HTTP URL).
session (ClientSession | None): Optional aiohttp ClientSession to reuse for requests. If omitted, a new session will be created and owned by this client.
"""
self._host = host self._host = host
self._session: ClientSession | None = session self._session: ClientSession | None = session
self._owns_session: bool = session is None self._owns_session: bool = session is None
@property @property
def session(self) -> ClientSession: def session(self) -> ClientSession:
"""
Ensure and return a usable aiohttp ClientSession for this client.
If no session exists or the existing session is closed, a new ClientSession is created and the client records ownership of that session so it can be closed later.
Returns:
An active aiohttp ClientSession instance associated with this client.
"""
if self._session is None or self._session.closed: if self._session is None or self._session.closed:
self._session = ClientSession() self._session = ClientSession()
self._owns_session = True self._owns_session = True
return self._session return self._session
async def async_close(self) -> None: async def async_close(self) -> None:
"""Close owned session.""" """
Close the client's owned HTTP session if one exists and is open.
Does nothing when there is no session, the session is already closed, or the client does not own the session.
"""
if self._session and not self._session.closed and self._owns_session: if self._session and not self._session.closed and self._owns_session:
await self._session.close() await self._session.close()
@property @property
def url(self) -> str: def url(self) -> str:
# These devices are plain HTTP, no TLS # These devices are plain HTTP, no TLS
"""
Base HTTP URL for the target device.
Returns:
The device base URL using plain HTTP (no TLS), including a trailing slash (e.g. "http://{host}/").
"""
return f"http://{self._host}/" return f"http://{self._host}/"
async def _async_request(self, method: str, url: str, **kwargs: Any) -> Any: async def _async_request(self, method: str, url: str, **kwargs: Any) -> Any:
"""Low-level HTTP helper.""" """
Perform an HTTP request using the client's session and decode the response.
Logs the request URL and query parameters. If the response status is 200, returns the response body as a string for `text/plain`, the parsed JSON for `application/json`, or `None` for other content types. On non-200 responses, raises the client response error.
Returns:
The response body as `str` for `text/plain`, the parsed JSON value for `application/json`, or `None` for other content types.
Raises:
aiohttp.ClientResponseError: If the response status is not 200.
"""
session = self.session session = self.session
_LOGGER.debug( _LOGGER.debug(
"%s %s", "%s %s",
@@ -65,30 +100,76 @@ class OasisHttpClient(OasisClientProtocol):
resp.raise_for_status() resp.raise_for_status()
async def _async_get(self, **kwargs: Any) -> str | None: async def _async_get(self, **kwargs: Any) -> str | None:
"""
Perform a GET request to the client's base URL using the provided request keyword arguments.
Parameters:
**kwargs: Additional request keyword arguments forwarded to the underlying request (for example `params`, `headers`, `timeout`).
Returns:
`str` response text when the server responds with `text/plain`, `None` otherwise.
"""
return await self._async_request("GET", self.url, **kwargs) return await self._async_request("GET", self.url, **kwargs)
async def _async_command(self, **kwargs: Any) -> str | None: async def _async_command(self, **kwargs: Any) -> str | None:
"""
Execute a device command by issuing a GET request with the provided query parameters and return the parsed response.
Parameters:
**kwargs: Mapping of query parameter names to values sent with the GET request.
Returns:
str | None: The device response as a string, a parsed JSON value, or None when the response has an unsupported content type.
"""
result = await self._async_get(**kwargs) result = await self._async_get(**kwargs)
_LOGGER.debug("Result: %s", result) _LOGGER.debug("Result: %s", result)
return result return result
async def async_get_mac_address(self, device: OasisDevice) -> str | None: async def async_get_mac_address(self, device: OasisDevice) -> str | None:
"""Fetch MAC address via HTTP GETMAC.""" """
Fetch the device MAC address using the device's HTTP GETMAC endpoint.
Returns:
str: The MAC address with surrounding whitespace removed, or `None` if it could not be retrieved.
"""
try: try:
mac = await self._async_get(params={"GETMAC": ""}) mac = await self._async_get(params={"GETMAC": ""})
if isinstance(mac, str): if isinstance(mac, str):
return mac.strip() return mac.strip()
except Exception: # noqa: BLE001 except Exception:
_LOGGER.exception( _LOGGER.exception(
"Failed to get MAC address via HTTP for %s", device.serial_number "Failed to get MAC address via HTTP for %s", device.serial_number
) )
return None return None
async def async_send_auto_clean_command(
self,
device: OasisDevice,
auto_clean: bool,
) -> None:
"""
Enable or disable the device's auto-clean mode.
Parameters:
device (OasisDevice): The target Oasis device to send the command to.
auto_clean (bool): `True` to enable auto-clean mode, `False` to disable it.
"""
await self._async_command(
params={"WRIAUTOCLEAN": 1 if auto_clean else 0},
)
async def async_send_ball_speed_command( async def async_send_ball_speed_command(
self, self,
device: OasisDevice, device: OasisDevice,
speed: int, speed: int,
) -> None: ) -> None:
"""
Send a ball speed command to the specified device.
Parameters:
device (OasisDevice): Target device for the command.
speed (int): Speed value to set for the device's ball mechanism.
"""
await self._async_command(params={"WRIOASISSPEED": speed}) await self._async_command(params={"WRIOASISSPEED": speed})
async def async_send_led_command( async def async_send_led_command(
@@ -99,10 +180,25 @@ class OasisHttpClient(OasisClientProtocol):
led_speed: int, led_speed: int,
brightness: int, brightness: int,
) -> None: ) -> None:
"""
Send an LED control command to the device.
Parameters:
device (OasisDevice): Target device to receive the command.
led_effect (str): Effect name or identifier to apply to the LEDs.
color (str): Color value recognized by the device (e.g., hex code or device color name).
led_speed (int): Animation speed value; larger values increase animation speed.
brightness (int): Brightness level for the LEDs.
"""
payload = f"{led_effect};0;{color};{led_speed};{brightness}" payload = f"{led_effect};0;{color};{led_speed};{brightness}"
await self._async_command(params={"WRILED": payload}) await self._async_command(params={"WRILED": payload})
async def async_send_sleep_command(self, device: OasisDevice) -> None: async def async_send_sleep_command(self, device: OasisDevice) -> None:
"""
Send a sleep command to the device.
Requests the device to enter sleep mode.
"""
await self._async_command(params={"CMDSLEEP": ""}) await self._async_command(params={"CMDSLEEP": ""})
async def async_send_move_job_command( async def async_send_move_job_command(
@@ -111,6 +207,14 @@ class OasisHttpClient(OasisClientProtocol):
from_index: int, from_index: int,
to_index: int, to_index: int,
) -> None: ) -> None:
"""
Move a job in the device's playlist from one index to another.
Parameters:
device (OasisDevice): Target device whose job list will be modified.
from_index (int): Zero-based index of the job to move.
to_index (int): Zero-based destination index where the job will be placed.
"""
await self._async_command(params={"MOVEJOB": f"{from_index};{to_index}"}) await self._async_command(params={"MOVEJOB": f"{from_index};{to_index}"})
async def async_send_change_track_command( async def async_send_change_track_command(
@@ -118,6 +222,12 @@ class OasisHttpClient(OasisClientProtocol):
device: OasisDevice, device: OasisDevice,
index: int, index: int,
) -> None: ) -> None:
"""
Change the device's current track to the specified track index.
Parameters:
index (int): Zero-based index of the track to select.
"""
await self._async_command(params={"CMDCHANGETRACK": index}) await self._async_command(params={"CMDCHANGETRACK": index})
async def async_send_add_joblist_command( async def async_send_add_joblist_command(
@@ -126,6 +236,15 @@ class OasisHttpClient(OasisClientProtocol):
tracks: list[int], tracks: list[int],
) -> None: ) -> None:
# The old code passed the list directly; if the device expects CSV: # The old code passed the list directly; if the device expects CSV:
"""
Send an "add joblist" command to the device with a list of track indices.
The provided track indices are serialized as a comma-separated string and sent to the device using the `ADDJOBLIST` parameter.
Parameters:
device (OasisDevice): Target device to receive the command.
tracks (list[int]): Track indices to add; these are sent as a CSV string (e.g., [1,2,3] -> "1,2,3").
"""
await self._async_command(params={"ADDJOBLIST": ",".join(map(str, tracks))}) await self._async_command(params={"ADDJOBLIST": ",".join(map(str, tracks))})
async def async_send_set_playlist_command( async def async_send_set_playlist_command(
@@ -133,6 +252,13 @@ class OasisHttpClient(OasisClientProtocol):
device: OasisDevice, device: OasisDevice,
playlist: list[int], playlist: list[int],
) -> None: ) -> None:
"""
Set the device's playlist on the target device and optimistically update the local device state.
Parameters:
device (OasisDevice): Target device to receive the playlist command; its state will be updated optimistically.
playlist (list[int]): Ordered list of track indices to set as the device's playlist.
"""
await self._async_command(params={"WRIJOBLIST": ",".join(map(str, playlist))}) await self._async_command(params={"WRIJOBLIST": ",".join(map(str, playlist))})
# optional: optimistic state update # optional: optimistic state update
device.update_from_status_dict({"playlist": playlist}) device.update_from_status_dict({"playlist": playlist})
@@ -142,6 +268,12 @@ class OasisHttpClient(OasisClientProtocol):
device: OasisDevice, device: OasisDevice,
repeat: bool, repeat: bool,
) -> None: ) -> None:
"""
Set the device's playlist repeat flag.
Parameters:
repeat (bool): `True` to enable playlist repeat, `False` to disable it.
"""
await self._async_command(params={"WRIREPEATJOB": 1 if repeat else 0}) await self._async_command(params={"WRIREPEATJOB": 1 if repeat else 0})
async def async_send_set_autoplay_command( async def async_send_set_autoplay_command(
@@ -149,6 +281,13 @@ class OasisHttpClient(OasisClientProtocol):
device: OasisDevice, device: OasisDevice,
option: str, option: str,
) -> None: ) -> None:
"""
Set the device's autoplay (wait-after) option.
Parameters:
device (OasisDevice): Target device whose autoplay option will be updated.
option (str): The value for the device's wait-after/autoplay setting as expected by the device firmware.
"""
await self._async_command(params={"WRIWAITAFTER": option}) await self._async_command(params={"WRIWAITAFTER": option})
async def async_send_upgrade_command( async def async_send_upgrade_command(
@@ -156,22 +295,52 @@ class OasisHttpClient(OasisClientProtocol):
device: OasisDevice, device: OasisDevice,
beta: bool, beta: bool,
) -> None: ) -> None:
"""
Send a firmware upgrade command to the specified device.
Parameters:
device (OasisDevice): Target device to receive the upgrade command.
beta (bool): If True, request the beta firmware; if False, request the stable firmware.
"""
await self._async_command(params={"CMDUPGRADE": 1 if beta else 0}) await self._async_command(params={"CMDUPGRADE": 1 if beta else 0})
async def async_send_play_command(self, device: OasisDevice) -> None: async def async_send_play_command(self, device: OasisDevice) -> None:
"""
Send the play command to the device.
"""
await self._async_command(params={"CMDPLAY": ""}) await self._async_command(params={"CMDPLAY": ""})
async def async_send_pause_command(self, device: OasisDevice) -> None: async def async_send_pause_command(self, device: OasisDevice) -> None:
"""
Send a pause command to the device.
"""
await self._async_command(params={"CMDPAUSE": ""}) await self._async_command(params={"CMDPAUSE": ""})
async def async_send_stop_command(self, device: OasisDevice) -> None: async def async_send_stop_command(self, device: OasisDevice) -> None:
"""
Sends the device stop command to halt playback or activity.
Sends an HTTP command to request the device stop its current operation.
"""
await self._async_command(params={"CMDSTOP": ""}) await self._async_command(params={"CMDSTOP": ""})
async def async_send_reboot_command(self, device: OasisDevice) -> None: async def async_send_reboot_command(self, device: OasisDevice) -> None:
"""
Send a reboot command to the device.
Sends a reboot request to the target device using the CMDBOOT control parameter.
"""
await self._async_command(params={"CMDBOOT": ""}) await self._async_command(params={"CMDBOOT": ""})
async def async_get_status(self, device: OasisDevice) -> None: async def async_get_status(self, device: OasisDevice) -> None:
"""Fetch status via GETSTATUS and update the device.""" """
Retrieve the device status from the device and apply it to the given OasisDevice.
If the device does not return a status, the device object is not modified.
Parameters:
device (OasisDevice): Device instance to update with the fetched status.
"""
raw_status = await self._async_get(params={"GETSTATUS": ""}) raw_status = await self._async_get(params={"GETSTATUS": ""})
if raw_status is None: if raw_status is None:
return return

View File

@@ -42,6 +42,24 @@ class OasisMqttClient(OasisClientProtocol):
def __init__(self) -> None: def __init__(self) -> None:
# MQTT connection state # MQTT connection state
"""
Initialize internal state for the MQTT transport client.
Sets up connection state, per-device registries and events, subscription bookkeeping, and a bounded pending command queue capped by MAX_PENDING_COMMANDS.
Attributes:
_client: Active aiomqtt client or None.
_loop_task: Background MQTT loop task or None.
_connected_at: Timestamp of last successful connection or None.
_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.
_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.
_command_queue: Bounded queue of pending (serial, payload) commands.
"""
self._client: aiomqtt.Client | None = None self._client: aiomqtt.Client | None = None
self._loop_task: asyncio.Task | None = None self._loop_task: asyncio.Task | None = None
self._connected_at: datetime | None = None self._connected_at: datetime | None = None
@@ -53,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
@@ -65,8 +83,32 @@ 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 a device so MQTT messages can be routed to it.""" """
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.
Parameters:
device (OasisDevice): The device instance to register.
Raises:
ValueError: If `device.serial_number` is not set.
"""
if not device.serial_number: if not device.serial_number:
raise ValueError("Device must have serial_number set before registration") raise ValueError("Device must have serial_number set before registration")
@@ -74,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
@@ -93,17 +135,30 @@ class OasisMqttClient(OasisClientProtocol):
) )
def register_devices(self, devices: Iterable[OasisDevice]) -> None: def register_devices(self, devices: Iterable[OasisDevice]) -> None:
"""Convenience method to register multiple devices.""" """
Register multiple OasisDevice instances with the client.
Parameters:
devices (Iterable[OasisDevice]): Iterable of devices to register.
"""
for device in devices: for device in devices:
self.register_device(device) self.register_device(device)
def unregister_device(self, device: OasisDevice) -> None: def unregister_device(self, device: OasisDevice) -> None:
"""
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.
Parameters:
device (OasisDevice): The device to unregister; must have `serial_number` set.
"""
serial = device.serial_number serial = device.serial_number
if not serial: if not serial:
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
@@ -118,7 +173,11 @@ class OasisMqttClient(OasisClientProtocol):
) )
async def _subscribe_serial(self, serial: str) -> None: async def _subscribe_serial(self, serial: str) -> None:
"""Subscribe to STATUS topics for a single device.""" """
Subscribe to the device's STATUS topic pattern and mark the device as subscribed.
Subscribes to "<serial>/STATUS/#" with QoS 1 and records the subscription; does nothing if the MQTT client is not connected or the serial is already subscribed.
"""
if not self._client: if not self._client:
return return
@@ -132,7 +191,13 @@ class OasisMqttClient(OasisClientProtocol):
_LOGGER.info("Subscribed to %s", topic) _LOGGER.info("Subscribed to %s", topic)
async def _unsubscribe_serial(self, serial: str) -> None: async def _unsubscribe_serial(self, serial: str) -> None:
"""Unsubscribe from STATUS topics for a single device.""" """
Unsubscribe from the device's STATUS topic and update subscription state.
If there is no active MQTT client or the serial is not currently subscribed, this is a no-op.
Parameters:
serial (str): Device serial used to build the topic "<serial>/STATUS/#".
"""
if not self._client: if not self._client:
return return
@@ -150,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."""
@@ -164,47 +230,77 @@ class OasisMqttClient(OasisClientProtocol):
await self.stop() await self.stop()
async def stop(self) -> None: async def stop(self) -> None:
"""Stop MQTT connection loop.""" """
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.
"""
_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
) -> bool: ) -> bool:
""" """
Wait until: Block until the MQTT client is connected and the device has emitted at least one STATUS message.
1. MQTT client is connected
2. Device sends at least one STATUS message
If request_status=True, a request status command is sent *after* connection. If `request_status` is True, a status request is sent after the client is connected to prompt the device to report its state.
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.
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.
Raises:
RuntimeError: If the provided device does not have a `serial_number`.
""" """
serial = device.serial_number serial = device.serial_number
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()
) )
@@ -222,28 +318,41 @@ 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: except Exception: # noqa: BLE001
_LOGGER.debug( _LOGGER.debug(
"Could not request status for %s (not fully connected yet?)", "Could not request status for %s (not fully connected yet?)",
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:
"""For MQTT, GETSTATUS causes MAC_ADDRESS to be published.""" """
Request a device's MAC address via an MQTT STATUS refresh and return it if available.
If the device already has a MAC address, it is returned immediately. Otherwise the function requests a status update (which causes the device to publish MAC_ADDRESS) and waits up to 3 seconds for the MAC to arrive.
Parameters:
device (OasisDevice): The device whose MAC address will be requested.
Returns:
str | None: The device MAC address if obtained, `None` if the wait timed out and no MAC was received.
Raises:
RuntimeError: If the provided device has no serial_number set.
"""
# If already known on the device, return it # If already known on the device, return it
if device.mac_address: if device.mac_address:
return device.mac_address return device.mac_address
@@ -268,7 +377,13 @@ class OasisMqttClient(OasisClientProtocol):
async def async_send_auto_clean_command( async def async_send_auto_clean_command(
self, device: OasisDevice, auto_clean: bool self, device: OasisDevice, auto_clean: bool
) -> None: ) -> None:
"""Send auto clean command.""" """
Set the device's automatic cleaning mode.
Parameters:
device (OasisDevice): Target Oasis device to send the command to.
auto_clean (bool): True to enable automatic cleaning, False to disable.
"""
payload = f"WRIAUTOCLEAN={1 if auto_clean else 0}" payload = f"WRIAUTOCLEAN={1 if auto_clean else 0}"
await self._publish_command(device, payload) await self._publish_command(device, payload)
@@ -277,6 +392,13 @@ class OasisMqttClient(OasisClientProtocol):
device: OasisDevice, device: OasisDevice,
speed: int, speed: int,
) -> None: ) -> None:
"""
Set the device's ball speed.
Parameters:
device (OasisDevice): Target device.
speed (int): Speed value to apply.
"""
payload = f"WRIOASISSPEED={speed}" payload = f"WRIOASISSPEED={speed}"
await self._publish_command(device, payload) await self._publish_command(device, payload)
@@ -288,10 +410,28 @@ class OasisMqttClient(OasisClientProtocol):
led_speed: int, led_speed: int,
brightness: int, brightness: int,
) -> None: ) -> None:
"""
Send an LED configuration command to the device.
If `brightness` is greater than zero, the device is woken before sending the command.
Parameters:
device (OasisDevice): Target device (must have a serial number).
led_effect (str): LED effect identifier to apply.
color (str): Color value for the LED effect (format expected by device).
led_speed (int): Speed/tempo for the LED effect.
brightness (int): Brightness level to set; also used to determine wake behavior.
"""
payload = f"WRILED={led_effect};0;{color};{led_speed};{brightness}" payload = f"WRILED={led_effect};0;{color};{led_speed};{brightness}"
await self._publish_command(device, payload, bool(brightness)) await self._publish_command(device, payload, bool(brightness))
async def async_send_sleep_command(self, device: OasisDevice) -> None: async def async_send_sleep_command(self, device: OasisDevice) -> None:
"""
Send the sleep command to the specified Oasis device.
Parameters:
device (OasisDevice): Target device; must have a valid serial_number. If the MQTT client is not connected, the command may be queued for delivery when a connection is available.
"""
await self._publish_command(device, "CMDSLEEP") await self._publish_command(device, "CMDSLEEP")
async def async_send_move_job_command( async def async_send_move_job_command(
@@ -300,6 +440,14 @@ class OasisMqttClient(OasisClientProtocol):
from_index: int, from_index: int,
to_index: int, to_index: int,
) -> None: ) -> None:
"""
Move a job in the device's playlist from one index to another.
Parameters:
device (OasisDevice): Target device to receive the command.
from_index (int): Source index of the job in the playlist.
to_index (int): Destination index where the job should be placed.
"""
payload = f"MOVEJOB={from_index};{to_index}" payload = f"MOVEJOB={from_index};{to_index}"
await self._publish_command(device, payload) await self._publish_command(device, payload)
@@ -308,6 +456,13 @@ class OasisMqttClient(OasisClientProtocol):
device: OasisDevice, device: OasisDevice,
index: int, index: int,
) -> None: ) -> None:
"""
Change the device's current track to the specified track index.
Parameters:
device (OasisDevice): Target Oasis device.
index (int): Track index to switch to (zero-based).
"""
payload = f"CMDCHANGETRACK={index}" payload = f"CMDCHANGETRACK={index}"
await self._publish_command(device, payload) await self._publish_command(device, payload)
@@ -316,6 +471,13 @@ class OasisMqttClient(OasisClientProtocol):
device: OasisDevice, device: OasisDevice,
tracks: list[int], tracks: list[int],
) -> None: ) -> None:
"""
Send an ADDJOBLIST command to add multiple tracks to the device's job list.
Parameters:
device (OasisDevice): Target device to receive the command.
tracks (list[int]): List of track indices to add; elements will be joined as a comma-separated list in the command payload.
"""
track_str = ",".join(map(str, tracks)) track_str = ",".join(map(str, tracks))
payload = f"ADDJOBLIST={track_str}" payload = f"ADDJOBLIST={track_str}"
await self._publish_command(device, payload) await self._publish_command(device, payload)
@@ -325,6 +487,13 @@ class OasisMqttClient(OasisClientProtocol):
device: OasisDevice, device: OasisDevice,
playlist: list[int], playlist: list[int],
) -> None: ) -> None:
"""
Set the device's playlist to the specified ordered list of track indices.
Parameters:
device (OasisDevice): Target Oasis device to receive the playlist command.
playlist (list[int]): Ordered list of track indices to apply as the device's playlist.
"""
track_str = ",".join(map(str, playlist)) track_str = ",".join(map(str, playlist))
payload = f"WRIJOBLIST={track_str}" payload = f"WRIJOBLIST={track_str}"
await self._publish_command(device, payload) await self._publish_command(device, payload)
@@ -334,6 +503,13 @@ class OasisMqttClient(OasisClientProtocol):
device: OasisDevice, device: OasisDevice,
repeat: bool, repeat: bool,
) -> None: ) -> None:
"""
Send a command to enable or disable repeating the device's playlist.
Parameters:
device (OasisDevice): Target device; must have a serial number.
repeat (bool): True to enable playlist repeat, False to disable it.
"""
payload = f"WRIREPEATJOB={1 if repeat else 0}" payload = f"WRIREPEATJOB={1 if repeat else 0}"
await self._publish_command(device, payload) await self._publish_command(device, payload)
@@ -342,6 +518,15 @@ class OasisMqttClient(OasisClientProtocol):
device: OasisDevice, device: OasisDevice,
option: str, option: str,
) -> None: ) -> None:
"""
Set the device's wait-after-job / autoplay option.
Publishes a "WRIWAITAFTER=<option>" command for the specified device to configure how long the device waits after a job or to adjust autoplay behavior.
Parameters:
device (OasisDevice): Target device (must have a serial_number).
option (str): Value accepted by the device firmware for the wait-after-job/autoplay setting (typically a numeric string or predefined option token).
"""
payload = f"WRIWAITAFTER={option}" payload = f"WRIWAITAFTER={option}"
await self._publish_command(device, payload) await self._publish_command(device, payload)
@@ -350,19 +535,48 @@ class OasisMqttClient(OasisClientProtocol):
device: OasisDevice, device: OasisDevice,
beta: bool, beta: bool,
) -> None: ) -> None:
"""
Request a firmware upgrade for the given device.
Sends an upgrade command to the device and selects the beta channel when requested.
Parameters:
device (OasisDevice): Target device.
beta (bool): If `True`, request a beta firmware upgrade; if `False`, request the stable firmware.
"""
payload = f"CMDUPGRADE={1 if beta else 0}" payload = f"CMDUPGRADE={1 if beta else 0}"
await self._publish_command(device, payload) await self._publish_command(device, payload)
async def async_send_play_command(self, device: OasisDevice) -> None: async def async_send_play_command(self, device: OasisDevice) -> None:
"""
Send a "play" command to the given device and wake it if the device is sleeping.
"""
await self._publish_command(device, "CMDPLAY", True) await self._publish_command(device, "CMDPLAY", True)
async def async_send_pause_command(self, device: OasisDevice) -> None: async def async_send_pause_command(self, device: OasisDevice) -> None:
"""
Sends a pause command to the specified Oasis device.
Publishes the "CMDPAUSE" command to the device's command topic.
"""
await self._publish_command(device, "CMDPAUSE") await self._publish_command(device, "CMDPAUSE")
async def async_send_stop_command(self, device: OasisDevice) -> None: async def async_send_stop_command(self, device: OasisDevice) -> None:
"""
Send the "stop" command to the given Oasis device.
Parameters:
device (OasisDevice): Target device to receive the stop command; must be registered with a valid serial number.
"""
await self._publish_command(device, "CMDSTOP") await self._publish_command(device, "CMDSTOP")
async def async_send_reboot_command(self, device: OasisDevice) -> None: async def async_send_reboot_command(self, device: OasisDevice) -> None:
"""
Send a reboot command to the specified Oasis device.
Parameters:
device (OasisDevice): Target device to receive the reboot command; must have a valid serial_number.
"""
await self._publish_command(device, "CMDBOOT") await self._publish_command(device, "CMDBOOT")
async def async_get_all(self, device: OasisDevice) -> None: async def async_get_all(self, device: OasisDevice) -> None:
@@ -370,7 +584,9 @@ class OasisMqttClient(OasisClientProtocol):
await self._publish_command(device, "GETALL") await self._publish_command(device, "GETALL")
async def async_get_status(self, device: OasisDevice) -> None: async def async_get_status(self, device: OasisDevice) -> None:
"""Ask device to publish STATUS topics.""" """
Request the device to publish its current STATUS topics.
"""
await self._publish_command(device, "GETSTATUS") await self._publish_command(device, "GETSTATUS")
async def _enqueue_command(self, serial: str, payload: str) -> None: async def _enqueue_command(self, serial: str, payload: str) -> None:
@@ -393,11 +609,18 @@ class OasisMqttClient(OasisClientProtocol):
_LOGGER.debug("Queued command for %s: %s", serial, payload) _LOGGER.debug("Queued command for %s: %s", serial, payload)
async def _flush_pending_commands(self) -> None: async def _flush_pending_commands(self) -> None:
"""Send any queued commands now that we're connected.""" """
Flush queued commands by publishing them to each device's COMMAND/CMD topic.
This consumes all entries from the internal command queue, skipping entries for devices that are no longer registered, publishing each payload to "<serial>/COMMAND/CMD" with QoS 1, and marking queue tasks done. If a publish fails, the failed command is re-queued and flushing stops so remaining queued commands will be retried on the next reconnect.
"""
if not self._client: if not self._client:
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:
@@ -411,26 +634,37 @@ class OasisMqttClient(OasisClientProtocol):
serial, serial,
payload, payload,
) )
self._command_queue.task_done()
continue continue
topic = f"{serial}/COMMAND/CMD" topic = f"{serial}/COMMAND/CMD"
_LOGGER.debug("Flushing queued MQTT command %s => %s", topic, payload) _LOGGER.debug("Flushing queued MQTT command %s => %s", topic, payload)
await self._client.publish(topic, payload.encode(), qos=1) await self._client.publish(topic, payload.encode(), qos=1)
except Exception: except Exception: # noqa: BLE001
_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
) -> None: ) -> None:
"""
Publish a command payload to the device's MQTT COMMAND topic, queueing it if the client is not connected.
If `wake` is True and the device reports it is sleeping, requests a full status refresh before publishing. If the MQTT client is not connected or publish fails, the command is enqueued for later delivery.
Parameters:
device (OasisDevice): Target device; must have a valid `serial_number`.
payload (str): Command payload to send to the device.
wake (bool): If True, refresh the device state when the device is sleeping before sending the command.
Raises:
RuntimeError: If the provided device has no serial number set.
"""
serial = device.serial_number serial = device.serial_number
if not serial: if not serial:
raise RuntimeError("Device has no serial number set") raise RuntimeError("Device has no serial number set")
@@ -450,13 +684,24 @@ class OasisMqttClient(OasisClientProtocol):
try: try:
_LOGGER.debug("MQTT publish %s => %s", topic, payload) _LOGGER.debug("MQTT publish %s => %s", topic, payload)
await self._client.publish(topic, payload.encode(), qos=1) await self._client.publish(topic, payload.encode(), qos=1)
except Exception: except Exception: # noqa: BLE001
_LOGGER.debug( _LOGGER.debug(
"MQTT publish failed, queueing command for %s: %s", serial, payload "MQTT publish failed, queueing command for %s: %s", serial, payload
) )
await self._enqueue_command(serial, payload) await self._enqueue_command(serial, payload)
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.
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)
@@ -492,7 +737,7 @@ class OasisMqttClient(OasisClientProtocol):
except asyncio.CancelledError: except asyncio.CancelledError:
break break
except Exception: except Exception: # noqa: BLE001
_LOGGER.info("MQTT connection error") _LOGGER.info("MQTT connection error")
finally: finally:
@@ -514,7 +759,18 @@ class OasisMqttClient(OasisClientProtocol):
await asyncio.sleep(RECONNECT_INTERVAL) await asyncio.sleep(RECONNECT_INTERVAL)
async def _handle_status_message(self, msg: aiomqtt.Message) -> None: async def _handle_status_message(self, msg: aiomqtt.Message) -> None:
"""Map MQTT STATUS topics → OasisDevice.update_from_status_dict payloads.""" """
Map an incoming MQTT STATUS message to an OasisDevice state update.
Expects msg.topic in the form "<serial>/STATUS/<STATUS_NAME>" and decodes msg.payload as text.
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.
Parameters:
msg (aiomqtt.Message): Incoming MQTT message; topic identifies device serial and status.
"""
topic_str = str(msg.topic) if msg.topic is not None else "" topic_str = str(msg.topic) if msg.topic is not None else ""
payload = msg.payload.decode(errors="replace") payload = msg.payload.decode(errors="replace")
@@ -540,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":
@@ -601,7 +861,7 @@ class OasisMqttClient(OasisClientProtocol):
status_name, status_name,
payload, payload,
) )
except Exception: # noqa: BLE001 except Exception:
_LOGGER.exception( _LOGGER.exception(
"Error parsing MQTT payload for %s %s: %r", serial, status_name, payload "Error parsing MQTT payload for %s %s: %r", serial, status_name, payload
) )
@@ -610,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,17 +14,40 @@ class OasisClientProtocol(Protocol):
- HTTP client (direct LAN) - HTTP client (direct LAN)
""" """
async def async_get_mac_address(self, device: OasisDevice) -> str | None: ... async def async_get_mac_address(self, device: OasisDevice) -> str | None:
"""
Retrieve the MAC address of the specified Oasis device.
Parameters:
device (OasisDevice): The target device to query.
Returns:
str | None: The device's MAC address as a string, or `None` if the MAC address is unavailable.
"""
async def async_send_auto_clean_command( async def async_send_auto_clean_command(
self, device: OasisDevice, auto_clean: bool self, device: OasisDevice, auto_clean: bool
) -> None: ... ) -> None:
"""
Enable or disable the device's auto-clean mode.
Parameters:
device (OasisDevice): The target Oasis device to send the command to.
auto_clean (bool): `True` to enable auto-clean mode, `False` to disable it.
"""
async def async_send_ball_speed_command( async def async_send_ball_speed_command(
self, self,
device: OasisDevice, device: OasisDevice,
speed: int, speed: int,
) -> None: ... ) -> None:
"""
Set the device's ball speed to the specified value.
Parameters:
device (OasisDevice): Target Oasis device to send the command to.
speed (int): Desired ball speed value for the device.
"""
async def async_send_led_command( async def async_send_led_command(
self, self,
@@ -33,61 +56,167 @@ class OasisClientProtocol(Protocol):
color: str, color: str,
led_speed: int, led_speed: int,
brightness: int, brightness: int,
) -> None: ... ) -> None:
"""
Configure the device's LED effect, color, speed, and brightness.
async def async_send_sleep_command(self, device: OasisDevice) -> None: ... Parameters:
device (OasisDevice): Target Oasis device to receive the LED command.
led_effect (str): Name or identifier of the LED effect to apply.
color (str): Color for the LED effect (format depends on implementation, e.g., hex code or color name).
led_speed (int): Effect speed; larger values increase the animation speed.
brightness (int): Brightness level as a percentage from 0 to 100.
"""
async def async_send_sleep_command(self, device: OasisDevice) -> None:
"""
Put the specified Oasis device into sleep mode.
Parameters:
device (OasisDevice): The target Oasis device to send the sleep command to.
"""
async def async_send_move_job_command( async def async_send_move_job_command(
self, self,
device: OasisDevice, device: OasisDevice,
from_index: int, from_index: int,
to_index: int, to_index: int,
) -> None: ... ) -> None:
"""
Move a job within the device's job list from one index to another.
Parameters:
device (OasisDevice): Target Oasis device.
from_index (int): Source index of the job in the device's job list.
to_index (int): Destination index to move the job to.
"""
async def async_send_change_track_command( async def async_send_change_track_command(
self, self,
device: OasisDevice, device: OasisDevice,
index: int, index: int,
) -> None: ... ) -> None:
"""
Change the device's current track to the specified track index.
Parameters:
device (OasisDevice): The target Oasis device to receive the command.
index (int): The index of the track to select on the device.
"""
async def async_send_add_joblist_command( async def async_send_add_joblist_command(
self, self,
device: OasisDevice, device: OasisDevice,
tracks: list[int], tracks: list[int],
) -> None: ... ) -> None:
"""
Add the given sequence of track indices to the device's job list.
Parameters:
device (OasisDevice): Target Oasis device to receive the new jobs.
tracks (list[int]): Ordered list of track indices to append to the device's job list.
"""
async def async_send_set_playlist_command( async def async_send_set_playlist_command(
self, self,
device: OasisDevice, device: OasisDevice,
playlist: list[int], playlist: list[int],
) -> None: ... ) -> None:
"""
Set the device's current playlist to the provided sequence of track indices.
Parameters:
device (OasisDevice): The target Oasis device to receive the playlist.
playlist (list[int]): Sequence of track indices in the desired playback order.
"""
async def async_send_set_repeat_playlist_command( async def async_send_set_repeat_playlist_command(
self, self,
device: OasisDevice, device: OasisDevice,
repeat: bool, repeat: bool,
) -> None: ... ) -> None:
"""
Set whether the device should repeat the current playlist.
Parameters:
repeat (bool): True to enable repeating the current playlist, False to disable it.
"""
async def async_send_set_autoplay_command( async def async_send_set_autoplay_command(
self, self,
device: OasisDevice, device: OasisDevice,
option: str, option: str,
) -> None: ... ) -> None:
"""
Send a command to configure the device's autoplay behavior.
Parameters:
device (OasisDevice): Target Oasis device to receive the command.
option (str): Autoplay option to set (e.g., "on", "off", "shuffle", or other device-supported mode).
"""
async def async_send_upgrade_command( async def async_send_upgrade_command(
self, self,
device: OasisDevice, device: OasisDevice,
beta: bool, beta: bool,
) -> None: ... ) -> None:
"""
Initiates a firmware upgrade on the given Oasis device.
async def async_send_play_command(self, device: OasisDevice) -> None: ... If `beta` is True, requests the device to use the beta upgrade channel; otherwise requests the stable channel.
async def async_send_pause_command(self, device: OasisDevice) -> None: ... Parameters:
device (OasisDevice): Target device to upgrade.
beta (bool): Whether to use the beta upgrade channel (`True`) or the stable channel (`False`).
"""
async def async_send_stop_command(self, device: OasisDevice) -> None: ... async def async_send_play_command(self, device: OasisDevice) -> None:
"""
Send a play command to the specified Oasis device.
async def async_send_reboot_command(self, device: OasisDevice) -> None: ... Parameters:
device (OasisDevice): The target device to instruct to start playback.
"""
async def async_get_all(self, device: OasisDevice) -> None: ... async def async_send_pause_command(self, device: OasisDevice) -> None:
"""
Pause playback on the specified Oasis device.
async def async_get_status(self, device: OasisDevice) -> None: ... This sends a pause command to the device so it stops current playback.
"""
async def async_send_stop_command(self, device: OasisDevice) -> None:
"""
Send a stop command to the specified Oasis device to halt playback.
Parameters:
device (OasisDevice): The target Oasis device to receive the stop command.
"""
async def async_send_reboot_command(self, device: OasisDevice) -> None:
"""
Send a reboot command to the specified Oasis device.
Parameters:
device (OasisDevice): The target Oasis device to reboot.
"""
async def async_get_all(self, device: OasisDevice) -> None:
"""
Fetch comprehensive device data for the specified Oasis device.
This method triggers retrieval of all relevant information (configuration, status, and runtime data) for the given device so the client's representation of that device can be refreshed.
Parameters:
device (OasisDevice): Target device whose data should be fetched and refreshed.
"""
async def async_get_status(self, device: OasisDevice) -> None:
"""
Retrieve the current runtime status for the specified Oasis device.
Implementations should query the device for its current state (for example: playback, LED settings, job/track lists, and connectivity) and update any client-side representation or caches as needed.
Parameters:
device (OasisDevice): The target device to query.
"""

View File

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

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from datetime import datetime
import logging import logging
from typing import TYPE_CHECKING, Any, Callable, Final, Iterable from typing import TYPE_CHECKING, Any, Callable, Final, Iterable
@@ -10,10 +11,19 @@ from .const import (
ERROR_CODE_MAP, ERROR_CODE_MAP,
LED_EFFECTS, LED_EFFECTS,
STATUS_CODE_MAP, STATUS_CODE_MAP,
STATUS_CODE_SLEEPING, STATUS_ERROR,
STATUS_PLAYING,
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_image_url_from_track,
now,
)
if TYPE_CHECKING: # avoid runtime circular imports if TYPE_CHECKING: # avoid runtime circular imports
from .clients import OasisCloudClient from .clients import OasisCloudClient
@@ -69,7 +79,23 @@ 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.
Parameters:
model (str | None): Device model identifier.
serial_number (str | None): Device serial number.
name (str | None): Human-readable device name; if omitted, defaults to "<model> <serial_number>".
ssid (str | None): Last-known Wi-Fi SSID for the device.
ip_address (str | None): Last-known IP address for the device.
cloud (OasisCloudClient | None): Optional cloud client used to fetch track metadata and remote data.
client (OasisClientProtocol | None): Optional transport client used to send commands to the device.
Notes:
- Creates an internal listener list for state-change callbacks.
- Initializes status fields (brightness, playlist, playback state, networking, etc.) with sensible defaults.
- Initializes a track metadata cache and a placeholder for a background refresh task.
"""
self._cloud = cloud self._cloud = cloud
self._client = client self._client = client
self._listeners: list[Callable[[], None]] = [] self._listeners: list[Callable[[], None]] = []
@@ -116,21 +142,45 @@ class OasisDevice:
self._track: dict | None = None self._track: dict | None = None
self._track_task: asyncio.Task | None = None self._track_task: asyncio.Task | None = None
# Diagnostic metadata
self.last_updated: datetime | None = None
@property @property
def brightness(self) -> int: def brightness(self) -> int:
"""Return the brightness.""" """
Current display brightness adjusted for the device sleep state.
Returns:
int: 0 when the device is sleeping, otherwise the stored brightness value.
"""
return 0 if self.is_sleeping else self._brightness return 0 if self.is_sleeping else self._brightness
@brightness.setter @brightness.setter
def brightness(self, value: int) -> None: def brightness(self, value: int) -> None:
"""
Set the device brightness and update brightness_on when non-zero.
Parameters:
value (int): Brightness level to apply; if non-zero, also stored in `brightness_on`.
"""
self._brightness = value self._brightness = value
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:
"""Return `True` if the status is set to sleeping.""" """
return self.status_code == STATUS_CODE_SLEEPING Indicates whether the device is currently in the sleeping status.
Returns:
`true` if the device is sleeping, `false` otherwise.
"""
return self.status_code == STATUS_SLEEPING
def attach_client(self, client: OasisClientProtocol) -> None: def attach_client(self, client: OasisClientProtocol) -> None:
"""Attach a transport client (MQTT, HTTP, etc.) to this device.""" """Attach a transport client (MQTT, HTTP, etc.) to this device."""
@@ -138,11 +188,24 @@ class OasisDevice:
@property @property
def client(self) -> OasisClientProtocol | None: def client(self) -> OasisClientProtocol | None:
"""Return the current transport client, if any.""" """
Get the attached transport client, or `None` if no client is attached.
Returns:
The attached transport client, or `None` if not attached.
"""
return self._client return self._client
def _require_client(self) -> OasisClientProtocol: def _require_client(self) -> OasisClientProtocol:
"""Return the attached client or raise if missing.""" """
Get the attached transport client for this device.
Returns:
OasisClientProtocol: The attached transport client.
Raises:
RuntimeError: If no client/transport is attached to the device.
"""
if self._client is None: if self._client is None:
raise RuntimeError( raise RuntimeError(
f"No client/transport attached for device {self.serial_number!r}" f"No client/transport attached for device {self.serial_number!r}"
@@ -150,6 +213,18 @@ class OasisDevice:
return self._client return self._client
def _update_field(self, name: str, value: Any) -> bool: def _update_field(self, name: str, value: Any) -> bool:
"""
Update an attribute on the device if the new value differs from the current value.
Sets the instance attribute named `name` to `value` and logs a debug message when a change occurs.
Parameters:
name (str): The attribute name to update.
value (Any): The new value to assign to the attribute.
Returns:
bool: True if the attribute was changed, False otherwise.
"""
old = getattr(self, name, None) old = getattr(self, name, None)
if old != value: if old != value:
_LOGGER.debug( _LOGGER.debug(
@@ -164,7 +239,13 @@ class OasisDevice:
return False return False
def update_from_status_dict(self, data: dict[str, Any]) -> None: def update_from_status_dict(self, data: dict[str, Any]) -> None:
"""Update device fields from a status payload (from any transport).""" """
Update the device's attributes from a status dictionary.
Expects a mapping of attribute names to values; known attributes are applied to the device,
unknown keys are logged and ignored. If `playlist` or `playlist_index` change, a track
refresh is scheduled. If any attribute changed, registered update listeners are notified.
"""
changed = False changed = False
playlist_or_index_changed = False playlist_or_index_changed = False
@@ -183,12 +264,25 @@ class OasisDevice:
if changed: if changed:
self._notify_listeners() self._notify_listeners()
def parse_status_string(self, raw_status: str) -> dict[str, Any] | None: self.last_updated = now()
"""Parse a semicolon-separated status string into a state dict.
Used by: def parse_status_string(self, raw_status: str) -> dict[str, Any] | None:
- HTTP GETSTATUS response """
- MQTT FULLSTATUS payload (includes software_version) Parse a semicolon-separated device status string into a structured state dictionary.
Expects a semicolon-separated string containing at least 18 fields (device status format returned by the device: e.g., HTTP GETSTATUS or MQTT FULLSTATUS). Returns None for empty input or if the string cannot be parsed into the expected fields.
Parameters:
raw_status (str): Semicolon-separated status string from the device.
Returns:
dict[str, Any] | None: A dictionary with these keys on success:
- `status_code`, `error`, `ball_speed`, `playlist` (list[int]), `playlist_index`,
`progress`, `led_effect`, `led_color_id`, `led_speed`, `brightness`, `color`,
`busy`, `download_progress`, `brightness_max`, `wifi_connected`, `repeat_playlist`,
`autoplay`, `auto_clean`
- `software_version` (str) is included if an additional trailing field is present.
Returns `None` if the input is empty or parsing fails.
""" """
if not raw_status: if not raw_status:
return None return None
@@ -202,7 +296,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] = {
@@ -230,7 +328,7 @@ class OasisDevice:
if n > 18: if n > 18:
status["software_version"] = values[18] status["software_version"] = values[18]
except Exception: # noqa: BLE001 except Exception:
_LOGGER.exception( _LOGGER.exception(
"Error parsing status string for %s: %r", self.serial_number, raw_status "Error parsing status string for %s: %r", self.serial_number, raw_status
) )
@@ -239,35 +337,71 @@ class OasisDevice:
return status return status
def update_from_status_string(self, raw_status: str) -> None: def update_from_status_string(self, raw_status: str) -> None:
"""Parse and apply a raw status string.""" """
Parse a semicolon-separated device status string and apply the resulting fields to the device state.
If the string cannot be parsed into a valid status dictionary, no state is changed.
Parameters:
raw_status (str): Raw status payload received from the device (semicolon-separated fields).
"""
if status := self.parse_status_string(raw_status): if status := self.parse_status_string(raw_status):
self.update_from_status_dict(status) self.update_from_status_dict(status)
def as_dict(self) -> dict[str, Any]: def as_dict(self) -> dict[str, Any]:
"""Return core state as a dict.""" """
Return a mapping of the device's core state fields to their current values.
Returns:
dict[str, Any]: A dictionary whose keys are the core state field names (as defined in _STATE_FIELDS)
and whose values are the current values for those fields.
"""
return {field: getattr(self, field) for field in _STATE_FIELDS} return {field: getattr(self, field) for field in _STATE_FIELDS}
@property @property
def error_message(self) -> str | None: def error_message(self) -> str | None:
"""Return the error message, if any.""" """
if self.status_code == 9: Get the human-readable error message for the current device error code.
Returns:
str: The mapped error message when the device status indicates an error (status code 9); `None` otherwise.
"""
if self.status_code == STATUS_ERROR:
return ERROR_CODE_MAP.get(self.error, f"Unknown ({self.error})") return ERROR_CODE_MAP.get(self.error, f"Unknown ({self.error})")
return None return None
@property @property
def status(self) -> str: def status(self) -> str:
"""Return human-readable status from status_code.""" """
Get a human-readable status description for the current status_code.
Returns:
str: Human-readable status corresponding to the device's status_code, or "Unknown (<code>)" when the code is not recognized.
"""
return STATUS_CODE_MAP.get(self.status_code, f"Unknown ({self.status_code})") return STATUS_CODE_MAP.get(self.status_code, f"Unknown ({self.status_code})")
@property @property
def track(self) -> dict | None: def track(self) -> dict | None:
"""Return cached track info if it matches the current `track_id`.""" """
Return the cached track metadata when it corresponds to the current track, otherwise retrieve the built-in track metadata.
Returns:
dict | None: The track metadata dictionary for the current `track_id`, or `None` if no matching track is available.
"""
if (track := self._track) and track["id"] == self.track_id: if (track := self._track) and track["id"] == self.track_id:
return track return track
return TRACKS.get(self.track_id) return TRACKS.get(self.track_id)
@property @property
def track_id(self) -> int | None: def track_id(self) -> int | None:
"""
Determine the current track id from the active playlist.
If the playlist index is beyond the end of the playlist, the first track id is returned.
Returns:
int | None: The current track id, or `None` if there is no playlist.
"""
if not self.playlist: if not self.playlist:
return None return None
i = self.playlist_index i = self.playlist_index
@@ -275,21 +409,38 @@ class OasisDevice:
@property @property
def track_image_url(self) -> str | None: def track_image_url(self) -> str | None:
"""Return the track image url, if any.""" """
if (track := self.track) and (image := track.get("image")): Get the full HTTPS URL for the current track's image if available.
return f"https://app.grounded.so/uploads/{image}"
return None Returns:
str: Full URL to the track image or `None` if no image is available.
"""
return get_image_url_from_track(self.track)
@property @property
def track_name(self) -> str | None: def track_name(self) -> str | None:
"""Return the track name, if any.""" """
Get the current track's display name.
If the current track has no explicit name, returns "Unknown Title (#{track_id})". If there is no current track, returns None.
Returns:
str | None: The track name, or `None` if no current track is available.
"""
if track := self.track: if track := self.track:
return track.get("name", f"Unknown Title (#{self.track_id})") return track.get("name", f"Unknown Title (#{self.track_id})")
return None return None
@property @property
def drawing_progress(self) -> float | None: def drawing_progress(self) -> float | None:
"""Return drawing progress percentage for the current track.""" """
Compute drawing progress percentage for the current track.
If the current track or its SVG content is unavailable, returns None.
Returns:
progress_percent (float | None): Percentage of the drawing completed (0-100), clamped to 100; `None` if no track or SVG content is available.
"""
if not (self.track and (svg_content := self.track.get("svg_content"))): if not (self.track and (svg_content := self.track.get("svg_content"))):
return None return None
svg_content = decrypt_svg_content(svg_content) svg_content = decrypt_svg_content(svg_content)
@@ -300,27 +451,47 @@ class OasisDevice:
@property @property
def playlist_details(self) -> dict[int, dict[str, str]]: def playlist_details(self) -> dict[int, dict[str, str]]:
"""Basic playlist details using built-in TRACKS metadata.""" """
Build a mapping of track IDs in the current playlist to their detail dictionaries, preferring the device's cached/current track data and falling back to built-in TRACKS.
Returns:
dict[int, dict[str, str]]: A mapping from track ID to a details dictionary (contains at least a `'name'` key). If track metadata is available from the device cache or built-in TRACKS it is used; otherwise a fallback `{"name": "Unknown Title (#<id>)"}` is provided.
"""
base = dict(TRACKS)
if (current_id := self.track_id) is not None and self.track:
base[current_id] = self.track
return { return {
track_id: {self.track_id: self.track or {}, **TRACKS}.get( track_id: base.get(track_id, {"name": f"Unknown Title (#{track_id})"})
track_id,
{"name": f"Unknown Title (#{track_id})"},
)
for track_id in self.playlist for track_id in self.playlist
} }
def create_svg(self) -> str | None: def create_svg(self) -> str | None:
"""Create the current svg based on track and progress.""" """
Generate an SVG representing the current track at the device's drawing progress.
Returns:
svg (str | None): SVG content for the current track reflecting current progress, or None if track data is unavailable.
"""
return create_svg(self.track, self.progress) return create_svg(self.track, self.progress)
def add_update_listener(self, listener: Callable[[], None]) -> Callable[[], None]: def add_update_listener(self, listener: Callable[[], None]) -> Callable[[], None]:
"""Register a callback for state changes. """
Register a callback to be invoked whenever the device state changes.
Returns an unsubscribe function. Parameters:
listener (Callable[[], None]): A zero-argument callback that will be called on state updates.
Returns:
Callable[[], None]: An unsubscribe function that removes the registered listener; calling the unsubscribe function multiple times is safe.
""" """
self._listeners.append(listener) self._listeners.append(listener)
def _unsub() -> None: def _unsub() -> None:
"""
Remove the previously registered listener from the device's listener list if it is present.
This function silently does nothing if the listener is not found.
"""
try: try:
self._listeners.remove(listener) self._listeners.remove(listener)
except ValueError: except ValueError:
@@ -329,15 +500,29 @@ class OasisDevice:
return _unsub return _unsub
def _notify_listeners(self) -> None: def _notify_listeners(self) -> None:
"""Call all registered listeners.""" """
Invoke all registered update listeners in registration order.
Each listener is called synchronously; exceptions raised by a listener are caught and logged so other listeners still run.
"""
for listener in list(self._listeners): for listener in list(self._listeners):
try: try:
listener() listener()
except Exception: # noqa: BLE001 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:
"""Return the device MAC address, refreshing via transport if needed.""" """
Get the device MAC address, requesting it from the attached transport client if not already known.
Returns:
mac (str | None): The device MAC address if available, otherwise `None`.
"""
if self.mac_address: if self.mac_address:
return self.mac_address return self.mac_address
@@ -348,10 +533,25 @@ class OasisDevice:
return mac return mac
async def async_set_auto_clean(self, auto_clean: bool) -> None: async def async_set_auto_clean(self, auto_clean: bool) -> None:
"""
Set whether the device performs automatic cleaning.
Parameters:
auto_clean (bool): `True` to enable automatic cleaning, `False` to disable it.
"""
client = self._require_client() client = self._require_client()
await client.async_send_auto_clean_command(self, auto_clean) await client.async_send_auto_clean_command(self, auto_clean)
async def async_set_ball_speed(self, speed: int) -> None: async def async_set_ball_speed(self, speed: int) -> None:
"""
Set the device's ball speed.
Parameters:
speed (int): Desired ball speed in the allowed range (BALL_SPEED_MIN to BALL_SPEED_MAX, inclusive).
Raises:
ValueError: If `speed` is outside the allowed range.
"""
if not BALL_SPEED_MIN <= speed <= BALL_SPEED_MAX: if not BALL_SPEED_MIN <= speed <= BALL_SPEED_MAX:
raise ValueError("Invalid speed specified") raise ValueError("Invalid speed specified")
client = self._require_client() client = self._require_client()
@@ -365,7 +565,19 @@ class OasisDevice:
led_speed: int | None = None, led_speed: int | None = None,
brightness: int | None = None, brightness: int | None = None,
) -> None: ) -> None:
"""Set the Oasis device LED (shared validation & attribute updates).""" """
Set the device LED effect, color, speed, and brightness.
Parameters:
led_effect (str | None): LED effect name; if None, the device's current effect is used. Must be one of the supported LED effects.
color (str | None): Hex color string (e.g. "#rrggbb"); if None, the device's current color is used or `#ffffff` if unset.
led_speed (int | None): LED animation speed; if None, the device's current speed is used. Must be within the allowed LED speed range.
brightness (int | None): Brightness level; if None, the device's current brightness is used. Must be between 0 and the device's `brightness_max`.
Raises:
ValueError: If `led_effect` is not supported, or `led_speed` or `brightness` are outside their valid ranges.
RuntimeError: If no transport client is attached to the device.
"""
if led_effect is None: if led_effect is None:
led_effect = self.led_effect led_effect = self.led_effect
if color is None: if color is None:
@@ -388,18 +600,52 @@ class OasisDevice:
) )
async def async_sleep(self) -> None: async def async_sleep(self) -> None:
"""
Put the device into sleep mode.
Sends a sleep command to the attached transport client.
Raises:
RuntimeError: If no client is attached.
"""
client = self._require_client() client = self._require_client()
await client.async_send_sleep_command(self) await client.async_send_sleep_command(self)
async def async_move_track(self, from_index: int, to_index: int) -> None: async def async_move_track(self, from_index: int, to_index: int) -> None:
"""
Move a track within the device's playlist from one index to another.
Parameters:
from_index (int): Index of the track to move within the current playlist.
to_index (int): Destination index where the track should be placed.
"""
client = self._require_client() client = self._require_client()
await client.async_send_move_job_command(self, from_index, to_index) await client.async_send_move_job_command(self, from_index, to_index)
async def async_change_track(self, index: int) -> None: async def async_change_track(self, index: int) -> None:
"""
Change the device's current track to the track at the given playlist index.
Parameters:
index (int): Zero-based index of the track in the device's current playlist.
"""
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.
Parameters:
track (int | Iterable[int]): A single track id or an iterable of track ids to append to the playlist.
Raises:
RuntimeError: If no transport client is attached to the device.
"""
if isinstance(track, int): if isinstance(track, int):
tracks = [track] tracks = [track]
else: else:
@@ -407,47 +653,114 @@ class OasisDevice:
client = self._require_client() client = self._require_client()
await client.async_send_add_joblist_command(self, tracks) await client.async_send_add_joblist_command(self, tracks)
async def async_set_playlist(self, playlist: int | Iterable[int]) -> None: async def async_set_playlist(
if isinstance(playlist, int): self, playlist: int | Iterable[int], *, start_playing: bool | None = None
playlist_list = [playlist] ) -> None:
else: """
playlist_list = list(playlist) 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.
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 = [playlist] if isinstance(playlist, int) else list(playlist)
if start_playing is None:
start_playing = self.status_code == STATUS_PLAYING
client = self._require_client() client = self._require_client()
await client.async_send_set_playlist_command(self, playlist_list) 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)
async def async_set_repeat_playlist(self, repeat: bool) -> None: async def async_set_repeat_playlist(self, repeat: bool) -> None:
"""
Set whether the device's playlist should repeat.
Parameters:
repeat (bool): True to enable repeating the playlist, False to disable it.
"""
client = self._require_client() client = self._require_client()
await client.async_send_set_repeat_playlist_command(self, repeat) await client.async_send_set_repeat_playlist_command(self, repeat)
async def async_set_autoplay(self, option: bool | int | str) -> None: async def async_set_autoplay(self, option: bool | int | str) -> None:
"""Set autoplay / wait-after behavior.""" """
Set the device's autoplay / wait-after option.
Parameters:
option (bool | int | str): Desired autoplay/wait-after value. If a `bool` is provided, `True` is converted to `"0"` and `False` to `"1"`. Integer or string values are sent as their string representation.
"""
if isinstance(option, bool): if isinstance(option, bool):
option = 0 if option else 1 option = 0 if option else 1
client = self._require_client() client = self._require_client()
await client.async_send_set_autoplay_command(self, str(option)) await client.async_send_set_autoplay_command(self, str(option))
async def async_upgrade(self, beta: bool = False) -> None: async def async_upgrade(self, beta: bool = False) -> None:
"""
Initiates a firmware upgrade on the device.
Parameters:
beta (bool): If True, request a beta (pre-release) firmware; otherwise request the stable firmware.
"""
client = self._require_client() client = self._require_client()
await client.async_send_upgrade_command(self, beta) await client.async_send_upgrade_command(self, beta)
async def async_play(self) -> None: async def async_play(self) -> None:
"""
Send a play command to the device via the attached transport client.
Raises:
RuntimeError: If no transport client is attached.
"""
client = self._require_client() client = self._require_client()
await client.async_send_play_command(self) await client.async_send_play_command(self)
async def async_pause(self) -> None: async def async_pause(self) -> None:
"""
Pause playback on the device.
Raises:
RuntimeError: If no transport client is attached.
"""
client = self._require_client() client = self._require_client()
await client.async_send_pause_command(self) await client.async_send_pause_command(self)
async def async_stop(self) -> None: async def async_stop(self) -> None:
"""
Stop playback on the device by sending a stop command through the attached transport client.
Raises:
RuntimeError: if no transport client is attached to the device.
"""
client = self._require_client() client = self._require_client()
await client.async_send_stop_command(self) await client.async_send_stop_command(self)
async def async_reboot(self) -> None: async def async_reboot(self) -> None:
"""
Reboots the device using the attached transport client.
Requests the attached client to send a reboot command to the device.
Raises:
RuntimeError: If no transport client is attached.
"""
client = self._require_client() client = self._require_client()
await client.async_send_reboot_command(self) await client.async_send_reboot_command(self)
def schedule_track_refresh(self) -> None: def schedule_track_refresh(self) -> None:
"""Schedule an async refresh of current track info if track_id changed.""" """
Schedule a background refresh of the current track metadata when the device's track may have changed.
Does nothing if no cloud client is attached or if there is no running event loop. If a previous refresh task is still pending, it is cancelled before a new background task is scheduled.
"""
if not self._cloud: if not self._cloud:
return return
@@ -463,7 +776,11 @@ class OasisDevice:
self._track_task = loop.create_task(self._async_refresh_current_track()) self._track_task = loop.create_task(self._async_refresh_current_track())
async def _async_refresh_current_track(self) -> None: async def _async_refresh_current_track(self) -> None:
"""Refresh the current track info.""" """
Refresh cached information for the current track by fetching details from the attached cloud client and notify listeners when updated.
If no cloud client is attached, no current track exists, or the cached track already matches the current track id, the method returns without change. On successful fetch, updates the device's track cache and invokes registered update listeners.
"""
if not self._cloud: if not self._cloud:
return return
@@ -476,7 +793,7 @@ class OasisDevice:
try: try:
track = await self._cloud.async_get_track_info(track_id) track = await self._cloud.async_get_track_info(track_id)
except Exception: # noqa: BLE001 except Exception:
_LOGGER.exception("Error fetching track info for %s", track_id) _LOGGER.exception("Error fetching track info for %s", track_id)
return return

View File

@@ -6,6 +6,7 @@ import base64
from datetime import UTC, datetime from datetime import UTC, datetime
import logging import logging
import math import math
from typing import Any
from xml.etree.ElementTree import Element, SubElement, tostring from xml.etree.ElementTree import Element, SubElement, tostring
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
@@ -21,22 +22,43 @@ COLOR_LIGHT_SHADE = ("#FFFFFF", "#86888F")
COLOR_MEDIUM_SHADE = ("#E5E2DE", "#86888F") COLOR_MEDIUM_SHADE = ("#E5E2DE", "#86888F")
COLOR_MEDIUM_TINT = ("#B8B8B8", "#FFFFFF") COLOR_MEDIUM_TINT = ("#B8B8B8", "#FFFFFF")
IMAGE_URL = "https://app.grounded.so/uploads/{image}"
def _bit_to_bool(val: str) -> bool: def _bit_to_bool(val: str) -> bool:
"""Convert a bit string to bool.""" """Convert a bit string to bool."""
return val == "1" return val == "1"
def _parse_int(val: str) -> int: def _parse_int(val: Any | None) -> int:
"""Convert an int string to int.""" """
Parse a string into an integer, falling back to 0 when conversion fails.
Parameters:
val (Any | None): String potentially containing an integer value.
Returns:
int: The parsed integer, or 0 if `val` cannot be converted.
"""
try: try:
return int(val) return int(val)
except Exception: except (TypeError, ValueError):
return 0 return 0
def create_svg(track: dict, progress: int) -> str | None: def create_svg(track: dict, progress: int) -> str | None:
"""Create an SVG from a track based on progress.""" """
Create an SVG visualization of a track showing progress as a completed path and indicator.
Builds an SVG representation from the track's "svg_content" and the provided progress value. If progress is supplied, the function will decrypt the stored SVG content (if needed), compute which path segments are complete using the track's optional "reduced_svg_content_new" value or the number of path segments, and render a base arc, completed arc, track, completed track segment, background circle, and a ball indicator positioned at the current progress point. Returns None if input is missing or an error occurs.
Parameters:
track (dict): Track data containing at minimum an "svg_content" entry and optionally "reduced_svg_content_new" to indicate total segments.
progress (int): Current progress expressed as a count relative to the track's total segments.
Returns:
str | None: Serialized SVG markup as a UTF-8 string when successful, otherwise `None`.
"""
if track and (svg_content := track.get("svg_content")): if track and (svg_content := track.get("svg_content")):
try: try:
if progress is not None: if progress is not None:
@@ -150,8 +172,8 @@ def create_svg(track: dict, progress: int) -> str | None:
) )
return tostring(svg).decode() return tostring(svg).decode()
except Exception as e: except Exception:
_LOGGER.exception(e) _LOGGER.exception("Error creating svg")
return None return None
@@ -180,5 +202,17 @@ def decrypt_svg_content(svg_content: dict[str, str]):
return decrypted 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: def now() -> datetime:
return datetime.now(UTC) return datetime.now(UTC)

View File

@@ -22,7 +22,16 @@ AUTOPLAY_MAP_LIST = list(AUTOPLAY_MAP)
def playlists_update_handler(entity: OasisDeviceSelectEntity) -> None: def playlists_update_handler(entity: OasisDeviceSelectEntity) -> None:
"""Handle playlists updates.""" """
Update the playlists select options and current option from the device's cloud playlists.
Iterates the device's cloud playlists to build a display list of playlist names (appending " (N)" for duplicate names)
and sets the entity's options to that list. If the device's current playlist matches a playlist's pattern IDs,
sets the entity's current option to that playlist's display name; otherwise leaves it None.
Parameters:
entity (OasisDeviceSelectEntity): The select entity to update.
"""
# pylint: disable=protected-access # pylint: disable=protected-access
device = entity.device device = entity.device
counts = defaultdict(int) counts = defaultdict(int)
@@ -41,7 +50,14 @@ def playlists_update_handler(entity: OasisDeviceSelectEntity) -> None:
def queue_update_handler(entity: OasisDeviceSelectEntity) -> None: def queue_update_handler(entity: OasisDeviceSelectEntity) -> None:
"""Handle queue updates.""" """
Update the select options and current selection for the device's playback queue.
Populate the entity's options from the device's current playlist and playlist details, disambiguating duplicate track names by appending a counter (e.g., "Title (2)"). Set the entity's current option to the track at device.playlist_index (or None if the queue is empty).
Parameters:
entity (OasisDeviceSelectEntity): The select entity whose options and current option will be updated.
"""
# pylint: disable=protected-access # pylint: disable=protected-access
device = entity.device device = entity.device
counts = defaultdict(int) counts = defaultdict(int)
@@ -66,13 +82,31 @@ def queue_update_handler(entity: OasisDeviceSelectEntity) -> None:
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant, # noqa: ARG001
entry: OasisDeviceConfigEntry, entry: OasisDeviceConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Oasis device select using config entry.""" """
Set up select entities for each Oasis device from a config entry.
Creates OasisDeviceSelectEntity instances for every device and descriptor and registers them with Home Assistant via the platform setup.
Parameters:
hass (HomeAssistant): Home Assistant core object.
entry (OasisDeviceConfigEntry): Configuration entry containing runtime data and devices to expose.
async_add_entities (AddEntitiesCallback): Callback to add created entities to Home Assistant.
"""
def make_entities(new_devices: list[OasisDevice]): def make_entities(new_devices: list[OasisDevice]):
"""
Create select entity instances for each provided Oasis device.
Parameters:
new_devices (list[OasisDevice]): Devices to create select entities for.
Returns:
list[OasisDeviceSelectEntity]: A flat list of OasisDeviceSelectEntity objects created for every combination of device and descriptor.
"""
return [ return [
OasisDeviceSelectEntity(entry.runtime_data, device, descriptor) OasisDeviceSelectEntity(entry.runtime_data, device, descriptor)
for device in new_devices for device in new_devices
@@ -133,17 +167,37 @@ class OasisDeviceSelectEntity(OasisDeviceEntity, SelectEntity):
device: OasisDevice, device: OasisDevice,
description: EntityDescription, description: EntityDescription,
) -> None: ) -> None:
"""Construct an Oasis device select entity.""" """
Initialize the Oasis device select entity and perform an initial coordinator update.
Parameters:
coordinator (OasisDeviceCoordinator): Coordinator that manages device updates.
device (OasisDevice): The Oasis device this entity represents.
description (EntityDescription): Metadata describing this select entity.
"""
super().__init__(coordinator, device, description) super().__init__(coordinator, device, description)
self._handle_coordinator_update() self._handle_coordinator_update()
async def async_select_option(self, option: str) -> None: async def async_select_option(self, option: str) -> None:
"""Change the selected option.""" """
Select and apply the option identified by its display string.
Parameters:
option (str): The display string of the option to select; the option's index in the current options list is used to apply the selection.
"""
await self.entity_description.select_fn(self.device, self.options.index(option)) await self.entity_description.select_fn(self.device, self.options.index(option))
@callback @callback
def _handle_coordinator_update(self) -> None: def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator.""" """
Update the entity's cached value and current option when coordinator data changes.
If the derived current value differs from the stored value, update the stored value.
If the entity description provides an update_handler, call it with this entity; otherwise,
set the entity's current option to the string form of the device attribute named by the
description's key. If Home Assistant is available on the entity, delegate to the base
class's _handle_coordinator_update to propagate the state change.
"""
new_value = self.entity_description.current_value(self.device) new_value = self.entity_description.current_value(self.device)
if self._current_value == new_value: if self._current_value == new_value:
return return

View File

@@ -2,7 +2,10 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity, SensorEntity,
SensorEntityDescription, SensorEntityDescription,
SensorStateClass, SensorStateClass,
@@ -17,13 +20,31 @@ from .pyoasiscontrol import OasisDevice
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant, # noqa: ARG001
entry: OasisDeviceConfigEntry, entry: OasisDeviceConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Oasis device sensors using config entry.""" """
Set up and register sensor entities for each Oasis device in the config entry.
Creates sensor entities for every Oasis device available on the provided config entry and adds them to Home Assistant via the provided add-entities callback.
Parameters:
hass (HomeAssistant): Home Assistant core object.
entry (OasisDeviceConfigEntry): Configuration entry containing runtime data and devices to expose.
async_add_entities (AddEntitiesCallback): Callback to add created entities to Home Assistant.
"""
def make_entities(new_devices: list[OasisDevice]): def make_entities(new_devices: list[OasisDevice]):
"""
Create sensor entity instances for each Oasis device and each sensor descriptor.
Parameters:
new_devices (list[OasisDevice]): Devices to create sensor entities for.
Returns:
list[OasisDeviceSensorEntity]: A list containing one sensor entity per combination of device and descriptor from DESCRIPTORS.
"""
return [ return [
OasisDeviceSensorEntity(entry.runtime_data, device, descriptor) OasisDeviceSensorEntity(entry.runtime_data, device, descriptor)
for device in new_devices for device in new_devices
@@ -33,7 +54,7 @@ async def async_setup_entry(
setup_platform_from_coordinator(entry, async_add_entities, make_entities) setup_platform_from_coordinator(entry, async_add_entities, make_entities)
DESCRIPTORS = { DESCRIPTORS = [
SensorEntityDescription( SensorEntityDescription(
key="download_progress", key="download_progress",
translation_key="download_progress", translation_key="download_progress",
@@ -50,7 +71,15 @@ DESCRIPTORS = {
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1, 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( SensorEntityDescription(
key=key, key=key,
translation_key=key, translation_key=key,
@@ -58,14 +87,13 @@ DESCRIPTORS = {
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
) )
for key in ("error", "led_color_id", "status") for key in ("error", "led_color_id", "status")
# for key in ("error_message", "led_color_id", "status") )
}
class OasisDeviceSensorEntity(OasisDeviceEntity, SensorEntity): class OasisDeviceSensorEntity(OasisDeviceEntity, SensorEntity):
"""Oasis device sensor entity.""" """Oasis device sensor entity."""
@property @property
def native_value(self) -> str | None: def native_value(self) -> str | int | float | datetime | None:
"""Return the value reported by the sensor.""" """Provide the current sensor value from the underlying device."""
return getattr(self.device, self.entity_description.key) return getattr(self.device, self.entity_description.key)

View File

@@ -76,15 +76,15 @@
"autoplay": { "autoplay": {
"name": "Autoplay", "name": "Autoplay",
"state": { "state": {
"0": "on", "1": "Off",
"1": "off", "0": "Immediately",
"2": "5 minutes", "2": "After 5 minutes",
"3": "10 minutes", "3": "After 10 minutes",
"4": "30 minutes", "4": "After 30 minutes",
"6": "1 hour", "6": "After 1 hour",
"7": "6 hours", "7": "After 6 hours",
"8": "12 hours", "8": "After 12 hours",
"5": "24 hours" "5": "After 24 hours"
} }
}, },
"playlist": { "playlist": {
@@ -125,6 +125,9 @@
"18": "Error while downloading the job file" "18": "Error while downloading the job file"
} }
}, },
"last_updated": {
"name": "Last updated"
},
"led_color_id": { "led_color_id": {
"name": "LED color ID" "name": "LED color ID"
}, },
@@ -157,9 +160,6 @@
}, },
"invalid_media": { "invalid_media": {
"message": "Invalid media: {media}" "message": "Invalid media: {media}"
},
"playlists_unsupported": {
"message": "Playlists are not currently supported"
} }
} }
} }

View File

@@ -15,13 +15,31 @@ from .pyoasiscontrol import OasisDevice
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant, # noqa: ARG001
entry: OasisDeviceConfigEntry, entry: OasisDeviceConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Oasis device switchs using config entry.""" """
Set up Oasis device switch entities for a config entry.
Creates an OasisDeviceSwitchEntity for each OasisDevice associated with the given config entry (one entity per descriptor in DESCRIPTORS) and registers them with Home Assistant via the coordinator helper.
Parameters:
hass (HomeAssistant): Home Assistant core instance.
entry (OasisDeviceConfigEntry): Config entry containing runtime data used to create device update entities.
async_add_entities (AddEntitiesCallback): Callback to add created entities to Home Assistant.
"""
def make_entities(new_devices: list[OasisDevice]): def make_entities(new_devices: list[OasisDevice]):
"""
Create OasisDeviceSwitchEntity instances for each device and descriptor.
Parameters:
new_devices (list[OasisDevice]): Devices to wrap as switch entities.
Returns:
list[OasisDeviceSwitchEntity]: A list containing one switch entity per device per descriptor from DESCRIPTORS.
"""
return [ return [
OasisDeviceSwitchEntity(entry.runtime_data, device, descriptor) OasisDeviceSwitchEntity(entry.runtime_data, device, descriptor)
for device in new_devices for device in new_devices
@@ -31,13 +49,13 @@ async def async_setup_entry(
setup_platform_from_coordinator(entry, async_add_entities, make_entities) setup_platform_from_coordinator(entry, async_add_entities, make_entities)
DESCRIPTORS = { DESCRIPTORS = (
SwitchEntityDescription( SwitchEntityDescription(
key="auto_clean", key="auto_clean",
translation_key="auto_clean", translation_key="auto_clean",
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
), ),
} )
class OasisDeviceSwitchEntity(OasisDeviceEntity, SwitchEntity): class OasisDeviceSwitchEntity(OasisDeviceEntity, SwitchEntity):
@@ -45,13 +63,24 @@ class OasisDeviceSwitchEntity(OasisDeviceEntity, SwitchEntity):
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:
"""Return True if entity is on.""" """
Determine whether the switch entity is currently on.
Returns:
bool: `True` if the underlying device attribute named by this entity's description key is truthy, `False` otherwise.
"""
return bool(getattr(self.device, self.entity_description.key)) return bool(getattr(self.device, self.entity_description.key))
async def async_turn_off(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off.""" """
Disable the device's automatic cleaning mode.
Sets the device's auto_clean setting to off.
"""
await self.device.async_set_auto_clean(False) await self.device.async_set_auto_clean(False)
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on.""" """
Enable the device's auto-clean feature.
"""
await self.device.async_set_auto_clean(True) await self.device.async_set_auto_clean(True)

View File

@@ -76,15 +76,15 @@
"autoplay": { "autoplay": {
"name": "Autoplay", "name": "Autoplay",
"state": { "state": {
"0": "on", "1": "Off",
"1": "off", "0": "Immediately",
"2": "5 minutes", "2": "After 5 minutes",
"3": "10 minutes", "3": "After 10 minutes",
"4": "30 minutes", "4": "After 30 minutes",
"6": "1 hour", "6": "After 1 hour",
"7": "6 hours", "7": "After 6 hours",
"8": "12 hours", "8": "After 12 hours",
"5": "24 hours" "5": "After 24 hours"
} }
}, },
"playlist": { "playlist": {
@@ -125,6 +125,9 @@
"18": "Error while downloading the job file" "18": "Error while downloading the job file"
} }
}, },
"last_updated": {
"name": "Last updated"
},
"led_color_id": { "led_color_id": {
"name": "LED color ID" "name": "LED color ID"
}, },
@@ -157,9 +160,6 @@
}, },
"invalid_media": { "invalid_media": {
"message": "Invalid media: {media}" "message": "Invalid media: {media}"
},
"playlists_unsupported": {
"message": "Playlists are not currently supported"
} }
} }
} }

View File

@@ -18,6 +18,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import OasisDeviceConfigEntry, setup_platform_from_coordinator from . import OasisDeviceConfigEntry, setup_platform_from_coordinator
from .entity import OasisDeviceEntity from .entity import OasisDeviceEntity
from .pyoasiscontrol import OasisDevice from .pyoasiscontrol import OasisDevice
from .pyoasiscontrol.const import STATUS_UPDATING
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -25,13 +26,29 @@ SCAN_INTERVAL = timedelta(hours=6)
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant, # noqa: ARG001
entry: OasisDeviceConfigEntry, entry: OasisDeviceConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Oasis device updates using config entry.""" """
Set up update entities for Oasis devices from a configuration entry.
Parameters:
hass (HomeAssistant): Home Assistant core instance.
entry (OasisDeviceConfigEntry): Config entry containing runtime data used to create device update entities.
async_add_entities (AddEntitiesCallback): Callback to add created entities to Home Assistant.
"""
def make_entities(new_devices: list[OasisDevice]): def make_entities(new_devices: list[OasisDevice]):
"""
Create update entities for the given Oasis devices.
Parameters:
new_devices (list[OasisDevice]): Devices to create update entities for.
Returns:
list: A list of OasisDeviceUpdateEntity instances corresponding to each device.
"""
return [ return [
OasisDeviceUpdateEntity(entry.runtime_data, device, DESCRIPTOR) OasisDeviceUpdateEntity(entry.runtime_data, device, DESCRIPTOR)
for device in new_devices for device in new_devices
@@ -55,7 +72,7 @@ class OasisDeviceUpdateEntity(OasisDeviceEntity, UpdateEntity):
@property @property
def in_progress(self) -> bool | int: def in_progress(self) -> bool | int:
"""Update installation progress.""" """Update installation progress."""
if self.device.status_code == 11: if self.device.status_code == STATUS_UPDATING:
return self.device.download_progress return self.device.download_progress
return False return False
@@ -72,13 +89,28 @@ class OasisDeviceUpdateEntity(OasisDeviceEntity, UpdateEntity):
async def async_install( async def async_install(
self, version: str | None, backup: bool, **kwargs: Any self, version: str | None, backup: bool, **kwargs: Any
) -> None: ) -> None:
"""Install an update.""" """
Trigger installation of the latest available update on the device.
If the latest available version matches the device's currently installed software version, no action is taken. Otherwise an upgrade is started on the device.
Parameters:
version (str | None): Ignored by this implementation; the entity uses its known latest version.
backup (bool): Ignored by this implementation.
**kwargs: Additional keyword arguments are ignored.
"""
if self.latest_version == self.device.software_version: if self.latest_version == self.device.software_version:
return return
await self.device.async_upgrade() await self.device.async_upgrade()
async def async_update(self) -> None: async def async_update(self) -> None:
"""Update the entity.""" """
Refreshes this entity's latest software metadata.
Fetches the latest software details from the coordinator's cloud client and updates
the entity's `latest_version`, `release_summary`, and `release_url` attributes.
If no software details are returned, the entity's attributes are left unchanged.
"""
client = self.coordinator.cloud_client client = self.coordinator.cloud_client
if not (software := await client.async_get_latest_software_details()): if not (software := await client.async_get_latest_software_details()):
_LOGGER.warning("Unable to get latest software details") _LOGGER.warning("Unable to get latest software details")

View File

@@ -1,3 +1,6 @@
[tool.ruff.flake8-unused-arguments]
ignore-variadic-names = true
[tool.ruff.lint.isort] [tool.ruff.lint.isort]
force-sort-within-sections = true force-sort-within-sections = true
known-first-party = ["homeassistant", "tests"] known-first-party = ["homeassistant", "tests"]
@@ -7,7 +10,4 @@ split-on-trailing-comma = false
[tool.pylint."MESSAGES CONTROL"] [tool.pylint."MESSAGES CONTROL"]
# abstract-method - with intro of async there are always methods missing # abstract-method - with intro of async there are always methods missing
disable = [ disable = ["abstract-method", "unexpected-keyword-arg"]
"abstract-method",
"unexpected-keyword-arg",
]

View File

@@ -14,47 +14,67 @@ ACCESS_TOKEN = os.getenv("GROUNDED_TOKEN")
def get_author_name(data: dict) -> str: def get_author_name(data: dict) -> str:
"""Get author name from a dict.""" """
Extracts the author's display name from a nested track data dictionary.
Parameters:
data (dict): A mapping representing track/result data. Expected shape is
{"author": {"user": {"name": ..., "nickname": ...}}}.
Returns:
str: The author's `name` if present, otherwise the author's `nickname`, otherwise "Kinetic Oasis".
"""
author = (data.get("author") or {}).get("user") or {} author = (data.get("author") or {}).get("user") or {}
return author.get("name") or author.get("nickname") or "Kinetic Oasis" return author.get("name") or author.get("nickname") or "Kinetic Oasis"
async def update_tracks() -> None: async def update_tracks() -> None:
"""Update tracks.""" """
Fetch tracks from the Grounded Labs cloud, detect new or changed public tracks
compared to the local TRACKS mapping, augment changed entries with author and
reduced SVG content, and persist the merged, sorted track list to
`custom_components/oasis_mini/pyoasiscontrol/tracks.json`.
Side effects:
- May print error or status messages to stdout.
- Writes the updated tracks JSON file.
- Ensures the OasisCloudClient session is closed and returns early on errors or
unexpected data.
"""
client = OasisCloudClient(access_token=ACCESS_TOKEN) client = OasisCloudClient(access_token=ACCESS_TOKEN)
try: try:
data = await client.async_get_tracks() try:
except Exception as ex: data = await client.async_get_tracks()
print(type(ex).__name__, ex) except Exception as ex:
await client.session.close() print(type(ex).__name__, ex)
return return
if not isinstance(data, list): if not isinstance(data, list):
print("Unexpected result:", data) print("Unexpected result:", data)
return return
updated_tracks: dict[int, dict[str, Any]] = {} updated_tracks: dict[int, dict[str, Any]] = {}
for result in filter(lambda d: d["public"], data): for result in filter(lambda d: d["public"], data):
if ( if (
(track_id := result["id"]) not in TRACKS (track_id := result["id"]) not in TRACKS
or any( or any(
result[field] != TRACKS[track_id].get(field) result[field] != TRACKS[track_id].get(field)
for field in ("name", "image", "png_image") for field in ("name", "image", "png_image")
) )
or TRACKS[track_id].get("author") != get_author_name(result) or TRACKS[track_id].get("author") != get_author_name(result)
): ):
print(f"Updating track {track_id}: {result['name']}") print(f"Updating track {track_id}: {result['name']}")
track_info = await client.async_get_track_info(int(track_id)) track_info = await client.async_get_track_info(int(track_id))
if not track_info: if not track_info:
print("No track info") print("No track info")
break break
result["author"] = get_author_name(result) result["author"] = get_author_name(result)
result["reduced_svg_content_new"] = track_info.get( result["reduced_svg_content_new"] = track_info.get(
"reduced_svg_content_new" "reduced_svg_content_new"
) )
updated_tracks[track_id] = result updated_tracks[track_id] = result
await client.session.close() finally:
await client.async_close()
if not updated_tracks: if not updated_tracks:
print("No updated tracks") print("No updated tracks")