1
0
mirror of https://github.com/natekspencer/hacs-oasis_mini.git synced 2025-11-14 08:03:52 -05:00

8 Commits
0.2.0 ... 0.3.0

Author SHA1 Message Date
Nathan Spencer
36da0249b7 Merge pull request #6 from natekspencer/dev
Code cleanup
2024-07-11 12:37:40 -06:00
Nathan Spencer
bcc8547e3e Code cleanup 2024-07-11 11:55:02 -06:00
Nathan Spencer
e678b20990 Merge pull request #5 from natekspencer/dev
Add ability to reconfigure integration to handle updated host ip
2024-07-11 11:54:12 -06:00
Nathan Spencer
cda435070d Add ability to reconfigure integration to handle updated host ip 2024-07-11 11:51:23 -06:00
Nathan Spencer
9b85d939c4 Merge pull request #4 from natekspencer/dev
Set HACS minimum HA version to 2024.4.0
2024-07-11 11:43:30 -06:00
Nathan Spencer
4eb86c5541 Set minimum HA version to 2024.4.0 2024-07-11 11:41:37 -06:00
Nathan Spencer
e35ae0d4fa Merge pull request #3 from natekspencer/dev
Add button to play a random track
2024-07-11 11:03:39 -06:00
Nathan Spencer
21105e497a Add button to play a random track 2024-07-11 11:00:01 -06:00
11 changed files with 162 additions and 75 deletions

View File

@@ -3,7 +3,7 @@
"name": "Home Assistant integration development", "name": "Home Assistant integration development",
"image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye", "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye",
"postCreateCommand": "sudo apt-get update && sudo apt-get install libturbojpeg0", "postCreateCommand": "sudo apt-get update && sudo apt-get install libturbojpeg0",
"postAttachCommand": ".devcontainer/setup", "postAttachCommand": "scripts/setup",
"forwardPorts": [8123], "forwardPorts": [8123],
"customizations": { "customizations": {
"vscode": { "vscode": {

View File

@@ -2,7 +2,9 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, Coroutine from dataclasses import dataclass
import random
from typing import Awaitable, Callable
from homeassistant.components.button import ( from homeassistant.components.button import (
ButtonDeviceClass, ButtonDeviceClass,
@@ -11,31 +13,68 @@ from homeassistant.components.button import (
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN from .const import DOMAIN
from .coordinator import OasisMiniCoordinator from .coordinator import OasisMiniCoordinator
from .entity import OasisMiniEntity from .entity import OasisMiniEntity
from .pyoasismini import OasisMini
from .pyoasismini.const import TRACKS from .pyoasismini.const import TRACKS
class OasisMiniButtonEntity(OasisMiniEntity, ButtonEntity):
"""Oasis Mini button entity."""
async def async_press(self) -> None:
"""Press the button."""
await self.device.async_reboot()
DESCRIPTOR = ButtonEntityDescription(
key="reboot", device_class=ButtonDeviceClass.RESTART
)
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None: ) -> None:
"""Set up Oasis Mini button using config entry.""" """Set up Oasis Mini button using config entry."""
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id] coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities([OasisMiniButtonEntity(coordinator, entry, DESCRIPTOR)]) async_add_entities(
[
OasisMiniButtonEntity(coordinator, entry, descriptor)
for descriptor in DESCRIPTORS
]
)
async def play_random_track(device: OasisMini) -> None:
"""Play random track."""
track = int(random.choice(list(TRACKS)))
if track not in device.playlist:
await device.async_add_track_to_playlist(track)
# Move track to next item in the playlist and then select it
if (idx := device.playlist.index(track)) != (next_idx := device.playlist_index + 1):
await device.async_move_track(idx, next_idx)
await device.async_change_track(next_idx)
await device.async_play()
@dataclass(frozen=True, kw_only=True)
class OasisMiniButtonEntityDescription(ButtonEntityDescription):
"""Oasis Mini button entity description."""
press_fn: Callable[[OasisMini], Awaitable[None]]
DESCRIPTORS = (
OasisMiniButtonEntityDescription(
key="reboot",
device_class=ButtonDeviceClass.RESTART,
press_fn=lambda device: device.async_reboot(),
),
OasisMiniButtonEntityDescription(
key="random_track",
name="Play random track",
press_fn=play_random_track,
),
)
class OasisMiniButtonEntity(OasisMiniEntity, ButtonEntity):
"""Oasis Mini button entity."""
entity_description: OasisMiniButtonEntityDescription
async def async_press(self) -> None:
"""Press the button."""
await self.entity_description.press_fn(self.device)
await self.coordinator.async_request_refresh()

View File

@@ -10,10 +10,9 @@ from aiohttp import ClientConnectorError
from httpx import ConnectError, HTTPStatusError from httpx import ConnectError, HTTPStatusError
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_HOST, CONF_PASSWORD from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_HOST, CONF_PASSWORD
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.schema_config_entry_flow import ( from homeassistant.helpers.schema_config_entry_flow import (
SchemaCommonFlowHandler, SchemaCommonFlowHandler,
SchemaFlowError, SchemaFlowError,
@@ -30,16 +29,14 @@ _LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
OPTIONS_SCHEMA = vol.Schema( OPTIONS_SCHEMA = vol.Schema(
{ {vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str}
vol.Optional(CONF_EMAIL): str,
vol.Optional(CONF_PASSWORD): str,
}
) )
async def cloud_login( async def cloud_login(
handler: SchemaCommonFlowHandler, user_input: dict[str, Any] handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Cloud login."""
coordinator: OasisMiniCoordinator = handler.parent_handler.hass.data[DOMAIN][ coordinator: OasisMiniCoordinator = handler.parent_handler.hass.data[DOMAIN][
handler.parent_handler.config_entry.entry_id handler.parent_handler.config_entry.entry_id
] ]
@@ -49,8 +46,8 @@ async def cloud_login(
email=user_input[CONF_EMAIL], password=user_input[CONF_PASSWORD] email=user_input[CONF_EMAIL], password=user_input[CONF_PASSWORD]
) )
user_input[CONF_ACCESS_TOKEN] = coordinator.device.access_token user_input[CONF_ACCESS_TOKEN] = coordinator.device.access_token
except: except Exception as ex:
raise SchemaFlowError("invalid_auth") raise SchemaFlowError("invalid_auth") from ex
del user_input[CONF_PASSWORD] del user_input[CONF_PASSWORD]
return user_input return user_input
@@ -75,7 +72,7 @@ class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN):
"""Get the options flow for this handler.""" """Get the options flow for this handler."""
return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW)
# async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: # async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> ConfigFlowResult:
# """Handle dhcp discovery.""" # """Handle dhcp discovery."""
# self.host = discovery_info.ip # self.host = discovery_info.ip
# self.name = discovery_info.hostname # self.name = discovery_info.hostname
@@ -85,13 +82,29 @@ class OasisMiniConfigFlow(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
) -> FlowResult: ) -> ConfigFlowResult:
"""Handle the initial step.""" """Handle the initial step."""
return await self._async_step("user", STEP_USER_DATA_SCHEMA, user_input) return await self._async_step("user", STEP_USER_DATA_SCHEMA, user_input)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration."""
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(
"reconfigure", STEP_USER_DATA_SCHEMA, user_input, suggested_values
)
async def _async_step( async def _async_step(
self, step_id: str, schema: vol.Schema, user_input: dict[str, Any] | None = None self,
) -> FlowResult: step_id: str,
schema: vol.Schema,
user_input: dict[str, Any] | None = None,
suggested_values: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Handle step setup.""" """Handle step setup."""
if abort := self._abort_if_configured(user_input): if abort := self._abort_if_configured(user_input):
return abort return abort
@@ -108,19 +121,24 @@ class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN):
existing_entry, data=data existing_entry, data=data
) )
await self.hass.config_entries.async_reload(existing_entry.entry_id) await self.hass.config_entries.async_reload(existing_entry.entry_id)
return self.async_abort(reason="reauth_successful") return self.async_abort(reason="reconfigure_successful")
return self.async_create_entry( return self.async_create_entry(
title=f"Oasis Mini {self.serial_number}", title=f"Oasis Mini {self.serial_number}",
data=data, data=data,
) )
return self.async_show_form(step_id=step_id, data_schema=schema, errors=errors) return self.async_show_form(
step_id=step_id,
data_schema=self.add_suggested_values_to_schema(schema, suggested_values),
errors=errors,
)
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 client setup."""
errors = {} errors = {}
try: try:
async with asyncio.timeout(10):
client = create_client({"host": self.host} | user_input) client = create_client({"host": self.host} | user_input)
self.serial_number = await client.async_get_serial_number() self.serial_number = await client.async_get_serial_number()
if not self.serial_number: if not self.serial_number:
@@ -143,7 +161,7 @@ class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN):
@callback @callback
def _abort_if_configured( def _abort_if_configured(
self, user_input: dict[str, Any] | None self, user_input: dict[str, Any] | None
) -> FlowResult | None: ) -> ConfigFlowResult | None:
"""Abort if configured.""" """Abort if configured."""
if self.host or user_input: if self.host or user_input:
data = {CONF_HOST: self.host, **(user_input or {})} data = {CONF_HOST: self.host, **(user_input or {})}

View File

@@ -127,6 +127,11 @@ class OasisMini:
"""Return the url.""" """Return the url."""
return f"http://{self._host}/" return f"http://{self._host}/"
async def async_add_track_to_playlist(self, track: int) -> None:
"""Add track to playlist."""
await self._async_command(params={"ADDJOBLIST": track})
self.playlist.append(track)
async def async_change_track(self, index: int) -> None: async def async_change_track(self, index: int) -> None:
"""Change the track.""" """Change the track."""
if index >= len(self.playlist): if index >= len(self.playlist):
@@ -156,6 +161,10 @@ class OasisMini:
setattr(self, attr, value) setattr(self, attr, value)
return status return status
async def async_move_track(self, _from: int, _to: int) -> None:
"""Move a track in the playlist."""
await self._async_command(params={"MOVEJOB": f"{_from};{_to}"})
async def async_pause(self) -> None: async def async_pause(self) -> None:
"""Send pause command.""" """Send pause command."""
await self._async_command(params={"CMDPAUSE": ""}) await self._async_command(params={"CMDPAUSE": ""})
@@ -228,7 +237,7 @@ class OasisMini:
async def _async_get(self, **kwargs: Any) -> str | None: async def _async_get(self, **kwargs: Any) -> str | None:
"""Perform a GET request.""" """Perform a GET request."""
response = await self._session.get(self.url, **kwargs) response = await self._session.get(self.url, **kwargs)
if response.status == 200: if response.status == 200 and response.content_type == "text/plain":
text = await response.text() text = await response.text()
return text return text
return None return None
@@ -264,6 +273,19 @@ class OasisMini:
) )
return response return response
async def async_cloud_get_tracks(self, tracks: list[int]) -> None:
"""Get cloud tracks."""
if not self.access_token:
return
response = await self._async_request(
"GET",
urljoin(CLOUD_BASE_URL, "api/track"),
headers={"Authorization": f"Bearer {self.access_token}"},
params={"ids[]": tracks},
)
return response
async def _async_request(self, method: str, url: str, **kwargs) -> Any: async def _async_request(self, method: str, url: str, **kwargs) -> Any:
"""Login via the cloud.""" """Login via the cloud."""
response = await self._session.request(method, url, **kwargs) response = await self._session.request(method, url, **kwargs)
@@ -283,3 +305,7 @@ class OasisMini:
self._current_track_details = await self.async_cloud_get_track_info( self._current_track_details = await self.async_cloud_get_track_info(
self.current_track_id self.current_track_id
) )
async def async_get_playlist_details(self) -> dict:
"""Get playlist info."""
return await self.async_cloud_get_tracks(self.playlist)

View File

@@ -8,4 +8,4 @@ from typing import Final
__TRACKS_FILE = os.path.join(os.path.dirname(__file__), "tracks.json") __TRACKS_FILE = os.path.join(os.path.dirname(__file__), "tracks.json")
with open(__TRACKS_FILE, "r", encoding="utf8") as file: with open(__TRACKS_FILE, "r", encoding="utf8") as file:
TRACKS: Final[dict[int, dict[str, str]]] = json.load(file) TRACKS: Final[dict[str, dict[str, str]]] = json.load(file)

View File

@@ -2,9 +2,6 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorEntity, SensorEntity,
SensorEntityDescription, SensorEntityDescription,
@@ -18,27 +15,19 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN from .const import DOMAIN
from .coordinator import OasisMiniCoordinator from .coordinator import OasisMiniCoordinator
from .entity import OasisMiniEntity from .entity import OasisMiniEntity
from .pyoasismini import OasisMini
@dataclass(frozen=True, kw_only=True) async def async_setup_entry(
class OasisMiniSensorEntityDescription(SensorEntityDescription): hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
"""Oasis Mini sensor entity description.""" ) -> None:
"""Set up Oasis Mini sensors using config entry."""
lookup_fn: Callable[[OasisMini], Any] | None = None coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
[
class OasisMiniSensorEntity(OasisMiniEntity, SensorEntity): OasisMiniSensorEntity(coordinator, entry, descriptor)
"""Oasis Mini sensor entity.""" for descriptor in DESCRIPTORS
]
entity_description: OasisMiniSensorEntityDescription | SensorEntityDescription )
@property
def native_value(self) -> str | None:
"""Return the value reported by the sensor."""
if lookup_fn := getattr(self.entity_description, "lookup_fn", None):
return lookup_fn(self.device)
return getattr(self.device, self.entity_description.key)
DESCRIPTORS = { DESCRIPTORS = {
@@ -49,9 +38,7 @@ DESCRIPTORS = {
name="Download progress", name="Download progress",
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
), ),
} } | {
OTHERS = {
SensorEntityDescription( SensorEntityDescription(
key=key, key=key,
name=key.replace("_", " ").capitalize(), name=key.replace("_", " ").capitalize(),
@@ -68,14 +55,10 @@ OTHERS = {
} }
async def async_setup_entry( class OasisMiniSensorEntity(OasisMiniEntity, SensorEntity):
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback """Oasis Mini sensor entity."""
) -> None:
"""Set up Oasis Mini sensors using config entry.""" @property
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id] def native_value(self) -> str | None:
async_add_entities( """Return the value reported by the sensor."""
[ return getattr(self.device, self.entity_description.key)
OasisMiniSensorEntity(coordinator, entry, descriptor)
for descriptor in DESCRIPTORS | OTHERS
]
)

View File

@@ -6,6 +6,11 @@
"host": "[%key:common::config_flow::data::host%]" "host": "[%key:common::config_flow::data::host%]"
} }
}, },
"reconfigure": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
}
},
"reauth_confirm": { "reauth_confirm": {
"data": {} "data": {}
} }
@@ -17,7 +22,9 @@
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
}, },
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]" "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
} }
}, },
"options": { "options": {

View File

@@ -6,6 +6,11 @@
"host": "Host" "host": "Host"
} }
}, },
"reconfigure": {
"data": {
"host": "Host"
}
},
"reauth_confirm": { "reauth_confirm": {
"data": {} "data": {}
} }
@@ -17,7 +22,9 @@
"unknown": "Unexpected error" "unknown": "Unexpected error"
}, },
"abort": { "abort": {
"already_configured": "Device is already configured" "already_configured": "Device is already configured",
"reauth_successful": "Re-authentication was successful",
"reconfigure_successful": "Re-configuration was successful"
} }
}, },
"options": { "options": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "Oasis Mini", "name": "Oasis Mini",
"homeassistant": "2024.7.0", "homeassistant": "2024.4.0",
"render_readme": true, "render_readme": true,
"zip_release": true, "zip_release": true,
"filename": "oasis_mini.zip" "filename": "oasis_mini.zip"

View File

@@ -4,3 +4,10 @@ known-first-party = ["homeassistant", "tests"]
forced-separate = ["tests"] forced-separate = ["tests"]
combine-as-imports = true combine-as-imports = true
split-on-trailing-comma = false split-on-trailing-comma = false
[tool.pylint."MESSAGES CONTROL"]
# abstract-method - with intro of async there are always methods missing
disable = [
"abstract-method",
"unexpected-keyword-arg",
]