3 Commits

Author SHA1 Message Date
RyanClark123
8d9299a8f4 Updated changelog for new release
Updated changelog for new release
2023-05-15 15:56:37 +01:00
RyanClark123
228d54b641 bumping pyowletapi version
### Fix
* Bump pyowletapi to 2023.5.21, fix for unawaited authentication call believe to be causing an issue reauthenticating when token had expired
* Github username change reflected, won't change again
2023-05-15 11:21:50 +01:00
RyanClark123
3e147aa914 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
2023-05-12 17:02:04 +01:00
16 changed files with 244 additions and 64 deletions

3
.gitignore vendored
View File

@@ -150,3 +150,6 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear # 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. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ #.idea/
/custom_components/owlet.zip
custom_components/owlet/owlet.zip

18
CHANGELOG.md Normal file
View File

@@ -0,0 +1,18 @@
# Changelog
<!--next-version-placeholder-->
## 2023-05-1 (2023-05-15)
#### Feature
* Changed versioning to date based
### Fix
* Bumping to pyowletapi 2023.5.21 to fix issue with unawaited authentication call, should resolve issue with refreshing authentication ([`228d54b`](https://github.com/ryanbdclark/owlet/commit/228d54b6414e0b9171064254246d1f36c3af8f5b))
## 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

50
README.md Normal file
View File

@@ -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/ryanbdclark/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/ryanbdclark/owlet?style=for-the-badge
[commits]: https://github.com/ryanbdclark/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/ryanbdclark/owlet.svg?style=for-the-badge
[maintenance-shield]: https://img.shields.io/badge/maintainer-Ryan%20Clark%20%40ryanbdclark-blue.svg?style=for-the-badge
[releases-shield]: https://img.shields.io/github/release/ryanbdclark/owlet.svg?style=for-the-badge
[releases]: https://github.com/ryanbdclark/owlet/releases
[user_profile]: https://github.com/ryanbdclark
[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

View File

@@ -0,0 +1 @@
"""Custom Integrations for Home Assistant."""

View File

@@ -5,20 +5,22 @@ import logging
from pyowletapi.api import OwletAPI from pyowletapi.api import OwletAPI
from pyowletapi.sock import Sock from pyowletapi.sock import Sock
from pyowletapi.exceptions import OwletAuthenticationError, OwletDevicesError from pyowletapi.exceptions import OwletConnectionError
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry, ConfigEntryAuthFailed
from homeassistant.const import Platform from homeassistant.const import (
Platform,
CONF_REGION,
CONF_USERNAME,
CONF_PASSWORD,
CONF_SCAN_INTERVAL,
CONF_API_TOKEN,
)
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import ( from .const import (
DOMAIN, DOMAIN,
CONF_OWLET_REGION,
CONF_OWLET_USERNAME,
CONF_OWLET_PASSWORD,
CONF_OWLET_POLLINTERVAL,
CONF_OWLET_EXPIRY, CONF_OWLET_EXPIRY,
CONF_OWLET_TOKEN,
SUPPORTED_VERSIONS, SUPPORTED_VERSIONS,
) )
from .coordinator import OwletCoordinator from .coordinator import OwletCoordinator
@@ -33,10 +35,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data.setdefault(DOMAIN, {}) hass.data.setdefault(DOMAIN, {})
owlet_api = OwletAPI( owlet_api = OwletAPI(
entry.data[CONF_OWLET_REGION], entry.data[CONF_REGION],
entry.data[CONF_OWLET_USERNAME], entry.data[CONF_USERNAME],
entry.data[CONF_OWLET_PASSWORD], entry.data[CONF_PASSWORD],
entry.data[CONF_OWLET_TOKEN], entry.data[CONF_API_TOKEN],
entry.data[CONF_OWLET_EXPIRY], entry.data[CONF_OWLET_EXPIRY],
async_get_clientsession(hass), async_get_clientsession(hass),
) )
@@ -45,20 +47,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
token = await owlet_api.authenticate() token = await owlet_api.authenticate()
if token: if token:
entry.data[CONF_OWLET_TOKEN] = token[CONF_OWLET_TOKEN] hass.config_entries.async_update_entry(entry, data={**entry.data, **token})
entry.data[CONF_OWLET_EXPIRY] = token[CONF_OWLET_EXPIRY]
socks = { socks = {
device["device"]["dsn"]: Sock(owlet_api, device["device"]) device["device"]["dsn"]: Sock(owlet_api, device["device"])
for device in await owlet_api.get_devices(SUPPORTED_VERSIONS) for device in await owlet_api.get_devices(SUPPORTED_VERSIONS)
} }
except OwletAuthenticationError as err: except OwletConnectionError as err:
_LOGGER.error("Login failed %s", err) _LOGGER.error("Credentials no longer valid, please setup owlet again")
return False raise ConfigEntryAuthFailed(
f"Credentials expired for {entry.data[CONF_USERNAME]}"
) from err
coordinators = [ coordinators = [
OwletCoordinator(hass, sock, entry.options.get(CONF_OWLET_POLLINTERVAL)) OwletCoordinator(hass, sock, entry.options.get(CONF_SCAN_INTERVAL))
for sock in socks.values() for sock in socks.values()
] ]

View File

@@ -112,11 +112,9 @@ class OwletBinarySensor(OwletBaseEntity, BinarySensorEntity):
sensor_description: OwletBinarySensorEntityDescription, sensor_description: OwletBinarySensorEntityDescription,
) -> None: ) -> None:
"""Initialize the binary sensor.""" """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) super().__init__(coordinator)
self.entity_description = sensor_description
self._attr_unique_id = f"{self.sock.serial}-{self.entity_description.name}"
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:

View File

@@ -7,26 +7,28 @@ from typing import Any
from pyowletapi.api import OwletAPI from pyowletapi.api import OwletAPI
from pyowletapi.sock import Sock from pyowletapi.sock import Sock
from pyowletapi.exceptions import ( from pyowletapi.exceptions import (
OwletConnectionError,
OwletAuthenticationError,
OwletDevicesError, OwletDevicesError,
OwletEmailError,
OwletPasswordError,
) )
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries, exceptions
from homeassistant.data_entry_flow import FlowResult from homeassistant.data_entry_flow import FlowResult
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.const import (
CONF_REGION,
CONF_USERNAME,
CONF_PASSWORD,
CONF_SCAN_INTERVAL,
CONF_API_TOKEN,
)
from .const import ( from .const import (
DOMAIN, DOMAIN,
CONF_OWLET_REGION,
CONF_OWLET_USERNAME,
CONF_OWLET_PASSWORD,
CONF_OWLET_POLLINTERVAL,
CONF_OWLET_TOKEN,
CONF_OWLET_EXPIRY, CONF_OWLET_EXPIRY,
POLLING_INTERVAL, POLLING_INTERVAL,
SUPPORTED_VERSIONS, SUPPORTED_VERSIONS,
@@ -54,6 +56,7 @@ class OwletConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._username: str self._username: str
self._password: str self._password: str
self._devices: dict[str, Sock] self._devices: dict[str, Sock]
self.reauth_entry: ConfigEntry | None = None
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
@@ -61,9 +64,9 @@ class OwletConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle the initial step.""" """Handle the initial step."""
errors: dict[str, str] = {} errors: dict[str, str] = {}
if user_input is not None: if user_input is not None:
self._region = user_input[CONF_OWLET_REGION] self._region = user_input[CONF_REGION]
self._username = user_input[CONF_OWLET_USERNAME] self._username = user_input[CONF_USERNAME]
self._password = user_input[CONF_OWLET_PASSWORD] self._password = user_input[CONF_PASSWORD]
owlet_api = OwletAPI( owlet_api = OwletAPI(
self._region, self._region,
@@ -82,21 +85,21 @@ class OwletConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return self.async_create_entry( return self.async_create_entry(
title=self._username, title=self._username,
data={ data={
CONF_OWLET_REGION: self._region, CONF_REGION: self._region,
CONF_OWLET_USERNAME: self._username, CONF_USERNAME: self._username,
CONF_OWLET_PASSWORD: self._password, CONF_PASSWORD: self._password,
CONF_OWLET_TOKEN: token[CONF_OWLET_TOKEN], CONF_API_TOKEN: token[CONF_API_TOKEN],
CONF_OWLET_EXPIRY: token[CONF_OWLET_EXPIRY], CONF_OWLET_EXPIRY: token[CONF_OWLET_EXPIRY],
}, },
options={CONF_OWLET_POLLINTERVAL: POLLING_INTERVAL}, options={CONF_SCAN_INTERVAL: POLLING_INTERVAL},
) )
except OwletDevicesError: except OwletDevicesError:
errors["base"] = "no_devices" errors["base"] = "no_devices"
except OwletConnectionError: except OwletEmailError:
errors["base"] = "cannot_connect" errors["base"] = "invalid_email"
except OwletAuthenticationError: except OwletPasswordError:
errors["base"] = "invalid_auth" errors["base"] = "invalid_password"
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception") _LOGGER.exception("Unexpected exception")
errors["base"] = "unknown" errors["base"] = "unknown"
@@ -111,6 +114,52 @@ class OwletConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Get the options flow for this handler.""" """Get the options flow for this handler."""
return OptionsFlowHandler(config_entry) 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): class OptionsFlowHandler(config_entries.OptionsFlow):
"""Handle a options flow for owlet""" """Handle a options flow for owlet"""
@@ -127,10 +176,14 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
schema = vol.Schema( schema = vol.Schema(
{ {
vol.Required( vol.Required(
CONF_OWLET_POLLINTERVAL, CONF_SCAN_INTERVAL,
default=self.config_entry.options.get(CONF_OWLET_POLLINTERVAL), default=self.config_entry.options.get(CONF_SCAN_INTERVAL),
): vol.All(vol.Coerce(int), vol.Range(min=10)), ): vol.All(vol.Coerce(int), vol.Range(min=10)),
} }
) )
return self.async_show_form(step_id="init", data_schema=schema) return self.async_show_form(step_id="init", data_schema=schema)
class InvalidAuth(exceptions.HomeAssistantError):
"""Error to indiciate there is invalud auth"""

View File

@@ -2,12 +2,6 @@
DOMAIN = "owlet" 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" CONF_OWLET_EXPIRY = "expiry"
SUPPORTED_VERSIONS = [3] SUPPORTED_VERSIONS = [3]

View File

@@ -5,7 +5,7 @@ from datetime import timedelta
import logging import logging
from pyowletapi.sock import Sock from pyowletapi.sock import Sock
from pyowletapi.exceptions import OwletError from pyowletapi.exceptions import OwletError, OwletConnectionError
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity import DeviceInfo
@@ -49,5 +49,5 @@ class OwletCoordinator(DataUpdateCoordinator):
"""Fetch the data from the device.""" """Fetch the data from the device."""
try: try:
await self.sock.update_properties() await self.sock.update_properties()
except OwletError as err: except (OwletError, OwletConnectionError) as err:
raise UpdateFailed(err) from err raise UpdateFailed(err) from err

View File

@@ -2,7 +2,7 @@
"domain": "owlet", "domain": "owlet",
"name": "Owlet Smart Sock", "name": "Owlet Smart Sock",
"codeowners": [ "codeowners": [
"@RyanClark123" "@ryanbdclark"
], ],
"config_flow": true, "config_flow": true,
"dependencies": [], "dependencies": [],
@@ -10,7 +10,7 @@
"homekit": {}, "homekit": {},
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"requirements": [ "requirements": [
"pyowletapi==2023.5.18" "pyowletapi==2023.5.21"
], ],
"version":"1.4.0" "version":"2023.5.1"
} }

View File

@@ -120,11 +120,9 @@ class OwletSensor(OwletBaseEntity, SensorEntity):
sensor_description: OwletSensorEntityDescription, sensor_description: OwletSensorEntityDescription,
) -> None: ) -> None:
"""Initialize the sensor.""" """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) super().__init__(coordinator)
self.entity_description = sensor_description
self._attr_unique_id = f"{self.sock.serial}-{self.entity_description.name}"
@property @property
def native_value(self): def native_value(self):
@@ -137,7 +135,7 @@ class OwletSensor(OwletBaseEntity, SensorEntity):
"battery_minutes", "battery_minutes",
"oxygen_saturation", "oxygen_saturation",
"skin_temperature", "skin_temperature",
"oxygen_10_av" "oxygen_10_av",
] ]
and self.sock.properties["charging"] and self.sock.properties["charging"]
): ):

View File

@@ -8,11 +8,18 @@
"username": "Email", "username": "Email",
"password": "Password" "password": "Password"
} }
},
"reauth_confirm":{
"title": "Reauthentiaction required for Owlet",
"data":{
"password": "Password"
}
} }
}, },
"error": { "error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "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%]" "unknown": "[%key:common::config_flow::error::unknown%]"
}, },
"abort": { "abort": {

View File

@@ -8,11 +8,18 @@
"username": "Email", "username": "Email",
"password": "Password" "password": "Password"
} }
},
"reauth_confirm":{
"title": "Reauthentiaction required for Owlet",
"data":{
"password": "Password"
}
} }
}, },
"error": { "error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "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%]" "unknown": "[%key:common::config_flow::error::unknown%]"
}, },
"abort": { "abort": {

View File

@@ -8,11 +8,18 @@
"username": "Email", "username": "Email",
"password": "Password" "password": "Password"
} }
},
"reauth_confirm":{
"title": "Reauthentiaction required for Owlet",
"data":{
"password": "Password"
}
} }
}, },
"error": { "error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "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%]" "unknown": "[%key:common::config_flow::error::unknown%]"
}, },
"abort": { "abort": {

View File

@@ -1,5 +1,7 @@
{ {
"name": "Owlet", "name": "Owlet",
"hacs": "1.31.1", "hacs": "1.32.1",
"homeassistant": "2023.04.1", "homeassistant": "2023.04.1",
"zip_release": true,
"filename": "owlet.zip"
} }

39
info.md Normal file
View File

@@ -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/ryanbdclark/owlet?style=for-the-badge
[commits]: https://github.com/ryanbdclark/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/ryanbdclark/owlet.svg?style=for-the-badge
[maintenance-shield]: https://img.shields.io/badge/maintainer-Ryan%20Clark%20%40ryanbdclark-blue.svg?style=for-the-badge
[releases-shield]: https://img.shields.io/github/release/ryanbdclark/owlet.svg?style=for-the-badge
[releases]: https://github.com/ryanbdclark/owlet/releases
[user_profile]: https://github.com/ryanbdclark
[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