From 3e147aa914e98819598952e9e3e87645bbf0f3db Mon Sep 17 00:00:00 2001 From: RyanClark123 <42173121+RyanClark123@users.noreply.github.com> Date: Fri, 12 May 2023 16:46:07 +0100 Subject: [PATCH] Added reauth flow, much better error handling on setup ### Feature * Now supports reauthentication if credentials have expired/no longer work * Better error handling when configuring integration, will notify user of incorrect credentials ### Fix * Removed Owlet specific constants, now using homeassistant generic constants * On initialisation the integration would crash when trying to update the auth token, the integration would then have to be deleted and setup again --- .gitignore | 2 + CHANGELOG.md | 12 +++ README.md | 50 ++++++++++ custom_components/__init__.py | 1 + custom_components/owlet/__init__.py | 39 ++++---- custom_components/owlet/binary_sensor.py | 6 +- custom_components/owlet/config_flow.py | 97 +++++++++++++++----- custom_components/owlet/const.py | 6 -- custom_components/owlet/manifest.json | 4 +- custom_components/owlet/sensor.py | 8 +- custom_components/owlet/strings.json | 9 +- custom_components/owlet/translations/en.json | 9 +- custom_components/owlet/translations/uk.json | 9 +- hacs.json | 4 +- info.md | 39 ++++++++ 15 files changed, 234 insertions(+), 61 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100644 custom_components/__init__.py create mode 100644 info.md diff --git a/.gitignore b/.gitignore index d9005f2..3d48897 100644 --- a/.gitignore +++ b/.gitignore @@ -150,3 +150,5 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +/custom_components/owlet.zip \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..dafea4e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + + + +## v1.5.0 (2023-05-12) +### Feature +* Now supports reauthentication if credentials have expired/no longer work +* Better error handling when configuring integration, will notify user of incorrect credentials + +### Fix +* Removed Owlet specific constants, now using homeassistant generic constants +* On initialisation the integration would crash when trying to update the auth token, the integration would then have to be deleted and setup again \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..1341ae7 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# Owlet Custom Integration + +[![GitHub Release][releases-shield]][releases] +[![GitHub Activity][commits-shield]][commits] + +[![License][license-shield]][license] + +[![hacs][hacsbadge]][hacs] +[![Project Maintenance][maintenance-shield]][user_profile] + +A custom component for the Owlet smart sock, currently this only supports the owlet smart sock 3. + +If you have a smart sock 2 and would like to contribute then please do so. + +## Installation + +1. Use [HACS](https://hacs.xyz/docs/setup/download), in `HACS > Integrations > Explore & Add Repositories` search for "Owlet". After adding this `https://github.com/RyanClark123/owlet` as a custom repository. +2. Restart Home Assistant. +3. [![Add Integration][add-integration-badge]][add-integration] or in the HA UI go to "Settings" -> "Devices & Services" then click "+" and search for "Owlet Smart Sock". + + + + +## Usage + +The `Owlet` integration offers integration with the Owlet Smart Sock cloud service. This provides sensors such as heart rate, oxygen saturation, charge percentage. + +This integration provides the following entities: + +- Binary sensors - charging status, high heart rate alert, low heart rate alert, high oxygen alert, low oxygen alert, low battery alert, lost power alert, sock diconnected alert, and sock status. +- Sensors - battery level, oxygen saturation, oxygen saturation 10 minute average, heart rate, battery time remaining, signal strength, and skin temperature. + +## Options + +- Seconds between polling - Number of seconds between each call for data from the owlet cloud service, default is 10 seconds. + +--- + +[commits-shield]: https://img.shields.io/github/commit-activity/w/RyanClark123/owlet?style=for-the-badge +[commits]: https://github.com/RyanClark123/owlet/commits/main +[hacs]: https://github.com/hacs/integration +[hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge +[license]: LICENSE +[license-shield]: https://img.shields.io/github/license/RyanClark123/owlet.svg?style=for-the-badge +[maintenance-shield]: https://img.shields.io/badge/maintainer-Ryan%20Clark%20%40RyanClark123-blue.svg?style=for-the-badge +[releases-shield]: https://img.shields.io/github/release/RyanClark123/owlet.svg?style=for-the-badge +[releases]: https://github.com/RyanClark123/owlet/releases +[user_profile]: https://github.com/RyanClark123 +[add-integration]: https://my.home-assistant.io/redirect/config_flow_start?domain=owlet +[add-integration-badge]: https://my.home-assistant.io/badges/config_flow_start.svg \ No newline at end of file diff --git a/custom_components/__init__.py b/custom_components/__init__.py new file mode 100644 index 0000000..c43fac7 --- /dev/null +++ b/custom_components/__init__.py @@ -0,0 +1 @@ +"""Custom Integrations for Home Assistant.""" \ No newline at end of file diff --git a/custom_components/owlet/__init__.py b/custom_components/owlet/__init__.py index 03a809f..c482996 100644 --- a/custom_components/owlet/__init__.py +++ b/custom_components/owlet/__init__.py @@ -5,20 +5,22 @@ import logging from pyowletapi.api import OwletAPI from pyowletapi.sock import Sock -from pyowletapi.exceptions import OwletAuthenticationError, OwletDevicesError +from pyowletapi.exceptions import OwletConnectionError -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.config_entries import ConfigEntry, ConfigEntryAuthFailed +from homeassistant.const import ( + Platform, + CONF_REGION, + CONF_USERNAME, + CONF_PASSWORD, + CONF_SCAN_INTERVAL, + CONF_API_TOKEN, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( DOMAIN, - CONF_OWLET_REGION, - CONF_OWLET_USERNAME, - CONF_OWLET_PASSWORD, - CONF_OWLET_POLLINTERVAL, CONF_OWLET_EXPIRY, - CONF_OWLET_TOKEN, SUPPORTED_VERSIONS, ) from .coordinator import OwletCoordinator @@ -33,10 +35,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) owlet_api = OwletAPI( - entry.data[CONF_OWLET_REGION], - entry.data[CONF_OWLET_USERNAME], - entry.data[CONF_OWLET_PASSWORD], - entry.data[CONF_OWLET_TOKEN], + entry.data[CONF_REGION], + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + entry.data[CONF_API_TOKEN], entry.data[CONF_OWLET_EXPIRY], async_get_clientsession(hass), ) @@ -45,20 +47,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: token = await owlet_api.authenticate() if token: - entry.data[CONF_OWLET_TOKEN] = token[CONF_OWLET_TOKEN] - entry.data[CONF_OWLET_EXPIRY] = token[CONF_OWLET_EXPIRY] + hass.config_entries.async_update_entry(entry, data={**entry.data, **token}) socks = { device["device"]["dsn"]: Sock(owlet_api, device["device"]) for device in await owlet_api.get_devices(SUPPORTED_VERSIONS) } - except OwletAuthenticationError as err: - _LOGGER.error("Login failed %s", err) - return False + except OwletConnectionError as err: + _LOGGER.error("Credentials no longer valid, please setup owlet again") + raise ConfigEntryAuthFailed( + f"Credentials expired for {entry.data[CONF_USERNAME]}" + ) from err coordinators = [ - OwletCoordinator(hass, sock, entry.options.get(CONF_OWLET_POLLINTERVAL)) + OwletCoordinator(hass, sock, entry.options.get(CONF_SCAN_INTERVAL)) for sock in socks.values() ] diff --git a/custom_components/owlet/binary_sensor.py b/custom_components/owlet/binary_sensor.py index 0964182..a356524 100644 --- a/custom_components/owlet/binary_sensor.py +++ b/custom_components/owlet/binary_sensor.py @@ -112,11 +112,9 @@ class OwletBinarySensor(OwletBaseEntity, BinarySensorEntity): sensor_description: OwletBinarySensorEntityDescription, ) -> None: """Initialize the binary sensor.""" - self.entity_description = sensor_description - self._attr_unique_id = ( - f"{coordinator.config_entry.entry_id}-{self.entity_description.name}" - ) super().__init__(coordinator) + self.entity_description = sensor_description + self._attr_unique_id = f"{self.sock.serial}-{self.entity_description.name}" @property def is_on(self) -> bool: diff --git a/custom_components/owlet/config_flow.py b/custom_components/owlet/config_flow.py index f3708a6..82b8314 100644 --- a/custom_components/owlet/config_flow.py +++ b/custom_components/owlet/config_flow.py @@ -7,26 +7,28 @@ from typing import Any from pyowletapi.api import OwletAPI from pyowletapi.sock import Sock from pyowletapi.exceptions import ( - OwletConnectionError, - OwletAuthenticationError, OwletDevicesError, + OwletEmailError, + OwletPasswordError, ) import voluptuous as vol -from homeassistant import config_entries +from homeassistant import config_entries, exceptions from homeassistant.data_entry_flow import FlowResult from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.core import callback +from homeassistant.const import ( + CONF_REGION, + CONF_USERNAME, + CONF_PASSWORD, + CONF_SCAN_INTERVAL, + CONF_API_TOKEN, +) from .const import ( DOMAIN, - CONF_OWLET_REGION, - CONF_OWLET_USERNAME, - CONF_OWLET_PASSWORD, - CONF_OWLET_POLLINTERVAL, - CONF_OWLET_TOKEN, CONF_OWLET_EXPIRY, POLLING_INTERVAL, SUPPORTED_VERSIONS, @@ -54,6 +56,7 @@ class OwletConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._username: str self._password: str self._devices: dict[str, Sock] + self.reauth_entry: ConfigEntry | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -61,9 +64,9 @@ class OwletConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle the initial step.""" errors: dict[str, str] = {} if user_input is not None: - self._region = user_input[CONF_OWLET_REGION] - self._username = user_input[CONF_OWLET_USERNAME] - self._password = user_input[CONF_OWLET_PASSWORD] + self._region = user_input[CONF_REGION] + self._username = user_input[CONF_USERNAME] + self._password = user_input[CONF_PASSWORD] owlet_api = OwletAPI( self._region, @@ -82,21 +85,21 @@ class OwletConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry( title=self._username, data={ - CONF_OWLET_REGION: self._region, - CONF_OWLET_USERNAME: self._username, - CONF_OWLET_PASSWORD: self._password, - CONF_OWLET_TOKEN: token[CONF_OWLET_TOKEN], + CONF_REGION: self._region, + CONF_USERNAME: self._username, + CONF_PASSWORD: self._password, + CONF_API_TOKEN: token[CONF_API_TOKEN], CONF_OWLET_EXPIRY: token[CONF_OWLET_EXPIRY], }, - options={CONF_OWLET_POLLINTERVAL: POLLING_INTERVAL}, + options={CONF_SCAN_INTERVAL: POLLING_INTERVAL}, ) except OwletDevicesError: errors["base"] = "no_devices" - except OwletConnectionError: - errors["base"] = "cannot_connect" - except OwletAuthenticationError: - errors["base"] = "invalid_auth" + except OwletEmailError: + errors["base"] = "invalid_email" + except OwletPasswordError: + errors["base"] = "invalid_password" except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" @@ -111,6 +114,52 @@ class OwletConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) + async def async_step_reauth(self, user_input=None): + """Handle reauth""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm(self, user_input=None): + """Dialog that informs the user that reauth is required""" + assert self.reauth_entry is not None + errors: dict[str, str] = {} + + if user_input is not None: + entry_data = self.reauth_entry.data + owlet_api = OwletAPI( + entry_data[CONF_REGION], + entry_data[CONF_USERNAME], + user_input[CONF_PASSWORD], + session=async_get_clientsession(self.hass), + ) + try: + token = await owlet_api.authenticate() + if token: + user_input[CONF_API_TOKEN] = token[CONF_API_TOKEN] + user_input[CONF_OWLET_EXPIRY] = token[CONF_OWLET_EXPIRY] + self.hass.config_entries.async_update_entry( + self.reauth_entry, data={**entry_data, **user_input} + ) + + await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) + + return self.async_abort(reason="reauth_successful") + + except OwletEmailError: + errors["base"] = "invalid_email" + except OwletPasswordError: + errors["base"] = "invalid_password" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("error reauthing") + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}), + errors=errors, + ) + class OptionsFlowHandler(config_entries.OptionsFlow): """Handle a options flow for owlet""" @@ -127,10 +176,14 @@ class OptionsFlowHandler(config_entries.OptionsFlow): schema = vol.Schema( { vol.Required( - CONF_OWLET_POLLINTERVAL, - default=self.config_entry.options.get(CONF_OWLET_POLLINTERVAL), + CONF_SCAN_INTERVAL, + default=self.config_entry.options.get(CONF_SCAN_INTERVAL), ): vol.All(vol.Coerce(int), vol.Range(min=10)), } ) return self.async_show_form(step_id="init", data_schema=schema) + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indiciate there is invalud auth""" diff --git a/custom_components/owlet/const.py b/custom_components/owlet/const.py index 6fe9003..04f0f48 100644 --- a/custom_components/owlet/const.py +++ b/custom_components/owlet/const.py @@ -2,12 +2,6 @@ DOMAIN = "owlet" -CONF_OWLET_REGION = "region" -CONF_OWLET_USERNAME = "username" -CONF_OWLET_PASSWORD = "password" -CONF_OWLET_DEVICES = "devices" -CONF_OWLET_POLLINTERVAL = "pollinterval" -CONF_OWLET_TOKEN = "token" CONF_OWLET_EXPIRY = "expiry" SUPPORTED_VERSIONS = [3] diff --git a/custom_components/owlet/manifest.json b/custom_components/owlet/manifest.json index 9542414..03d2586 100644 --- a/custom_components/owlet/manifest.json +++ b/custom_components/owlet/manifest.json @@ -10,7 +10,7 @@ "homekit": {}, "iot_class": "cloud_polling", "requirements": [ - "pyowletapi==2023.5.18" + "pyowletapi==2023.5.20" ], - "version":"1.4.0" + "version":"1.5.0" } diff --git a/custom_components/owlet/sensor.py b/custom_components/owlet/sensor.py index 79f6cb6..db0f7c5 100644 --- a/custom_components/owlet/sensor.py +++ b/custom_components/owlet/sensor.py @@ -120,11 +120,9 @@ class OwletSensor(OwletBaseEntity, SensorEntity): sensor_description: OwletSensorEntityDescription, ) -> None: """Initialize the sensor.""" - self.entity_description = sensor_description - self._attr_unique_id = ( - f"{coordinator.config_entry.entry_id}-{self.entity_description.name}" - ) super().__init__(coordinator) + self.entity_description = sensor_description + self._attr_unique_id = f"{self.sock.serial}-{self.entity_description.name}" @property def native_value(self): @@ -137,7 +135,7 @@ class OwletSensor(OwletBaseEntity, SensorEntity): "battery_minutes", "oxygen_saturation", "skin_temperature", - "oxygen_10_av" + "oxygen_10_av", ] and self.sock.properties["charging"] ): diff --git a/custom_components/owlet/strings.json b/custom_components/owlet/strings.json index 0e2d550..f932c92 100644 --- a/custom_components/owlet/strings.json +++ b/custom_components/owlet/strings.json @@ -8,11 +8,18 @@ "username": "Email", "password": "Password" } + }, + "reauth_confirm":{ + "title": "Reauthentiaction required for Owlet", + "data":{ + "password": "Password" + } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_email": "Entered email address is incorrect", + "invalid_password": "Entered password is incorrect", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { diff --git a/custom_components/owlet/translations/en.json b/custom_components/owlet/translations/en.json index 0e2d550..f932c92 100644 --- a/custom_components/owlet/translations/en.json +++ b/custom_components/owlet/translations/en.json @@ -8,11 +8,18 @@ "username": "Email", "password": "Password" } + }, + "reauth_confirm":{ + "title": "Reauthentiaction required for Owlet", + "data":{ + "password": "Password" + } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_email": "Entered email address is incorrect", + "invalid_password": "Entered password is incorrect", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { diff --git a/custom_components/owlet/translations/uk.json b/custom_components/owlet/translations/uk.json index 0e2d550..f932c92 100644 --- a/custom_components/owlet/translations/uk.json +++ b/custom_components/owlet/translations/uk.json @@ -8,11 +8,18 @@ "username": "Email", "password": "Password" } + }, + "reauth_confirm":{ + "title": "Reauthentiaction required for Owlet", + "data":{ + "password": "Password" + } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_email": "Entered email address is incorrect", + "invalid_password": "Entered password is incorrect", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { diff --git a/hacs.json b/hacs.json index ec3744f..b4431ca 100644 --- a/hacs.json +++ b/hacs.json @@ -1,5 +1,7 @@ { "name": "Owlet", - "hacs": "1.31.1", + "hacs": "1.32.1", "homeassistant": "2023.04.1", + "zip_release": true, + "filename": "owlet.zip" } diff --git a/info.md b/info.md new file mode 100644 index 0000000..ae53bcf --- /dev/null +++ b/info.md @@ -0,0 +1,39 @@ +# Owlet Custom Integration + +[![GitHub Release][releases-shield]][releases] +[![GitHub Activity][commits-shield]][commits] + +[![License][license-shield]][license] + +[![hacs][hacsbadge]][hacs] +[![Project Maintenance][maintenance-shield]][user_profile] + +A custom component for the Owlet smart sock, currently this only supports the owlet smart sock 3. + +If you have a smart sock 2 and would like to contribute then please do so. + +## Installation + +1. Click install. +2. Reboot Home Assistant. +3. Hard refresh browser cache. +4. [![Add Integration][add-integration-badge]][add-integration] or in the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Owlet Smart Sock". + +{% endif %} + + + +--- + +[commits-shield]: https://img.shields.io/github/commit-activity/w/RyanClark123/owlet?style=for-the-badge +[commits]: https://github.com/RyanClark123/owlet/commits/main +[hacs]: https://github.com/hacs/integration +[hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge +[license]: LICENSE +[license-shield]: https://img.shields.io/github/license/RyanClark123/owlet.svg?style=for-the-badge +[maintenance-shield]: https://img.shields.io/badge/maintainer-Ryan%20Clark%20%40RyanClark123-blue.svg?style=for-the-badge +[releases-shield]: https://img.shields.io/github/release/RyanClark123/owlet.svg?style=for-the-badge +[releases]: https://github.com/RyanClark123/owlet/releases +[user_profile]: https://github.com/RyanClark123 +[add-integration]: https://my.home-assistant.io/redirect/config_flow_start?domain=owlet +[add-integration-badge]: https://my.home-assistant.io/badges/config_flow_start.svg \ No newline at end of file