1
0
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:
Nathan Spencer
2025-11-24 01:09:23 -07:00
committed by GitHub
parent 171a608314
commit 379b6f67f2
40 changed files with 4262 additions and 1263 deletions

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

@@ -1,92 +1,80 @@
"""Config flow for Oasis Mini integration."""
"""Config flow for Oasis device integration."""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from typing import Any, Mapping
from aiohttp import ClientConnectorError
from httpx import ConnectError, HTTPStatusError
import voluptuous as vol
from homeassistant.components import dhcp
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_HOST, CONF_PASSWORD
from homeassistant.core import callback
from homeassistant.helpers.schema_config_entry_flow import (
SchemaCommonFlowHandler,
SchemaFlowError,
SchemaFlowFormStep,
SchemaOptionsFlowHandler,
)
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD
from . import OasisMiniConfigEntry
from .const import DOMAIN
from .coordinator import OasisMiniCoordinator
from .helpers import create_client
from .pyoasiscontrol import UnauthenticatedError
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
OPTIONS_SCHEMA = vol.Schema(
STEP_USER_DATA_SCHEMA = vol.Schema(
{vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str}
)
async def cloud_login(
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
) -> dict[str, Any]:
"""Cloud login."""
coordinator: OasisMiniCoordinator = handler.parent_handler.config_entry.runtime_data
try:
await coordinator.device.async_cloud_login(
email=user_input[CONF_EMAIL], password=user_input[CONF_PASSWORD]
)
user_input[CONF_ACCESS_TOKEN] = coordinator.device.access_token
except Exception as ex:
raise SchemaFlowError("invalid_auth") from ex
del user_input[CONF_PASSWORD]
return user_input
OPTIONS_FLOW = {
"init": SchemaFlowFormStep(OPTIONS_SCHEMA, validate_user_input=cloud_login)
}
class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Oasis Mini."""
class OasisDeviceConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Oasis devices."""
VERSION = 1
MINOR_VERSION = 2
MINOR_VERSION = 3
@staticmethod
@callback
def async_get_options_flow(
config_entry: OasisMiniConfigEntry,
) -> SchemaOptionsFlowHandler:
"""Get the options flow for this handler."""
return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW)
async def async_step_dhcp(
self, discovery_info: dhcp.DhcpServiceInfo
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle DHCP discovery."""
host = {CONF_HOST: discovery_info.ip}
await self.validate_client(host)
self._abort_if_unique_id_configured(updates=host)
# This should never happen since we only listen to DHCP requests
# for configured devices.
return self.async_abort(reason="already_configured")
"""
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()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""
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"])
assert entry
suggested_values = user_input or entry.data
return await self._async_step(
"reauth_confirm", STEP_USER_DATA_SCHEMA, user_input, suggested_values
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> 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(
"user", STEP_USER_DATA_SCHEMA, user_input, user_input
)
@@ -110,25 +98,40 @@ class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN):
user_input: dict[str, Any] | None = None,
suggested_values: dict[str, Any] | None = None,
) -> 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 = {}
if user_input is not None:
if not (errors := await self.validate_client(user_input)):
if step_id != "reconfigure":
self._abort_if_unique_id_configured(updates=user_input)
if existing_entry := self.hass.config_entries.async_get_entry(
self.context.get("entry_id")
):
self.hass.config_entries.async_update_entry(
existing_entry, data=user_input
entry_id = self.context.get("entry_id")
existing_entry = self.hass.config_entries.async_get_entry(entry_id)
if existing_entry and existing_entry.unique_id:
self._abort_if_unique_id_mismatch(reason="wrong_account")
if existing_entry:
return self.async_update_reload_and_abort(
existing_entry,
unique_id=self.unique_id,
title=user_input[CONF_EMAIL],
data=user_input,
reload_even_if_entry_is_unchanged=False,
)
await self.hass.config_entries.async_reload(existing_entry.entry_id)
return self.async_abort(reason="reconfigure_successful")
self._abort_if_unique_id_configured(updates=user_input)
return self.async_create_entry(
title=f"Oasis Mini {self.unique_id}",
data=user_input,
title=user_input[CONF_EMAIL], data=user_input
)
return self.async_show_form(
@@ -138,25 +141,47 @@ class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN):
)
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 = {}
client = create_client(self.hass, user_input)
try:
async with asyncio.timeout(10):
client = create_client(user_input)
await self.async_set_unique_id(await client.async_get_serial_number())
await client.async_login(
email=user_input[CONF_EMAIL], password=user_input[CONF_PASSWORD]
)
user_input[CONF_ACCESS_TOKEN] = client.access_token
user = await client.async_get_user()
await self.async_set_unique_id(str(user["id"]))
del user_input[CONF_PASSWORD]
if not self.unique_id:
errors["base"] = "invalid_host"
errors["base"] = "invalid_auth"
except UnauthenticatedError:
errors["base"] = "invalid_auth"
except asyncio.TimeoutError:
errors["base"] = "timeout_connect"
except ConnectError:
errors["base"] = "invalid_host"
errors["base"] = "invalid_auth"
except ClientConnectorError:
errors["base"] = "invalid_host"
errors["base"] = "invalid_auth"
except HTTPStatusError as err:
errors["base"] = str(err)
except Exception as ex: # pylint: disable=broad-except
_LOGGER.error(ex)
except Exception:
_LOGGER.exception("Error while attempting to validate client")
errors["base"] = "unknown"
finally:
await client.session.close()
await client.async_close()
return errors