diff --git a/custom_components/oasis_mini/__init__.py b/custom_components/oasis_mini/__init__.py index ce04119..ae00307 100755 --- a/custom_components/oasis_mini/__init__.py +++ b/custom_components/oasis_mini/__init__.py @@ -7,7 +7,8 @@ import logging from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +import homeassistant.helpers.device_registry as dr from .const import DOMAIN from .coordinator import OasisMiniCoordinator @@ -39,10 +40,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except Exception as ex: _LOGGER.exception(ex) + if not entry.unique_id: + if not (serial_number := coordinator.device.serial_number): + dev_reg = dr.async_get(hass) + devices = dr.async_entries_for_config_entry(dev_reg, entry.entry_id) + serial_number = next( + ( + identifier[1] + for identifier in devices[0].identifiers + if identifier[0] == DOMAIN + ), + None, + ) + hass.config_entries.async_update_entry(entry, unique_id=serial_number) + if not coordinator.data: await client.session.close() raise ConfigEntryNotReady + if entry.unique_id != coordinator.device.serial_number: + await client.session.close() + raise ConfigEntryError("Serial number mismatch") + hass.data[DOMAIN][entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/custom_components/oasis_mini/config_flow.py b/custom_components/oasis_mini/config_flow.py index 35a5cb7..80a421f 100755 --- a/custom_components/oasis_mini/config_flow.py +++ b/custom_components/oasis_mini/config_flow.py @@ -10,6 +10,7 @@ from aiohttp import ClientConnectorError from httpx import ConnectError, HTTPStatusError import voluptuous as vol +from homeassistant.components import dhcp from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_HOST, CONF_PASSWORD from homeassistant.core import callback @@ -63,28 +64,30 @@ class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - host: str | None = None - serial_number: str | None = None - @staticmethod @callback def async_get_options_flow(config_entry: ConfigEntry) -> SchemaOptionsFlowHandler: """Get the options flow for this handler.""" return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) - # async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> ConfigFlowResult: - # """Handle dhcp discovery.""" - # self.host = discovery_info.ip - # self.name = discovery_info.hostname - # await self.async_set_unique_id(discovery_info.macaddress) - # self._abort_if_unique_id_configured(updates={CONF_HOST: self.host}) - # return await self.async_step_api_key() + async def async_step_dhcp( + self, discovery_info: dhcp.DhcpServiceInfo + ) -> 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") async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """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, user_input + ) async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None @@ -106,26 +109,24 @@ class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN): suggested_values: dict[str, Any] | None = None, ) -> ConfigFlowResult: """Handle step setup.""" - if abort := self._abort_if_configured(user_input): - return abort - errors = {} if user_input is not None: if not (errors := await self.validate_client(user_input)): - data = {CONF_HOST: user_input.get(CONF_HOST, self.host)} + 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=data + existing_entry, data=user_input ) await self.hass.config_entries.async_reload(existing_entry.entry_id) return self.async_abort(reason="reconfigure_successful") return self.async_create_entry( - title=f"Oasis Mini {self.serial_number}", - data=data, + title=f"Oasis Mini {self.unique_id}", + data=user_input, ) return self.async_show_form( @@ -139,9 +140,9 @@ class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN): errors = {} try: async with asyncio.timeout(10): - client = create_client({"host": self.host} | user_input) - self.serial_number = await client.async_get_serial_number() - if not self.serial_number: + client = create_client(user_input) + await self.async_set_unique_id(await client.async_get_serial_number()) + if not self.unique_id: errors["base"] = "invalid_host" except asyncio.TimeoutError: errors["base"] = "timeout_connect" @@ -157,15 +158,3 @@ class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN): finally: await client.session.close() return errors - - @callback - def _abort_if_configured( - self, user_input: dict[str, Any] | None - ) -> ConfigFlowResult | None: - """Abort if configured.""" - if self.host or user_input: - data = {CONF_HOST: self.host, **(user_input or {})} - for entry in self._async_current_entries(): - if entry.data[CONF_HOST] == data[CONF_HOST]: - return self.async_abort(reason="already_configured") - return None diff --git a/custom_components/oasis_mini/coordinator.py b/custom_components/oasis_mini/coordinator.py index 9db1292..dfb2420 100644 --- a/custom_components/oasis_mini/coordinator.py +++ b/custom_components/oasis_mini/coordinator.py @@ -8,7 +8,6 @@ import logging import async_timeout from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -20,6 +19,7 @@ _LOGGER = logging.getLogger(__name__) class OasisMiniCoordinator(DataUpdateCoordinator[str]): """Oasis Mini data update coordinator.""" + attempt: int = 0 last_updated: datetime | None = None def __init__(self, hass: HomeAssistant, device: OasisMini) -> None: @@ -30,6 +30,10 @@ class OasisMiniCoordinator(DataUpdateCoordinator[str]): self.device = device async def _async_update_data(self): + """Update the data.""" + data: str | None = None + self.attempt += 1 + try: async with async_timeout.timeout(10): if not self.device.mac_address: @@ -40,10 +44,14 @@ class OasisMiniCoordinator(DataUpdateCoordinator[str]): await self.device.async_get_software_version() data = await self.device.async_get_status() await self.device.async_get_current_track_details() - except Exception as ex: - raise UpdateFailed("Couldn't read from the Oasis Mini") from ex - if data is None: - raise ConfigEntryAuthFailed + except Exception as ex: # pylint:disable=broad-except + if self.attempt > 2 or not self.data: + raise UpdateFailed( + f"Couldn't read from the Oasis Mini after {self.attempt} attempts" + ) from ex + else: + self.attempt = 0 + if data != self.data: self.last_updated = datetime.now() return data diff --git a/custom_components/oasis_mini/manifest.json b/custom_components/oasis_mini/manifest.json index 1fe096f..0bdd6a6 100755 --- a/custom_components/oasis_mini/manifest.json +++ b/custom_components/oasis_mini/manifest.json @@ -3,6 +3,7 @@ "name": "Oasis Mini", "codeowners": ["@natekspencer"], "config_flow": true, + "dhcp": [{ "registered_devices": true }], "documentation": "https://github.com/natekspencer/hacs-oasis_mini", "integration_type": "device", "iot_class": "local_polling", diff --git a/custom_components/oasis_mini/media_player.py b/custom_components/oasis_mini/media_player.py index 9fc1a2b..5630917 100644 --- a/custom_components/oasis_mini/media_player.py +++ b/custom_components/oasis_mini/media_player.py @@ -43,11 +43,11 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity): return MediaType.IMAGE @property - def media_duration(self) -> int: + def media_duration(self) -> int | None: """Duration of current playing media in seconds.""" if (track := self.device.track) and "reduced_svg_content" in track: return track["reduced_svg_content"].get("1") - return math.ceil(self.media_position / 0.99) + return None @property def media_image_url(self) -> str | None: @@ -84,11 +84,11 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity): def state(self) -> MediaPlayerState: """State of the player.""" status_code = self.device.status_code - if self.device.error or status_code == 9: + if self.device.error or status_code in (9, 11): return MediaPlayerState.OFF if status_code == 2: return MediaPlayerState.IDLE - if status_code in (3, 11, 13): + if status_code in (3, 13): return MediaPlayerState.BUFFERING if status_code == 4: return MediaPlayerState.PLAYING diff --git a/custom_components/oasis_mini/number.py b/custom_components/oasis_mini/number.py index cec01e4..7609fda 100644 --- a/custom_components/oasis_mini/number.py +++ b/custom_components/oasis_mini/number.py @@ -2,7 +2,11 @@ from __future__ import annotations -from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.components.number import ( + NumberEntity, + NumberEntityDescription, + NumberMode, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -34,12 +38,14 @@ DESCRIPTORS = { NumberEntityDescription( key="ball_speed", name="Ball speed", + mode=NumberMode.SLIDER, native_max_value=BALL_SPEED_MAX, native_min_value=BALL_SPEED_MIN, ), NumberEntityDescription( key="led_speed", name="LED speed", + mode=NumberMode.SLIDER, native_max_value=LED_SPEED_MAX, native_min_value=LED_SPEED_MIN, ), diff --git a/custom_components/oasis_mini/pyoasismini/__init__.py b/custom_components/oasis_mini/pyoasismini/__init__.py index a4e963f..14ff211 100644 --- a/custom_components/oasis_mini/pyoasismini/__init__.py +++ b/custom_components/oasis_mini/pyoasismini/__init__.py @@ -35,7 +35,7 @@ ATTRIBUTES: Final[list[tuple[str, Callable[[str], Any]]]] = [ ("status_code", int), # see status code map ("error", int), # error, 0 = none, and 10 = ?, 18 = can't download? ("ball_speed", int), # 200 - 1000 - ("playlist", lambda value: [int(track) for track in value.split(",")]), # noqa: E501 # comma separated track ids + ("playlist", lambda value: [int(track) for track in value.split(",") if track]), # noqa: E501 # comma separated track ids ("playlist_index", int), # index of above ("progress", int), # 0 - max svg path ("led_effect", str), # led effect (code lookup) @@ -122,6 +122,16 @@ class OasisMini: """Return the mac address.""" return self._mac_address + @property + def drawing_progress(self) -> float | None: + """Return the drawing progress percent.""" + if not (self.track and (svg_content := self.track.get("svg_content"))): + return None + paths = svg_content.split("L") + total = self.track.get("reduced_svg_content", {}).get("1", len(paths)) + percent = (100 * self.progress) / total + return percent + @property def serial_number(self) -> str | None: """Return the serial number.""" @@ -150,8 +160,10 @@ class OasisMini: return None @property - def track_id(self) -> int: + def track_id(self) -> int | None: """Return the current track id.""" + if not self.playlist: + return None i = self.playlist_index return self.playlist[0] if i >= len(self.playlist) else self.playlist[i] @@ -199,7 +211,7 @@ class OasisMini: _LOGGER.debug("Software version: %s", self._software_version) return self._software_version - async def async_get_status(self) -> None: + async def async_get_status(self) -> str: """Get the status from the device.""" status = await self._async_get(params={"GETSTATUS": ""}) _LOGGER.debug("Status: %s", status) diff --git a/custom_components/oasis_mini/pyoasismini/tracks.json b/custom_components/oasis_mini/pyoasismini/tracks.json index c75af58..f8e8fac 100644 --- a/custom_components/oasis_mini/pyoasismini/tracks.json +++ b/custom_components/oasis_mini/pyoasismini/tracks.json @@ -710,9 +710,9 @@ "image": "2024/03/3829ea91a3af828e7046f473707b0627.svg" }, "455": { - "name": "Teste", + "name": "Princess", "author": "Otávio Bittencourt", - "image": "2024/06/ecd77e23fe859ba8e7e8c6a6ecfc9b8e.svg" + "image": "2024/07/ecd77e23fe859ba8e7e8c6a6ecfc9b8e.svg" }, "223": { "name": "The Knot", diff --git a/custom_components/oasis_mini/select.py b/custom_components/oasis_mini/select.py index c2c5e0e..4b14503 100644 --- a/custom_components/oasis_mini/select.py +++ b/custom_components/oasis_mini/select.py @@ -67,7 +67,7 @@ def playlist_update_handler(entity: OasisMiniSelectEntity) -> None: ] entity._attr_options = options index = min(entity.device.playlist_index, len(options) - 1) - entity._attr_current_option = options[index] + entity._attr_current_option = options[index] if options else None DESCRIPTORS = ( diff --git a/custom_components/oasis_mini/sensor.py b/custom_components/oasis_mini/sensor.py index c23ec96..83c172e 100644 --- a/custom_components/oasis_mini/sensor.py +++ b/custom_components/oasis_mini/sensor.py @@ -22,12 +22,18 @@ async def async_setup_entry( ) -> None: """Set up Oasis Mini sensors using config entry.""" coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - [ - OasisMiniSensorEntity(coordinator, entry, descriptor) - for descriptor in DESCRIPTORS - ] - ) + entities = [ + OasisMiniSensorEntity(coordinator, entry, descriptor) + for descriptor in DESCRIPTORS + ] + if coordinator.device.access_token: + entities.extend( + [ + OasisMiniSensorEntity(coordinator, entry, descriptor) + for descriptor in CLOUD_DESCRIPTORS + ] + ) + async_add_entities(entities) DESCRIPTORS = { @@ -55,6 +61,17 @@ DESCRIPTORS = { ) } +CLOUD_DESCRIPTORS = ( + SensorEntityDescription( + key="drawing_progress", + entity_category=EntityCategory.DIAGNOSTIC, + name="Drawing progress", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), +) + class OasisMiniSensorEntity(OasisMiniEntity, SensorEntity): """Oasis Mini sensor entity.""" diff --git a/custom_components/oasis_mini/strings.json b/custom_components/oasis_mini/strings.json index aed06fa..afdf5e3 100755 --- a/custom_components/oasis_mini/strings.json +++ b/custom_components/oasis_mini/strings.json @@ -10,9 +10,6 @@ "data": { "host": "[%key:common::config_flow::data::host%]" } - }, - "reauth_confirm": { - "data": {} } }, "error": { @@ -23,7 +20,6 @@ }, "abort": { "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%]" } }, diff --git a/custom_components/oasis_mini/translations/en.json b/custom_components/oasis_mini/translations/en.json index e9f321c..e876722 100755 --- a/custom_components/oasis_mini/translations/en.json +++ b/custom_components/oasis_mini/translations/en.json @@ -10,9 +10,6 @@ "data": { "host": "Host" } - }, - "reauth_confirm": { - "data": {} } }, "error": { @@ -23,7 +20,6 @@ }, "abort": { "already_configured": "Device is already configured", - "reauth_successful": "Re-authentication was successful", "reconfigure_successful": "Re-configuration was successful" } }, diff --git a/custom_components/oasis_mini/update.py b/custom_components/oasis_mini/update.py index 4302674..e72439b 100644 --- a/custom_components/oasis_mini/update.py +++ b/custom_components/oasis_mini/update.py @@ -66,10 +66,14 @@ class OasisMiniUpdateEntity(OasisMiniEntity, UpdateEntity): self, version: str | None, backup: bool, **kwargs: Any ) -> None: """Install an update.""" + version = await self.device.async_get_software_version() + if version == self.latest_version: + return await self.device.async_upgrade() async def async_update(self) -> None: """Update the entity.""" + await self.device.async_get_software_version() software = await self.device.async_cloud_get_latest_software_details() self._attr_latest_version = software["version"] self._attr_release_summary = software["description"] diff --git a/requirements.txt b/requirements.txt index 52c6a13..08fd6f5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,5 @@ # Home Assistant homeassistant>=2024.4 -home-assistant-frontend numpy PyTurboJPEG