mirror of
https://github.com/natekspencer/hacs-oasis_mini.git
synced 2025-12-06 18:44:14 -05:00
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>
This commit is contained in:
@@ -1,62 +1,184 @@
|
||||
"""Oasis Mini coordinator."""
|
||||
"""Oasis devices coordinator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
|
||||
import async_timeout
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
from .pyoasismini import OasisMini
|
||||
from .pyoasiscontrol import OasisCloudClient, OasisDevice, OasisMqttClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import OasisDeviceConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OasisMiniCoordinator(DataUpdateCoordinator[str]):
|
||||
"""Oasis Mini data update coordinator."""
|
||||
class OasisDeviceCoordinator(DataUpdateCoordinator[list[OasisDevice]]):
|
||||
"""Oasis device data update coordinator."""
|
||||
|
||||
attempt: int = 0
|
||||
last_updated: datetime | None = None
|
||||
|
||||
def __init__(self, hass: HomeAssistant, device: OasisMini) -> None:
|
||||
"""Initialize."""
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: OasisDeviceConfigEntry,
|
||||
cloud_client: OasisCloudClient,
|
||||
mqtt_client: OasisMqttClient,
|
||||
) -> None:
|
||||
"""
|
||||
Create an OasisDeviceCoordinator that manages OasisDevice discovery and updates using cloud and MQTT clients.
|
||||
|
||||
Parameters:
|
||||
config_entry (OasisDeviceConfigEntry): The config entry whose runtime data contains device serial numbers.
|
||||
cloud_client (OasisCloudClient): Client for communicating with the Oasis cloud API and fetching device data.
|
||||
mqtt_client (OasisMqttClient): Client for registering devices and coordinating MQTT-based readiness/status.
|
||||
"""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(seconds=10),
|
||||
update_interval=timedelta(minutes=10),
|
||||
always_update=False,
|
||||
)
|
||||
self.device = device
|
||||
self.cloud_client = cloud_client
|
||||
self.mqtt_client = mqtt_client
|
||||
|
||||
async def _async_update_data(self):
|
||||
"""Update the data."""
|
||||
data: str | None = None
|
||||
async def _async_update_data(self) -> list[OasisDevice]:
|
||||
"""
|
||||
Fetch and assemble the current list of OasisDevice objects, reconcile removed devices in Home Assistant, register discovered devices with MQTT, and verify per-device readiness.
|
||||
|
||||
Returns:
|
||||
A list of OasisDevice instances representing devices currently available for the account.
|
||||
|
||||
Raises:
|
||||
UpdateFailed: If no devices can be read after repeated attempts or an unexpected error persists past retry limits.
|
||||
"""
|
||||
devices: list[OasisDevice] = []
|
||||
self.attempt += 1
|
||||
|
||||
try:
|
||||
async with async_timeout.timeout(10):
|
||||
if not self.device.mac_address:
|
||||
await self.device.async_get_mac_address()
|
||||
if not self.device.serial_number:
|
||||
await self.device.async_get_serial_number()
|
||||
if not self.device.software_version:
|
||||
await self.device.async_get_software_version()
|
||||
data = await self.device.async_get_status()
|
||||
self.attempt = 0
|
||||
await self.device.async_get_current_track_details()
|
||||
await self.device.async_get_playlist_details()
|
||||
await self.device.async_cloud_get_playlists()
|
||||
except Exception as ex: # pylint:disable=broad-except
|
||||
if self.attempt > 2 or not (data or self.data):
|
||||
raise UpdateFailed(
|
||||
f"Couldn't read from the Oasis Mini after {self.attempt} attempts"
|
||||
) from ex
|
||||
async with asyncio.timeout(30):
|
||||
raw_devices = await self.cloud_client.async_get_devices()
|
||||
|
||||
if data != self.data:
|
||||
self.last_updated = datetime.now()
|
||||
return data
|
||||
existing_by_serial = {
|
||||
d.serial_number: d for d in (self.data or []) if d.serial_number
|
||||
}
|
||||
|
||||
for raw in raw_devices:
|
||||
if not (serial := raw.get("serial_number")):
|
||||
continue
|
||||
|
||||
if device := existing_by_serial.get(serial):
|
||||
if name := raw.get("name"):
|
||||
device.name = name
|
||||
else:
|
||||
device = OasisDevice(
|
||||
model=(raw.get("model") or {}).get("name"),
|
||||
serial_number=serial,
|
||||
name=raw.get("name"),
|
||||
cloud=self.cloud_client,
|
||||
)
|
||||
|
||||
devices.append(device)
|
||||
|
||||
new_serials = {d.serial_number for d in devices if d.serial_number}
|
||||
removed_serials = set(existing_by_serial) - new_serials
|
||||
|
||||
if removed_serials:
|
||||
device_registry = dr.async_get(self.hass)
|
||||
for serial in removed_serials:
|
||||
_LOGGER.info(
|
||||
"Oasis device %s removed from account; cleaning up in HA",
|
||||
serial,
|
||||
)
|
||||
device_entry = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, serial)}
|
||||
)
|
||||
if device_entry:
|
||||
device_registry.async_update_device(
|
||||
device_id=device_entry.id,
|
||||
remove_config_entry_id=self.config_entry.entry_id,
|
||||
)
|
||||
|
||||
# ✅ Valid state: logged in but no devices on account
|
||||
if not devices:
|
||||
_LOGGER.debug("No Oasis devices found for account")
|
||||
self.attempt = 0
|
||||
if devices != self.data:
|
||||
self.last_updated = dt_util.now()
|
||||
return []
|
||||
|
||||
self.mqtt_client.register_devices(devices)
|
||||
|
||||
# Best-effort playlists
|
||||
try:
|
||||
await self.cloud_client.async_get_playlists()
|
||||
except Exception:
|
||||
_LOGGER.exception("Error fetching playlists from cloud")
|
||||
|
||||
any_success = False
|
||||
|
||||
for device in devices:
|
||||
try:
|
||||
ready = await self.mqtt_client.wait_until_ready(
|
||||
device, request_status=True
|
||||
)
|
||||
if not ready:
|
||||
_LOGGER.warning(
|
||||
"Timeout waiting for Oasis device %s to be ready",
|
||||
device.serial_number,
|
||||
)
|
||||
continue
|
||||
|
||||
mac = await device.async_get_mac_address()
|
||||
if not mac:
|
||||
_LOGGER.warning(
|
||||
"Could not get MAC address for Oasis device %s",
|
||||
device.serial_number,
|
||||
)
|
||||
continue
|
||||
|
||||
any_success = True
|
||||
device.schedule_track_refresh()
|
||||
|
||||
except Exception:
|
||||
_LOGGER.exception(
|
||||
"Error preparing Oasis device %s", device.serial_number
|
||||
)
|
||||
|
||||
if any_success:
|
||||
self.attempt = 0
|
||||
else:
|
||||
if self.attempt > 2 or not self.data:
|
||||
raise UpdateFailed(
|
||||
"Couldn't read from any Oasis device "
|
||||
f"after {self.attempt} attempts"
|
||||
)
|
||||
|
||||
except UpdateFailed:
|
||||
raise
|
||||
except Exception as ex:
|
||||
if self.attempt > 2 or not (devices or self.data):
|
||||
raise UpdateFailed(
|
||||
"Unexpected error talking to Oasis devices "
|
||||
f"after {self.attempt} attempts"
|
||||
) from ex
|
||||
_LOGGER.warning(
|
||||
"Error updating Oasis devices; reusing previous data", exc_info=ex
|
||||
)
|
||||
return self.data or devices
|
||||
|
||||
if devices != self.data:
|
||||
self.last_updated = dt_util.now()
|
||||
|
||||
return devices
|
||||
|
||||
Reference in New Issue
Block a user