73 Commits

Author SHA1 Message Date
eb600e0a97 Stub out initial camera code 2025-12-14 22:45:49 -08:00
RyanClark123
f8e0067a1e Revert to pyowletapi 2025.4.1 2025-04-15 19:56:21 +01:00
RyanClark123
45e65f384b Updating version in manifest and changelog 2025-04-15 19:14:31 +01:00
ryanbdclark
df6b45621e Merge pull request #25 from MarjovanLier/fix-multiple-devices
(Fixed) Ensure entities from multiple Owlet devices register correctly
2025-04-15 19:08:46 +01:00
Marjo Wenzel van Lier
1244bffcb4 fix(entity): Ensure entities from multiple devices register correctly
- Modify sensor and switch setup to use `extend` instead of list
  reassignment. This prevents overwriting entities from previously
  processed devices.
- Update `OwletBaseEntity` initialisation to correctly store the
  coordinator instance.
- Refine device information retrieval using `getattr` for enhanced
  robustness and provide more specific device details (e.g., serial
  number in name).

This change addresses a bug where, in setups with multiple Owlet devices,
only the entities belonging to the last device in the configuration
were registered. Using `extend` ensures all entities across all devices
are correctly added. Device information presentation is also improved.
2025-04-15 00:36:04 +02:00
RyanClark123
d323cbfd11 Bumping pyowletapi
### Fix
* Bumping pyowletapi to 2025.4.1, should hopefully stop issue where only one device was added to HA.
2025-04-14 14:53:04 +01:00
RyanClark123
2accec2b49 Merge branch 'main' of https://github.com/ryanbdclark/owlet 2025-04-11 13:51:11 +01:00
RyanClark123
6b343a76ca Fix for errors after refactoring of api
### Fix
* Changes to stop errors after refactoring pyowletapi
2025-04-11 13:50:59 +01:00
ryanbdclark
fa2e06dcf4 Merge pull request #23 from jusso-dev/patch-1
Update README.md
2025-04-11 13:37:12 +01:00
ryanbdclark
975e98c337 Update CHANGELOG.md 2025-04-11 13:35:12 +01:00
RyanClark123
268365ccd4 Bumping pyowletapi
### Fix
* Bumping pyowletapi to 2025.4.0
2025-04-11 13:34:11 +01:00
ryanbdclark
ac9c8c6111 Update manifest.json
Added documentation link
2025-04-05 21:42:48 +01:00
ryanbdclark
4fa40f8621 Update manifest.json
Remove documentation link
2025-04-05 21:38:38 +01:00
Justin Middler
c04d6b7bf8 Update README.md 2025-04-01 15:01:19 +11:00
RyanClark123
dd17aca283 Merge branch 'main' of https://github.com/ryanbdclark/owlet 2024-10-10 11:23:35 +01:00
RyanClark123
91578464de Refactoring
### Fix
* Refactoring
* Changed switch to follow a more homeassistant pattern and allow for easier addition of future switches
2024-10-10 11:23:30 +01:00
ryanbdclark
e28b9ddf3e Update CHANGELOG.md 2024-10-09 20:55:37 +01:00
RyanClark123
f63e0a6dfe Added base station as a switch
### Feature
* Base station has now been removed from binary sensors and added as a switch
2024-10-09 20:51:05 +01:00
RyanClark123
82823be1c8 Bump pyowletapi
### Fix
* Bump pyowletapi to 2024.10.1
2024-10-09 20:03:14 +01:00
RyanClark123
14787e03c4 Correct strings for password and connection error
### Fix
* Fix strings for password and connection error in all languages
2024-10-09 16:31:45 +01:00
RyanClark123
0991eb31d9 Add state class
### Fix
* Add state class to battery minutes and O2 saturation 10 minute average
2024-10-09 15:19:28 +01:00
RyanClark123
ad91a851fc Update changelog and version
Update changelog and version
2024-09-26 10:18:03 +01:00
ryanbdclark
339dc43d6d Merge pull request #18 from Julien80/patch-1
Add translation fr
2024-09-26 10:14:03 +01:00
Julien80
f3c853e2d7 Add translation fr 2024-09-26 10:26:00 +02:00
ryanbdclark
dfc2ffc0e1 Update hacs.json 2024-07-09 07:29:05 +01:00
ryanbdclark
dc28ebb02f Update CHANGELOG.md 2024-06-17 09:31:30 +01:00
RyanClark123
52710ba7de Bumping pyowletapi
* Fix
Bump pyowletapi to 2024.6.1
2024-06-17 09:28:22 +01:00
ryanbdclark
d8449c14a4 Update CHANGELOG.md 2024-05-13 13:49:23 +01:00
ryanbdclark
3610262855 Update CHANGELOG.md 2024-05-13 13:48:02 +01:00
ryanbdclark
904023e93a Update CHANGELOG.md 2024-05-13 13:47:23 +01:00
RyanClark123
ceade24851 Sensors now show unavailable, refactoring
### Feature
* As per HA core patterns, certain sensors will now show as unavailable when sock is charging
###
* Refactoring as per core maintainers suggestions
2024-05-13 13:41:51 +01:00
ryanbdclark
ab486d5519 Merge pull request #14 from coreywillwhat/patch-1
fix: O2 Sat 10m Avg reporting `255%`
2024-05-13 09:52:30 +01:00
coreywillwhat
5e17ecdeb2 fix: O2 Sat 10m Avg reporting 255%
Just a suggestion!
The O2 Sat 10m average reports `255%` when not charging, and before it can calculate the 10m average. Suggest changing to report `None` until the average can be displayed. This way the graphs/data aren't skewed by the 255 value.
2024-05-07 11:32:15 -06:00
ryanbdclark
4b90ce0d61 Update CHANGELOG.md 2024-03-27 15:53:56 +00:00
RyanClark123
50c55dcfd3 Add binary sensor, correct interval input
### Feature
*Base station on added as binary sensor

### Fix
* Bumping pyowletapi to 2024.3.2
* UI config now allows you to set interval to 5 seconds, previously the minimum was 10
2024-03-27 15:51:12 +00:00
ryanbdclark
faefd0b18b Update info.md 2023-11-23 15:42:11 +00:00
ryanbdclark
1192b833ca Update README.md 2023-11-23 15:41:37 +00:00
ryanbdclark
d440fed621 Update CHANGELOG.md 2023-11-23 15:41:03 +00:00
RyanClark123
50fe1a8765 Support for V2 sock added
### Feature
* Support added for V2 sock
* Added tests for binary sensors
### Fix
* Bumping pyowletapi to 2023.11.4 to allow V2 support
* Refactoring
* Corrected spelling of sock disconnected sensor
2023-11-23 15:38:35 +00:00
ryanbdclark
1cfff537d7 Update CHANGELOG.md 2023-11-16 11:45:00 +00:00
RyanClark123
3acf847352 Correcting error where properties may not exist
### Fix
* Bumping pyowletapi to 2023.11.1
* Sensors and binary sensors are now only created where the sock contains that property, this stops errors where different sock versions have different properties
2023-11-16 11:42:34 +00:00
ryanbdclark
c0bf404f6a Update CHANGELOG.md 2023-09-20 15:05:45 +01:00
RyanClark123
0a7f703100 Fix for new sock revision 5
### Fix
* Bumping pyowletapi to 2023.9.1 to allow for revisions
* New revision of sock, revision 5 doesn't report all vitals as before, this would cause the integration to fail to update. Have adjusted the integration to detect the revision and ignore the vitals that are no longer reported
2023-09-20 15:03:10 +01:00
RyanClark123
cab737cae4 Merge branch 'main' of https://github.com/ryanbdclark/owlet 2023-08-21 15:11:14 +01:00
RyanClark123
092321cbae Update manifest.json 2023-08-21 15:10:57 +01:00
ryanbdclark
835786b89b Update CHANGELOG.md 2023-08-21 15:10:38 +01:00
RyanClark123
2cd46c18f8 Refactoring
###Fix
* Refactoring done based on home assistant style guidelines and suggestions submitted on the core pull request version of this integration
* Added new sensors to strings json and disabled by default
2023-08-21 15:06:28 +01:00
ryanbdclark
575b213ddd Merge pull request #3 from seanford/main
Update sensor.py
2023-08-21 11:49:48 +01:00
Sean Ford
3afa43c82c Update sensor.py
Added Movement and Movement Bucket values as sensors
2023-08-10 10:09:47 -04:00
ryanbdclark
5c8411fab7 Update CHANGELOG.md 2023-07-04 15:42:31 +01:00
RyanClark123
c45959b123 Bumping pyowlet
###Fix
* Bumping pyowlet version to 2023.7.2
2023-07-04 15:38:44 +01:00
ryanbdclark
4e30d4652f Update cron.yml 2023-07-04 13:52:42 +01:00
ryanbdclark
02f8679ed1 Update manifest.json 2023-07-03 14:52:04 +01:00
ryanbdclark
534ad8a351 Update CHANGELOG.md 2023-07-03 14:51:14 +01:00
ryanbdclark
c693fefbf3 Update manifest.json 2023-07-03 14:38:17 +01:00
ryanbdclark
523ba949dd Bumping pyowlet api
Bumping pyowletapi version to 2023.07.01
2023-07-03 14:37:49 +01:00
RyanClark123
3c35d87fd2 Minor changes to description
Minor changes to description of entities made
2023-06-15 11:11:06 +01:00
ryanbdclark
6c2c531a19 Update manifest.json 2023-05-30 14:30:23 +01:00
ryanbdclark
f4e38ec521 Update manifest.json 2023-05-30 14:27:07 +01:00
ryanbdclark
ecb950da8a Create cron.yml 2023-05-30 14:23:23 +01:00
RyanClark123
a7d4276671 Merge branch 'main' of https://github.com/ryanbdclark/owlet 2023-05-30 13:57:30 +01:00
RyanClark123
ef0a3c3ddb Merge branch 'main' of https://github.com/ryanbdclark/owlet 2023-05-30 13:57:14 +01:00
ryanbdclark
7844b5bd87 Update CHANGELOG.md 2023-05-30 13:49:15 +01:00
RyanClark123
3d31de0205 Merge branch 'main' of https://github.com/ryanbdclark/owlet 2023-05-30 13:47:20 +01:00
RyanClark123
8d173174e2 Fixed binary sensors
### Fix
* Fixed issue with binary sensors not loading, caused by change to way the coordinators are stored
2023-05-30 13:46:48 +01:00
ryanbdclark
1977df6be0 Update CHANGELOG.md 2023-05-30 13:33:25 +01:00
ryanbdclark
ea4c543ec5 Update CHANGELOG.md 2023-05-30 13:32:50 +01:00
RyanClark123
26f81c14bf Refactoring, added tests
###Fix
# In light of submitting this as a pull request to the core of HA there have been some refactoring changes to comply with HA's style requirements
#Sensor names now moved to strings file to allow for translations
#Coordinator now properly handles multiple devices
#Spelling of signal strength sensor corrected

###Feature
#Tests added
2023-05-30 13:30:59 +01:00
RyanClark123
c6f37493ca Corrected spelling fixed typing reordered imports
###Fix
# In preparation for pull request to homeassistant core, just corrected some spelling, sorted imports using isort and corrected some type hinting
2023-05-26 16:29:40 +01:00
RyanClark123
5328933de3 Update CHANGELOG.md 2023-05-19 15:16:38 +01:00
RyanClark123
0141f7d01a Refresh token should now work properly
#### Fix
* Owlet refresh token becomes invalid after 24 hours. Meant that after 1 day the integration would stop working. Moved to pyowletapi v2023.5.28 which uses different refresh token, should no longer need reconfiguring after 24 hours
2023-05-19 15:14:19 +01:00
RyanClark123
dc58b19a46 Fix non conversion of dict_values to list
###Fix
* dict_values for sleep state now correctly converted to list
2023-05-17 19:32:27 +01:00
RyanClark123
bd6a315b00 Bumping to pyowletapi 2023.5.25
#### Fix
* Bumping to pyowletapi 2023.5.25
2023-05-17 19:23:37 +01:00
33 changed files with 6738 additions and 347 deletions

25
.github/workflows/cron.yml vendored Normal file
View File

@@ -0,0 +1,25 @@
name: Validate with hassfest
on:
push:
pull_request:
schedule:
- cron: '0 0 * * *'
jobs:
hassfest:
name: "Hassfest validation"
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v3"
- uses: "home-assistant/actions/hassfest@master"
hacs:
name: "HACS Action"
runs-on: "ubuntu-latest"
steps:
- name: "HACS Action"
uses: "hacs/action@main"
with:
category: "integration"

View File

@@ -1,17 +1,123 @@
# Changelog
<!--next-version-placeholder-->
## 2023-05-3 (2023-05-17)
## 2025.4.3 (2025-04-15)
### Fix
* Changes to how the sensors are stored to solve the issue where only one device is added, thanks [`@MarjovanLier`](https://github.com/MarjovanLier). ([`1244bff`](https://github.com/ryanbdclark/owlet/commit/1244bffcb48d7337a9d7a0da518959fe4b31a230))
## 2025.4.2 (2025-04-14)
### Fix
* Bumping pyowletapi to 2025.4.1, should hopefully stop issue where only one device was added to HA. ([`d323cbf`](https://github.com/ryanbdclark/owlet/commit/d323cbfd11411ff34866ead492de10c109c72689))
## 2025.4.1 (2025-04-11)
### Fix
* Changes to stop errors after refactoring pyowletapi ([`6b343a7`](https://github.com/ryanbdclark/owlet/commit/6b343a76caad3375e10c80f4d26942a1bbbb831d))
## 2025.4.0 (2025-04-11)
### Fix
* Bumping pyowletapi to 2025.4.0 ([`268365c`](https://github.com/ryanbdclark/owlet/commit/268365ccd428418dd5707f0569ce738b54a12fdd))
## 2024.10.1 (2024-10-09)
### Feature
* Base station has now been removed from binary sensors and added as a switch ([`f63e0a6`](https://github.com/ryanbdclark/owlet/commit/f63e0a6dfeab1a05ba09ef3e0087cb404ba0dac4))
### Fix
* Bump pyowletapi to 2024.10.1 ([`82823be`](https://github.com/ryanbdclark/owlet/commit/82823be1c8265d2b9431771136853febef648650))
* Fix strings for password and connection error in all languages ([`14787e0`](https://github.com/ryanbdclark/owlet/commit/14787e03c4d275f46f446921a3ee133fc7cfd1b1))
* Add state class to battery minutes and O2 saturation 10 minute average ([`0991eb3`](https://github.com/ryanbdclark/owlet/commit/0991eb31d919f3ee9f65ece793166d7ee3e33c38))
## 2024.9.1 (2024-09-26)
### Feature
* Now includes French translation, thanks [`@Julien80`](https://github.com/Julien80) ([`f3c853e`](https://github.com/ryanbdclark/owlet/commit/f3c853e2d7243d766889f2d18c718819da30e4be))
## 2024.6.1 (2024-06-17)
### Fix
* Bumping pyowletapi to 2024.6.1 to resolve setup errors ([`52710ba`](https://github.com/ryanbdclark/owlet/commit/52710ba7de53fd07195537c2a5fd2f95bc7dfd1a))
## 2024.5.1 (2024-05-13)
### Feature
* As per HA core patterns, certain sensors will now show as unavailable when sock is charging ([`ceade24`](https://github.com/ryanbdclark/owlet/commit/ceade24851479b8c9bc60b7b8bed74a7bdb927e9))
* Oxygen 10 minute average now only shows a figure if it is between 0 and 100 this avoids skewing by 255 values before the 10 minutes is reached, thanks @coreywillwhat ([`5e17ecd`](https://github.com/ryanbdclark/owlet/commit/5e17ecdeb2aca5bbb35f19ca5795a2c5e0f776ab))
### Fix
* Refactoring as per core maintainers suggestions ([`ceade24`](https://github.com/ryanbdclark/owlet/commit/ceade24851479b8c9bc60b7b8bed74a7bdb927e9))
## 2024.3.1
### Feature
* Base station on added as binary sensor ([`50c55dc`](https://github.com/ryanbdclark/owlet/commit/50c55dcfd30d15027155a8f1d05340238501522d))
### Fix
* Bumping pyowletapi to 2024.3.2 ([`50c55dc`](https://github.com/ryanbdclark/owlet/commit/50c55dcfd30d15027155a8f1d05340238501522d))
* UI config now allows you to set interval to 5 seconds, previously the minimum was 10 ([`50c55dc`](https://github.com/ryanbdclark/owlet/commit/50c55dcfd30d15027155a8f1d05340238501522d))
## 2023.11.2 (2023-11-23)
### Feature
* Support added for V2 sock ([`50fe1a8`](https://github.com/ryanbdclark/owlet/commit/50fe1a87656b7d6413d06f06f3650fd0bfb48e02))
* Added tests for binary sensors ([`50fe1a8`](https://github.com/ryanbdclark/owlet/commit/50fe1a87656b7d6413d06f06f3650fd0bfb48e02))
### Fix
* Bumping pyowletapi to 2023.11.4 to allow V2 support ([`50fe1a8`](https://github.com/ryanbdclark/owlet/commit/50fe1a87656b7d6413d06f06f3650fd0bfb48e02))
* Refactoring ([`50fe1a8`](https://github.com/ryanbdclark/owlet/commit/50fe1a87656b7d6413d06f06f3650fd0bfb48e02))
* Corrected spelling of sock disconnected sensor ([`50fe1a8`](https://github.com/ryanbdclark/owlet/commit/50fe1a87656b7d6413d06f06f3650fd0bfb48e02))
## 2023.11.1 (2023-11-16)
### Fix
* Bumping pyowletapi to 2023.11.1 ([`3acf847`](https://github.com/ryanbdclark/owlet/commit/3acf8473526665382b44ef6325d708a6c62fff45))
* Sensors and binary sensors are now only created where the sock contains that property, this stops errors where different sock versions have different properties ([`3acf847`](https://github.com/ryanbdclark/owlet/commit/3acf8473526665382b44ef6325d708a6c62fff45))
## 2023.9.1 (2023-09-20)
### Fix
* Bumping pyowletapi to 2023.9.1 to allow for revisions ([`0a7f703`](https://github.com/ryanbdclark/owlet/commit/0a7f70310080a129c988e9607331baa2f6c691e0))
* New revision of sock, revision 5 doesn't report all vitals as before, this would cause the integration to fail to update. Have adjusted the integration to detect the revision and ignore the vitals that are no longer reported ([`0a7f703`](https://github.com/ryanbdclark/owlet/commit/0a7f70310080a129c988e9607331baa2f6c691e0))
## 2023.8.1 (2023-08-21)
### Feature
* 2 new sensors, movement and movement bucket disabled by default, thanks [`@seanford`](https://github.com/seanford) ([`575b213`](https://github.com/ryanbdclark/owlet/commit/575b213ddd732779cd7938e575fc87c8881a69b0))
### Fix
* Various refactoring tasks completed to make this integration more inline with home assistants style guidelines ([`2cd46c1`](https://github.com/ryanbdclark/owlet/commit/c45959b123a6e5f77747475f11d3d3ab67859756))
* Added new sensors to strings jsons ([`2cd46c1`](https://github.com/ryanbdclark/owlet/commit/c45959b123a6e5f77747475f11d3d3ab67859756))
## 2023.7.2 (2023-07-04)
### Fix
* Bumping pyowletapi version to 2023.7.2 ([`c45959b`](https://github.com/ryanbdclark/owlet/commit/c45959b123a6e5f77747475f11d3d3ab67859756))
## 2023.7.1 (2023-07-03)
### Fix
* Bumping pyowletapi to 2023.7.1 ([`c693fef`](https://github.com/ryanbdclark/owlet/commit/c693fefbf3dba8f35802b87d064401dadbb211b5))
## 2023.5.7 (2023-05-30)
### Fix
* Fixed issue with binary sensors not loading, caused by change to way the coordinators are stored ([`8d17317`](https://github.com/ryanbdclark/owlet/commit/8d173174e286b0451cbb2c0d4ae3087028d1ea23))
## 2023.5.6 (2023-05-30)
### Fix
* In light of submitting this as a pull request to the core of HA there have been some refactoring changes to comply with HA's style requirements
* Sensor names now moved to strings file to allow for translations
* Coordinator now properly handles multiple devices
* Spelling of signal strength sensor corrected
### Feature
* Tests added
## 2023.5.5 (2023-05-19)
#### Fix
* Owlet refresh token becomes invalid after 24 hours. Meant that after 1 day the integration would stop working. Moved to pyowletapi v2023.5.28 which uses different refresh token, should no longer need reconfiguring after 24 hours ([`dc58b19`](https://github.com/ryanbdclark/owlet/commit/0141f7d01a9ac9b3e1dcc74cabb896e19bd4a821))
## 2023.5.4 (2023-05-17)
#### Fix
* Bumping to pyowletapi 2023.5.25
## 2023.5.3 (2023-05-17)
#### Fix
* Bumping to pyowletapi 2023.5.24
* Reauthing now no longer re adds users' password to config entry
## 2023-05-2 (2023-05-16)
## 2023.5.2 (2023-05-16)
#### Feature
* Integration now makes use of refresh token from pyowletapi to reauthenticate, user password in no longer stored by integration ([`dc710a1`](https://github.com/ryanbdclark/owlet/commit/dc710a1783a4cad9d6cf355240fe12ac779a87ef))
* New sensors create for baby sleep state ([`9b3392b`](https://github.com/ryanbdclark/owlet/commit/9b3392bdbcd82015ed31d3a50a517e4e22905684))
## 2023-05-1 (2023-05-15)
## 2023.5.1 (2023-05-15)
#### Feature
* Changed versioning to date based
### Fix
@@ -25,4 +131,4 @@
### 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
* On initialisation the integration would crash when trying to update the auth token, the integration would then have to be deleted and setup again

View File

@@ -8,13 +8,11 @@
[![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.
A custom component for the Owlet smart sock
## 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.
1. Use [HACS](https://hacs.xyz/docs/use/download/download/), in `HACS > Integrations > Explore & Add Repositories` search for "Owlet".
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".
@@ -47,4 +45,4 @@ This integration provides the following entities:
[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
[add-integration-badge]: https://my.home-assistant.io/badges/config_flow_start.svg

View File

@@ -1,32 +1,36 @@
"""The Owlet Smart Sock integration."""
from __future__ import annotations
import asyncio
import logging
from pyowletapi.api import OwletAPI
from pyowletapi.exceptions import (
OwletAuthenticationError,
OwletConnectionError,
OwletDevicesError,
OwletEmailError,
OwletPasswordError,
)
from pyowletapi.sock import Sock
from pyowletapi.exceptions import OwletAuthenticationError
from homeassistant.config_entries import ConfigEntry, ConfigEntryAuthFailed
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
Platform,
CONF_REGION,
CONF_USERNAME,
CONF_PASSWORD,
CONF_SCAN_INTERVAL,
CONF_API_TOKEN,
CONF_REGION,
CONF_SCAN_INTERVAL,
CONF_USERNAME,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import (
DOMAIN,
CONF_OWLET_EXPIRY,
CONF_OWLET_REFRESH,
SUPPORTED_VERSIONS,
)
from .const import CONF_OWLET_EXPIRY, CONF_OWLET_REFRESH, DOMAIN, SUPPORTED_VERSIONS
from .coordinator import OwletCoordinator
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.CAMERA, Platform.SENSOR, Platform.SWITCH]
_LOGGER = logging.getLogger(__name__)
@@ -44,30 +48,47 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
try:
token = await owlet_api.authenticate()
if token:
if token := await owlet_api.authenticate():
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)
}
devices = await owlet_api.get_devices(SUPPORTED_VERSIONS)
except OwletAuthenticationError as err:
except (OwletAuthenticationError, OwletEmailError, OwletPasswordError) 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_SCAN_INTERVAL))
for sock in socks.values()
]
except OwletConnectionError as err:
raise ConfigEntryNotReady(
f"Error connecting to {entry.data[CONF_USERNAME]}"
) from err
for coordinator in coordinators:
await coordinator.async_config_entry_first_refresh()
hass.data[DOMAIN][entry.entry_id] = coordinator
except OwletDevicesError:
_LOGGER.error("No owlet devices found to set up")
return False
if "tokens" in devices:
hass.config_entries.async_update_entry(
entry, data={**entry.data, **devices["tokens"]}
)
scan_interval = entry.options.get(CONF_SCAN_INTERVAL)
coordinators = {
device["device"]["dsn"]: OwletCoordinator(
hass, Sock(owlet_api, device["device"]), scan_interval, entry
)
for device in devices["response"]
}
await asyncio.gather(
*(
coordinator.async_config_entry_first_refresh()
for coordinator in list(coordinators.values())
)
)
hass.data[DOMAIN][entry.entry_id] = coordinators
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

View File

@@ -1,4 +1,5 @@
"""Support for Owlet binary sensors."""
from __future__ import annotations
from dataclasses import dataclass
@@ -17,80 +18,79 @@ from .coordinator import OwletCoordinator
from .entity import OwletBaseEntity
@dataclass
class OwletBinarySensorEntityMixin:
"""Owlet binary sensor element mixin"""
element: str
@dataclass
class OwletBinarySensorEntityDescription(
BinarySensorEntityDescription, OwletBinarySensorEntityMixin
):
@dataclass(kw_only=True)
class OwletBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Represent the owlet binary sensor entity description."""
available_during_charging: bool
SENSORS: tuple[OwletBinarySensorEntityDescription, ...] = (
OwletBinarySensorEntityDescription(
key="charging",
name="Charging",
translation_key="charging",
device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
element="charging",
available_during_charging=True,
),
OwletBinarySensorEntityDescription(
key="highhr",
name="High heart rate alert",
key="high_heart_rate_alert",
translation_key="high_hr_alrt",
device_class=BinarySensorDeviceClass.SOUND,
element="high_heart_rate_alert",
available_during_charging=True,
),
OwletBinarySensorEntityDescription(
key="lowhr",
name="Low Heart Rate Alert",
key="low_heart_rate_alert",
translation_key="low_hr_alrt",
device_class=BinarySensorDeviceClass.SOUND,
element="low_heart_rate_alert",
available_during_charging=True,
),
OwletBinarySensorEntityDescription(
key="higho2",
name="High oxygen alert",
key="high_oxygen_alert",
translation_key="high_ox_alrt",
device_class=BinarySensorDeviceClass.SOUND,
element="high_oxygen_alert",
available_during_charging=True,
),
OwletBinarySensorEntityDescription(
key="lowo2",
name="Low oxygen alert",
key="low_oxygen_alert",
translation_key="low_ox_alrt",
device_class=BinarySensorDeviceClass.SOUND,
element="low_oxygen_alert",
available_during_charging=True,
),
OwletBinarySensorEntityDescription(
key="lowbattery",
name="Low Battery alert",
key="critical_oxygen_alert",
translation_key="crit_ox_alrt",
device_class=BinarySensorDeviceClass.SOUND,
element="low_battery_alert",
available_during_charging=True,
),
OwletBinarySensorEntityDescription(
key="lostpower",
name="Lost power alert",
key="low_battery_alert",
translation_key="low_batt_alrt",
device_class=BinarySensorDeviceClass.SOUND,
element="lost_power_alert",
available_during_charging=True,
),
OwletBinarySensorEntityDescription(
key="sockdisconnected",
name="Sock disconnected alert",
key="critical_battery_alert",
translation_key="crit_batt_alrt",
device_class=BinarySensorDeviceClass.SOUND,
element="sock_disconnected",
available_during_charging=True,
),
OwletBinarySensorEntityDescription(
key="lost_power_alert",
translation_key="lost_pwr_alrt",
device_class=BinarySensorDeviceClass.SOUND,
available_during_charging=True,
),
OwletBinarySensorEntityDescription(
key="sock_disconnected",
translation_key="sock_discon_alrt",
device_class=BinarySensorDeviceClass.SOUND,
available_during_charging=True,
),
OwletBinarySensorEntityDescription(
key="sock_off",
name="Sock off",
translation_key="sock_off",
device_class=BinarySensorDeviceClass.POWER,
element="sock_off",
),
OwletBinarySensorEntityDescription(
key="awake",
name="Awake",
element="sleep_state",
icon="mdi:sleep",
available_during_charging=True,
),
)
@@ -102,11 +102,20 @@ async def async_setup_entry(
) -> None:
"""Set up the owlet sensors from config entry."""
coordinator: OwletCoordinator = hass.data[DOMAIN][config_entry.entry_id]
coordinators: OwletCoordinator = hass.data[DOMAIN][config_entry.entry_id].values()
entities = [OwletBinarySensor(coordinator, sensor) for sensor in SENSORS]
sensors = []
for coordinator in coordinators:
sensors.extend([
OwletBinarySensor(coordinator, sensor)
for sensor in SENSORS
if sensor.key in coordinator.sock.properties
])
async_add_entities(entities)
if OwletAwakeSensor.entity_description.key in coordinator.sock.properties:
sensors.append(OwletAwakeSensor(coordinator))
async_add_entities(sensors)
class OwletBinarySensor(OwletBaseEntity, BinarySensorEntity):
@@ -115,24 +124,46 @@ class OwletBinarySensor(OwletBaseEntity, BinarySensorEntity):
def __init__(
self,
coordinator: OwletCoordinator,
sensor_description: OwletBinarySensorEntityDescription,
description: OwletBinarySensorEntityDescription,
) -> None:
"""Initialize the binary sensor."""
super().__init__(coordinator)
self.entity_description = sensor_description
self._attr_unique_id = f"{self.sock.serial}-{self.entity_description.name}"
self.entity_description = description
self._attr_unique_id = f"{self.sock.serial}-{description.key}"
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and (
not self.sock.properties["charging"]
or self.entity_description.available_during_charging
)
@property
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
state = self.sock.properties[self.entity_description.element]
if self.entity_description.element == "sleep_state":
if self.sock.properties["charging"]:
return None
if state in [8, 15]:
state = False
else:
state = True
return self.sock.properties[self.entity_description.key]
return state
class OwletAwakeSensor(OwletBinarySensor):
"""Representation of an Owlet sleep sensor."""
entity_description = OwletBinarySensorEntityDescription(
key="sleep_state",
translation_key="awake",
icon="mdi:sleep",
available_during_charging=False,
)
def __init__(
self,
coordinator: OwletCoordinator,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, self.entity_description)
@property
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
return self.sock.properties[self.entity_description.key] not in [8, 15]

View File

@@ -0,0 +1,217 @@
"""Owlet Camera integration."""
from __future__ import annotations
import asyncio
from datetime import timedelta
import logging
from typing import Any
import aiohttp
import async_timeout
from homeassistant.components.camera import Camera, CameraEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_TOKEN, CONF_REGION, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, MANUFACTURER
_LOGGER = logging.getLogger(__name__)
# Camera API endpoints based on region
CAMERA_KMS_ENDPOINTS = {
"world": "https://camera-kms.owletdata.com/kms/",
"europe": "https://camera-kms.eu.owletdata.com/kms/",
}
# AWS Kinesis Video endpoint template
AWS_KINESIS_ENDPOINT_TEMPLATE = "https://kinesisvideo.{region}.amazonaws.com"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Owlet cameras."""
session = async_get_clientsession(hass)
region = config_entry.data[CONF_REGION]
token = config_entry.data[CONF_API_TOKEN]
# Get cameras from Owlet API
camera_api = OwletCameraAPI(session, region, token)
try:
cameras = await camera_api.get_cameras()
_LOGGER.info(f"Found {len(cameras)} Owlet camera(s)")
entities = [
OwletCamera(hass, camera, camera_api)
for camera in cameras
]
async_add_entities(entities)
except Exception as err:
_LOGGER.error(f"Error setting up Owlet cameras: {err}")
class OwletCameraAPI:
"""API client for Owlet cameras."""
def __init__(self, session: aiohttp.ClientSession, region: str, token: str):
"""Initialize the camera API."""
self.session = session
self.region = region
self.token = token
self.kms_endpoint = CAMERA_KMS_ENDPOINTS.get(
region, CAMERA_KMS_ENDPOINTS["world"]
)
async def get_cameras(self) -> list[dict[str, Any]]:
"""Get list of cameras from Owlet API."""
# This will need to call the Owlet devices API to get cameras
# For now, return empty list - needs actual API implementation
headers = {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
}
try:
# TODO: Implement actual camera discovery endpoint
# This might be part of the existing get_devices call
# For now, we'll need to check if pyowletapi has camera support
_LOGGER.warning("Camera discovery not yet implemented")
return []
except Exception as err:
_LOGGER.error(f"Error getting cameras: {err}")
return []
async def get_stream_credentials(self, camera_id: str) -> dict[str, Any]:
"""Get AWS Kinesis credentials for a camera."""
headers = {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
}
try:
async with async_timeout.timeout(10):
response = await self.session.post(
self.kms_endpoint,
json={"camera_id": camera_id},
headers=headers,
)
response.raise_for_status()
return await response.json()
except asyncio.TimeoutError:
_LOGGER.error(f"Timeout getting credentials for camera {camera_id}")
raise
except aiohttp.ClientError as err:
_LOGGER.error(f"Error getting credentials for camera {camera_id}: {err}")
raise
async def get_hls_streaming_url(
self, stream_name: str, aws_credentials: dict[str, Any]
) -> str:
"""Get HLS streaming URL from AWS Kinesis Video Streams."""
# This would use AWS SDK to:
# 1. Get data endpoint for the stream
# 2. Call GetHLSStreamingSessionURL
# For now, this is a placeholder
_LOGGER.warning("HLS streaming URL generation not yet implemented")
return ""
class OwletCamera(Camera):
"""Representation of an Owlet Camera."""
_attr_has_entity_name = True
_attr_supported_features = CameraEntityFeature.STREAM
def __init__(
self,
hass: HomeAssistant,
camera_data: dict[str, Any],
api: OwletCameraAPI,
) -> None:
"""Initialize the camera."""
super().__init__()
self._hass = hass
self._api = api
self._camera_data = camera_data
self._attr_name = camera_data.get("name", "Owlet Camera")
self._attr_unique_id = camera_data.get("device_id") or camera_data.get("dsn")
self._stream_url: str | None = None
self._last_url_refresh = None
@property
def device_info(self):
"""Return device information."""
return {
"identifiers": {(DOMAIN, self.unique_id)},
"name": self._attr_name,
"manufacturer": MANUFACTURER,
"model": self._camera_data.get("model", "Owlet Cam"),
}
@property
def available(self) -> bool:
"""Return True if camera is available."""
return True
async def async_camera_image(
self, width: int | None = None, height: int | None = None
) -> bytes | None:
"""Return a still image from the camera."""
# Get the stream URL and extract a frame
# This is optional - HLS streams don't provide easy still images
return None
async def stream_source(self) -> str | None:
"""Return the stream source URL."""
# Refresh URL if needed (AWS URLs expire)
if self._should_refresh_url():
await self._refresh_stream_url()
return self._stream_url
def _should_refresh_url(self) -> bool:
"""Check if stream URL needs to be refreshed."""
if self._stream_url is None:
return True
# AWS HLS URLs typically expire after a period
# Refresh every 30 minutes to be safe
if self._last_url_refresh is None:
return True
from datetime import datetime
age = datetime.now() - self._last_url_refresh
return age > timedelta(minutes=30)
async def _refresh_stream_url(self) -> None:
"""Refresh the HLS streaming URL."""
try:
camera_id = self._camera_data.get("device_id") or self._camera_data.get("dsn")
# Step 1: Get AWS credentials from Owlet KMS API
credentials = await self._api.get_stream_credentials(camera_id)
# Step 2: Use credentials to get HLS URL from AWS Kinesis
stream_name = self._camera_data.get("kinesis_stream_name", camera_id)
self._stream_url = await self._api.get_hls_streaming_url(
stream_name, credentials
)
from datetime import datetime
self._last_url_refresh = datetime.now()
_LOGGER.info(f"Refreshed stream URL for camera {self._attr_name}")
except Exception as err:
_LOGGER.error(f"Error refreshing stream URL: {err}")
self._stream_url = None

View File

@@ -1,47 +1,40 @@
"""Config flow for Owlet Smart Sock integration."""
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
from pyowletapi.api import OwletAPI
from pyowletapi.sock import Sock
from pyowletapi.exceptions import (
OwletCredentialsError,
OwletDevicesError,
OwletEmailError,
OwletPasswordError,
)
import voluptuous as vol
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.config_entries import ConfigEntry, ConfigFlowResult
from homeassistant.const import (
CONF_REGION,
CONF_USERNAME,
CONF_PASSWORD,
CONF_REGION,
CONF_SCAN_INTERVAL,
CONF_API_TOKEN,
CONF_USERNAME,
)
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import (
DOMAIN,
CONF_OWLET_EXPIRY,
POLLING_INTERVAL,
SUPPORTED_VERSIONS,
CONF_OWLET_REFRESH,
)
from .const import DOMAIN, POLLING_INTERVAL
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required("region"): vol.In(["europe", "world"]),
vol.Required("username"): str,
vol.Required("password"): str,
vol.Required(CONF_REGION): vol.In(["europe", "world"]),
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
)
@@ -50,60 +43,52 @@ class OwletConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Owlet Smart Sock."""
VERSION = 1
reauth_entry: ConfigEntry | None = None
def __init__(self) -> None:
self._entry: ConfigEntry
self._region: str
self._username: str
self._password: str
self._devices: dict[str, Sock]
self.reauth_entry: ConfigEntry | None = None
"""Initialise config flow."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
self._region = user_input[CONF_REGION]
self._username = user_input[CONF_USERNAME]
self._password = user_input[CONF_PASSWORD]
owlet_api = OwletAPI(
self._region,
self._username,
self._password,
region=user_input[CONF_REGION],
user=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
session=async_get_clientsession(self.hass),
)
await self.async_set_unique_id(self._username.lower())
await self.async_set_unique_id(user_input[CONF_USERNAME].lower())
self._abort_if_unique_id_configured()
try:
token = await owlet_api.authenticate()
try:
await owlet_api.get_devices(SUPPORTED_VERSIONS)
return self.async_create_entry(
title=self._username,
data={
CONF_REGION: self._region,
CONF_USERNAME: self._username,
CONF_API_TOKEN: token[CONF_API_TOKEN],
CONF_OWLET_EXPIRY: token[CONF_OWLET_EXPIRY],
CONF_OWLET_REFRESH: token[CONF_OWLET_REFRESH],
},
options={CONF_SCAN_INTERVAL: POLLING_INTERVAL},
)
except OwletDevicesError:
errors["base"] = "no_devices"
await owlet_api.validate_authentication()
except OwletDevicesError:
errors["base"] = "no_devices"
except OwletEmailError:
errors["base"] = "invalid_email"
errors[CONF_USERNAME] = "invalid_email"
except OwletPasswordError:
errors["base"] = "invalid_password"
errors[CONF_PASSWORD] = "invalid_password"
except OwletCredentialsError:
errors["base"] = "invalid_credentials"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(
title=user_input[CONF_USERNAME],
data={
CONF_REGION: user_input[CONF_REGION],
CONF_USERNAME: user_input[CONF_USERNAME],
**token,
},
options={CONF_SCAN_INTERVAL: POLLING_INTERVAL},
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
@@ -111,19 +96,23 @@ class OwletConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
@staticmethod
@callback
def async_get_options_flow(config_entry):
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler:
"""Get the options flow for this handler."""
return OptionsFlowHandler(config_entry)
async def async_step_reauth(self, user_input=None):
"""Handle reauth"""
async def async_step_reauth(
self, user_input: Mapping[str, Any]
) -> ConfigFlowResult:
"""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"""
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Dialog that informs the user that reauth is required."""
assert self.reauth_entry is not None
errors: dict[str, str] = {}
@@ -136,22 +125,21 @@ class OwletConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
session=async_get_clientsession(self.hass),
)
try:
token = await owlet_api.authenticate()
if token:
if token := await owlet_api.authenticate():
self.hass.config_entries.async_update_entry(
self.reauth_entry, data={**entry_data, **token}
)
await self.hass.config_entries.async_reload(self.reauth_entry.entry_id)
await self.hass.config_entries.async_reload(
self.reauth_entry.entry_id
)
return self.async_abort(reason="reauth_successful")
return self.async_abort(reason="reauth_successful")
except OwletEmailError:
errors["base"] = "invalid_email"
except OwletPasswordError:
errors["base"] = "invalid_password"
errors[CONF_PASSWORD] = "invalid_password"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("error reauthing")
_LOGGER.exception("Error reauthenticating")
return self.async_show_form(
step_id="reauth_confirm",
@@ -161,14 +149,16 @@ class OwletConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
class OptionsFlowHandler(config_entries.OptionsFlow):
"""Handle a options flow for owlet"""
"""Handle a options flow for owlet."""
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
"""Initialise options flow"""
def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialise options flow."""
self.config_entry = config_entry
async def async_step_init(self, user_input=None):
"""Handle options flow"""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle options flow."""
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
@@ -177,7 +167,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
vol.Required(
CONF_SCAN_INTERVAL,
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=5)),
}
)
@@ -185,4 +175,4 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
class InvalidAuth(exceptions.HomeAssistantError):
"""Error to indiciate there is invalud auth"""
"""Error to indicate there is invalid auth."""

View File

@@ -5,7 +5,7 @@ DOMAIN = "owlet"
CONF_OWLET_EXPIRY = "expiry"
CONF_OWLET_REFRESH = "refresh"
SUPPORTED_VERSIONS = [3]
SUPPORTED_VERSIONS = [2, 3]
POLLING_INTERVAL = 5
MANUFACTURER = "Owlet Baby Care"
SLEEP_STATES = {0: "Unknown", 1: "Awake", 8: "Light Sleep", 15: "Deep Sleep"}
SLEEP_STATES = {0: "unknown", 1: "awake", 8: "light_sleep", 15: "deep_sleep"}

View File

@@ -1,22 +1,23 @@
"""Owlet integration."""
"""Owlet integration coordinator class."""
from __future__ import annotations
from datetime import timedelta
import logging
from pyowletapi.sock import Sock
from pyowletapi.exceptions import (
OwletError,
OwletConnectionError,
OwletAuthenticationError,
OwletConnectionError,
OwletError,
)
from pyowletapi.sock import Sock
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.const import CONF_API_TOKEN
from .const import DOMAIN, MANUFACTURER, CONF_OWLET_EXPIRY, CONF_OWLET_REFRESH
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -24,7 +25,9 @@ _LOGGER = logging.getLogger(__name__)
class OwletCoordinator(DataUpdateCoordinator):
"""Coordinator is responsible for querying the device at a specified route."""
def __init__(self, hass: HomeAssistant, sock: Sock, interval: int) -> None:
def __init__(
self, hass: HomeAssistant, sock: Sock, interval, entry: ConfigEntry
) -> None:
"""Initialise a custom coordinator."""
super().__init__(
hass,
@@ -32,35 +35,21 @@ class OwletCoordinator(DataUpdateCoordinator):
name=DOMAIN,
update_interval=timedelta(seconds=interval),
)
assert self.config_entry is not None
self._device_unique_id = sock.serial
self._model = sock.model
self._sw_version = sock.sw_version
self._hw_version = sock.version
self.sock = sock
self.device_info = DeviceInfo(
identifiers={(DOMAIN, self._device_unique_id)},
name="Owlet Baby Care Sock",
manufacturer=MANUFACTURER,
model=self._model,
sw_version=self._sw_version,
hw_version=self._hw_version,
)
self.config_entry: ConfigEntry = entry
async def _async_update_data(self) -> None:
"""Fetch the data from the device."""
try:
await self.sock.update_properties()
tokens = await self.sock.api.tokens_changed(
{
CONF_API_TOKEN: self.config_entry.data[CONF_API_TOKEN],
CONF_OWLET_EXPIRY: self.config_entry.data[CONF_OWLET_EXPIRY],
CONF_OWLET_REFRESH: self.config_entry.data[CONF_OWLET_REFRESH],
}
)
if tokens:
properties = await self.sock.update_properties()
if "tokens" in properties:
self.hass.config_entries.async_update_entry(
self.config_entry, data={**self.config_entry.data, **tokens}
self.config_entry,
data={**self.config_entry.data, **properties["tokens"]},
)
except (OwletError, OwletConnectionError, OwletAuthenticationError) as err:
except OwletAuthenticationError as err:
raise ConfigEntryAuthFailed(
f"Authentication failed for {self.config_entry.data[CONF_EMAIL]}"
) from err
except (OwletError, OwletConnectionError) as err:
raise UpdateFailed(err) from err

View File

@@ -1,20 +1,38 @@
"""Base class for Owlet entities."""
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
from .coordinator import OwletCoordinator
class OwletBaseEntity(CoordinatorEntity[OwletCoordinator], Entity):
"""Base class for Owlet Sock entities."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: OwletCoordinator,
) -> None:
"""Initialize the base entity."""
super().__init__(coordinator)
self.coordinator = coordinator
self.sock = coordinator.sock
self._attr_device_info = coordinator.device_info
self._attr_has_entity_name = True
@property
def device_info(self) -> DeviceInfo:
"""Return the device info of the device."""
return DeviceInfo(
identifiers={(DOMAIN, self.sock.serial)},
name=f"Owlet Sock {self.sock.serial}",
connections={("mac", getattr(self.sock, "mac", "unknown"))},
suggested_area="Nursery",
configuration_url="https://my.owletcare.com/",
manufacturer="Owlet Baby Care",
model=getattr(self.sock, "model", None),
sw_version=getattr(self.sock, "sw_version", None),
hw_version=getattr(self.sock, "hw_version", "3r8"),
)

View File

@@ -0,0 +1,213 @@
"""AWS Kinesis Video Streams client for Owlet cameras."""
from __future__ import annotations
import asyncio
from datetime import datetime, timedelta
import hashlib
import hmac
import json
import logging
from typing import Any
from urllib.parse import quote
import aiohttp
_LOGGER = logging.getLogger(__name__)
class KinesisVideoClient:
"""Client for AWS Kinesis Video Streams API."""
def __init__(
self,
session: aiohttp.ClientSession,
region: str = "eu-west-1",
):
"""Initialize the Kinesis client."""
self.session = session
self.region = region
self.service = "kinesisvideo"
self.control_endpoint = f"https://kinesisvideo.{region}.amazonaws.com"
async def get_data_endpoint(
self,
stream_name: str,
access_key: str,
secret_key: str,
session_token: str | None = None,
) -> str:
"""Get the data endpoint for a Kinesis Video Stream."""
endpoint = self.control_endpoint
headers = self._get_signed_headers(
method="POST",
uri="/getDataEndpoint",
access_key=access_key,
secret_key=secret_key,
session_token=session_token,
payload={
"StreamName": stream_name,
"APIName": "GET_HLS_STREAMING_SESSION_URL",
},
)
try:
async with asyncio.timeout(10):
response = await self.session.post(
f"{endpoint}/getDataEndpoint",
json={
"StreamName": stream_name,
"APIName": "GET_HLS_STREAMING_SESSION_URL",
},
headers=headers,
)
response.raise_for_status()
data = await response.json()
return data.get("DataEndpoint", "")
except Exception as err:
_LOGGER.error(f"Error getting data endpoint: {err}")
raise
async def get_hls_streaming_url(
self,
stream_name: str,
data_endpoint: str,
access_key: str,
secret_key: str,
session_token: str | None = None,
expires: int = 43200, # 12 hours
) -> str:
"""Get HLS streaming URL for a Kinesis Video Stream."""
service = "kinesisvideo"
headers = self._get_signed_headers(
method="POST",
uri="/getHLSStreamingSessionURL",
access_key=access_key,
secret_key=secret_key,
session_token=session_token,
endpoint=data_endpoint,
payload={
"StreamName": stream_name,
"PlaybackMode": "LIVE",
"HLSFragmentSelector": {
"FragmentSelectorType": "SERVER_TIMESTAMP"
},
"ContainerFormat": "MPEG_TS",
"DiscontinuityMode": "ALWAYS",
"DisplayFragmentTimestamp": "NEVER",
"Expires": expires,
},
)
try:
async with asyncio.timeout(10):
response = await self.session.post(
f"{data_endpoint}/getHLSStreamingSessionURL",
json={
"StreamName": stream_name,
"PlaybackMode": "LIVE",
"HLSFragmentSelector": {
"FragmentSelectorType": "SERVER_TIMESTAMP"
},
"ContainerFormat": "MPEG_TS",
"DiscontinuityMode": "ALWAYS",
"DisplayFragmentTimestamp": "NEVER",
"Expires": expires,
},
headers=headers,
)
response.raise_for_status()
data = await response.json()
return data.get("HLSStreamingSessionURL", "")
except Exception as err:
_LOGGER.error(f"Error getting HLS URL: {err}")
raise
def _get_signed_headers(
self,
method: str,
uri: str,
access_key: str,
secret_key: str,
session_token: str | None = None,
endpoint: str | None = None,
payload: dict[str, Any] | None = None,
) -> dict[str, str]:
"""Generate AWS Signature V4 signed headers."""
if endpoint is None:
endpoint = self.control_endpoint
# Parse endpoint to get host
host = endpoint.replace("https://", "").replace("http://", "")
# Current timestamp
now = datetime.utcnow()
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
date_stamp = now.strftime("%Y%m%d")
# Payload
payload_str = json.dumps(payload) if payload else ""
payload_hash = hashlib.sha256(payload_str.encode("utf-8")).hexdigest()
# Canonical request
canonical_headers = f"host:{host}\nx-amz-date:{amz_date}\n"
signed_headers = "host;x-amz-date"
if session_token:
canonical_headers += f"x-amz-security-token:{session_token}\n"
signed_headers += ";x-amz-security-token"
canonical_request = (
f"{method}\n"
f"{uri}\n"
f"\n" # Query string (empty)
f"{canonical_headers}\n"
f"{signed_headers}\n"
f"{payload_hash}"
)
# String to sign
algorithm = "AWS4-HMAC-SHA256"
credential_scope = f"{date_stamp}/{self.region}/{self.service}/aws4_request"
string_to_sign = (
f"{algorithm}\n"
f"{amz_date}\n"
f"{credential_scope}\n"
f"{hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()}"
)
# Signing key
def sign(key: bytes, msg: str) -> bytes:
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
k_date = sign(f"AWS4{secret_key}".encode("utf-8"), date_stamp)
k_region = sign(k_date, self.region)
k_service = sign(k_region, self.service)
signing_key = sign(k_service, "aws4_request")
# Signature
signature = hmac.new(
signing_key, string_to_sign.encode("utf-8"), hashlib.sha256
).hexdigest()
# Authorization header
authorization_header = (
f"{algorithm} "
f"Credential={access_key}/{credential_scope}, "
f"SignedHeaders={signed_headers}, "
f"Signature={signature}"
)
headers = {
"Content-Type": "application/json",
"X-Amz-Date": amz_date,
"Authorization": authorization_header,
"Host": host,
}
if session_token:
headers["X-Amz-Security-Token"] = session_token
return headers

View File

@@ -5,12 +5,11 @@
"@ryanbdclark"
],
"config_flow": true,
"dependencies": [],
"documentation": "https://www.home-assistant.io/integrations/owlet",
"homekit": {},
"documentation":"https://github.com/ryanbdclark/owlet",
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/ryanbdclark/owlet/issues",
"requirements": [
"pyowletapi==2023.5.24"
"pyowletapi==2025.4.1"
],
"version":"2023.5.3"
"version": "2025.4.3"
}

View File

@@ -1,5 +1,7 @@
"""Support for Android IP Webcam binary sensors."""
"""Support for Owlet sensors."""
from __future__ import annotations
from dataclasses import dataclass
from homeassistant.components.sensor import (
@@ -9,90 +11,92 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.const import (
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
UnitOfTime,
UnitOfTemperature,
UnitOfTime,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import DOMAIN, SLEEP_STATES
from .coordinator import OwletCoordinator
from .entity import OwletBaseEntity
@dataclass
class OwletSensorEntityDescriptionMixin:
"""Owlet sensor description mix in"""
element: str
@dataclass
class OwletSensorEntityDescription(
SensorEntityDescription, OwletSensorEntityDescriptionMixin
):
@dataclass(kw_only=True)
class OwletSensorEntityDescription(SensorEntityDescription):
"""Represent the owlet sensor entity description."""
available_during_charging: bool
SENSORS: tuple[OwletSensorEntityDescription, ...] = (
OwletSensorEntityDescription(
key="batterypercentage",
name="Battery",
key="battery_percentage",
translation_key="batterypercent",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
state_class=SensorStateClass.MEASUREMENT,
element="battery_percentage",
available_during_charging=True,
),
OwletSensorEntityDescription(
key="oxygensaturation",
name="O2 Saturation",
key="oxygen_saturation",
translation_key="o2saturation",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
element="oxygen_saturation",
icon="mdi:leaf",
available_during_charging=False,
),
OwletSensorEntityDescription(
key="oxygensaturation10a",
name="O2 Saturation 10 Minute Average",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
element="oxygen_10_av",
icon="mdi:leaf",
),
OwletSensorEntityDescription(
key="heartrate",
name="Heart rate",
key="heart_rate",
translation_key="heartrate",
native_unit_of_measurement="bpm",
state_class=SensorStateClass.MEASUREMENT,
element="heart_rate",
icon="mdi:heart-pulse",
available_during_charging=False,
),
OwletSensorEntityDescription(
key="batteryminutes",
name="Battery Remaining",
key="battery_minutes",
translation_key="batterymin",
native_unit_of_measurement=UnitOfTime.MINUTES,
device_class=SensorDeviceClass.DURATION,
state_class=SensorStateClass.MEASUREMENT,
element="battery_minutes",
available_during_charging=False,
),
OwletSensorEntityDescription(
key="signalstrength",
name="Singal Strength",
key="signal_strength",
translation_key="signalstrength",
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
state_class=SensorStateClass.MEASUREMENT,
element="signal_strength",
available_during_charging=True,
),
OwletSensorEntityDescription(
key="skintemp",
name="Skin Temperature",
key="skin_temperature",
translation_key="skintemp",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
element="skin_temperature",
available_during_charging=False,
),
OwletSensorEntityDescription(
key="movement",
translation_key="movement",
state_class=SensorStateClass.MEASUREMENT,
icon="mdi:cursor-move",
entity_registry_enabled_default=False,
available_during_charging=False,
),
OwletSensorEntityDescription(
key="movement_bucket",
translation_key="movementbucket",
state_class=SensorStateClass.MEASUREMENT,
icon="mdi:bucket-outline",
entity_registry_enabled_default=False,
available_during_charging=False,
),
)
@@ -104,12 +108,28 @@ async def async_setup_entry(
) -> None:
"""Set up the owlet sensors from config entry."""
coordinator: OwletCoordinator = hass.data[DOMAIN][config_entry.entry_id]
coordinators: list[OwletCoordinator] = list(
hass.data[DOMAIN][config_entry.entry_id].values()
)
entities = [OwletSensor(coordinator, sensor) for sensor in SENSORS]
entities.append(OwletSleepStateSensor(coordinator))
sensors = []
async_add_entities(entities)
for coordinator in coordinators:
sensors.extend([
OwletSensor(coordinator, sensor)
for sensor in SENSORS
if sensor.key in coordinator.sock.properties
])
if OwletSleepSensor.entity_description.key in coordinator.sock.properties:
sensors.append(OwletSleepSensor(coordinator))
if (
OwletOxygenAverageSensor.entity_description.key
in coordinator.sock.properties
):
sensors.append(OwletOxygenAverageSensor(coordinator))
async_add_entities(sensors)
class OwletSensor(OwletBaseEntity, SensorEntity):
@@ -118,56 +138,82 @@ class OwletSensor(OwletBaseEntity, SensorEntity):
def __init__(
self,
coordinator: OwletCoordinator,
sensor_description: OwletSensorEntityDescription,
description: OwletSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = sensor_description
self._attr_unique_id = f"{self.sock.serial}-{self.entity_description.name}"
self.entity_description: OwletSensorEntityDescription = description
self._attr_unique_id = f"{self.sock.serial}-{description.key}"
@property
def native_value(self):
"""Return sensor value"""
def available(self) -> bool:
"""Return if entity is available."""
return super().available and (
not self.sock.properties["charging"]
or self.entity_description.available_during_charging
)
if (
self.entity_description.element
in [
"heart_rate",
"battery_minutes",
"oxygen_saturation",
"skin_temperature",
"oxygen_10_av",
]
and self.sock.properties["charging"]
):
return None
@property
def native_value(self) -> StateType:
"""Return sensor value."""
return self.sock.properties[self.entity_description.element]
return self.sock.properties[self.entity_description.key]
class OwletSleepStateSensor(OwletBaseEntity, SensorEntity):
"""Representation of an Owlet sleep state sensor."""
class OwletSleepSensor(OwletSensor):
"""Representation of an Owlet sleep sensor."""
_attr_options = list(SLEEP_STATES.values())
entity_description = OwletSensorEntityDescription(
key="sleep_state",
translation_key="sleepstate",
device_class=SensorDeviceClass.ENUM,
available_during_charging=False,
)
def __init__(
self,
coordinator: OwletCoordinator,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self._attr_unique_id = f"{self.sock.serial}-Sleep State"
self._attr_icon = "mdi:sleep"
self._attr_device_class = SensorDeviceClass.ENUM
self._attr_translation_key = "sleepstate"
self._attr_name = "Sleep State"
super().__init__(coordinator, self.entity_description)
@property
def native_value(self):
"""Return sensor value"""
if self.sock.properties["charging"]:
return None
def native_value(self) -> StateType:
"""Return sensor value."""
return SLEEP_STATES[self.sock.properties["sleep_state"]]
class OwletOxygenAverageSensor(OwletSensor):
"""Representation of an Owlet sleep sensor."""
entity_description = OwletSensorEntityDescription(
key="oxygen_10_av",
translation_key="o2saturation10a",
native_unit_of_measurement=PERCENTAGE,
icon="mdi:leaf",
available_during_charging=False,
state_class=SensorStateClass.MEASUREMENT,
)
def __init__(
self,
coordinator: OwletCoordinator,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, self.entity_description)
@property
def options(self) -> list[str]:
return SLEEP_STATES.values()
def available(self) -> bool:
"""Return if entity is available."""
return (
super().available
and (
not self.sock.properties["charging"]
or self.entity_description.available_during_charging
)
and (
self.sock.properties["oxygen_10_av"] >= 0
and self.sock.properties["oxygen_10_av"] <= 100
)
)

View File

@@ -1,18 +1,17 @@
{
"config": {
"step": {
"user":{
"title": "Enter login details",
"data":{
"user": {
"data": {
"region": "Region",
"username": "Email",
"password": "Password"
"password": "[%key:common::config_flow::data::password%]"
}
},
"reauth_confirm":{
"reauth_confirm": {
"title": "Reauthentiaction required for Owlet",
"data":{
"password": "Password"
"data": {
"password": "[%key:common::config_flow::data::password%]"
}
}
},
@@ -20,20 +19,105 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_email": "Entered email address is incorrect",
"invalid_password": "Entered password is incorrect",
"unknown": "[%key:common::config_flow::error::unknown%]"
"invalid_credentials": "Entered credentials are incorrect",
"unknown": "Unknown error occured"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
"already_configured": "Device already configured",
"reauth_successful": "Reauthentication successful"
}
},
"options": {
"step": {
"init":{
"title":"Configure options for Owlet",
"data":{
"init": {
"title": "Configure options for Owlet",
"data": {
"pollinterval": "Polling interval in seconds, min 10"
}
}
}
},
"entity": {
"binary_sensor": {
"charging": {
"name": "Charging"
},
"high_hr_alrt": {
"name": "High heart rate alert"
},
"low_hr_alrt": {
"name": "Low heart rate alert"
},
"high_ox_alrt": {
"name": "High oxygen alert"
},
"low_ox_alrt": {
"name": "Low oxygen alert"
},
"crit_ox_alrt": {
"name": "Critical oxygen alert"
},
"low_batt_alrt": {
"name": "Low battery alert"
},
"crit_batt_alrt": {
"name": "Critical battery alert"
},
"lost_pwr_alrt": {
"name": "Lost power alert"
},
"sock_discon_alrt": {
"name": "Sock disconnected alert"
},
"sock_off": {
"name": "Sock off"
},
"awake": {
"name": "Awake"
}
},
"sensor": {
"batterypercent": {
"name": "Battery percentage"
},
"signalstrength": {
"name": "Signal strength"
},
"o2saturation": {
"name": "O2 saturation"
},
"o2saturation10a": {
"name": "O2 saturation 10 minute average"
},
"heartrate": {
"name": "Heart rate"
},
"batterymin": {
"name": "Battery remaining"
},
"skintemp": {
"name": "Skin temperature"
},
"sleepstate": {
"name": "Sleep state",
"state": {
"unknown": "Unknown",
"awake": "Awake",
"light_sleep": "Light sleep",
"deep_sleep": "Deep sleep"
}
},
"movement": {
"name": "Movement"
},
"movementbucket": {
"name": "Movement bucket"
}
},
"switch": {
"base_on": {
"name": "Base station on"
}
}
}
}

View File

@@ -0,0 +1,94 @@
"""Support for Owlet switches."""
from __future__ import annotations
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from datetime import timedelta
from typing import Any
from pyowletapi.sock import Sock
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .coordinator import OwletCoordinator
from .entity import OwletBaseEntity
SCAN_INTERVAL = timedelta(seconds=5)
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class OwletSwitchEntityDescription(SwitchEntityDescription):
"""Describes Owlet switch entity."""
turn_on_fn: Callable[[Sock], Callable[[bool], Coroutine[Any, Any, None]]]
turn_off_fn: Callable[[Sock], Callable[[bool], Coroutine[Any, Any, None]]]
available_during_charging: bool
SWITCHES: tuple[OwletSwitchEntityDescription, ...] = (
OwletSwitchEntityDescription(
key="base_station_on",
translation_key="base_on",
turn_on_fn=lambda sock: (lambda state: sock.control_base_station(state)),
turn_off_fn=lambda sock: (lambda state: sock.control_base_station(state)),
available_during_charging=False,
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Owlet switch based on a config entry."""
coordinators: OwletCoordinator = hass.data[DOMAIN][config_entry.entry_id].values()
switches = []
for coordinator in coordinators:
switches.extend([OwletBaseSwitch(coordinator, switch) for switch in SWITCHES])
async_add_entities(switches)
class OwletBaseSwitch(OwletBaseEntity, SwitchEntity):
"""Defines a Owlet switch."""
entity_description: OwletSwitchEntityDescription
def __init__(
self,
coordinator: OwletCoordinator,
description: OwletSwitchEntityDescription,
) -> None:
"""Initialize owlet switch platform."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{self.sock.serial}-{description.key}"
self._attr_is_on = False
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and (
not self.sock.properties["charging"]
or self.entity_description.available_during_charging
)
@property
def is_on(self) -> bool:
"""Return if switch is on or off."""
return self.sock.properties[self.entity_description.key]
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the switch."""
await self.entity_description.turn_on_fn(self.sock)(True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the switch."""
await self.entity_description.turn_off_fn(self.sock)(False)

View File

@@ -1,39 +1,123 @@
{
"config": {
"step": {
"user":{
"title": "Enter login details",
"data":{
"region": "Region",
"username": "Email",
"password": "Password"
"config": {
"abort": {
"already_configured": "Device already configured",
"reauth_successful": "Reauthentication successful"
},
"error": {
"cannot_connect": "Failed to connect",
"invalid_credentials": "Entered credentials are incorrect",
"invalid_email": "Entered email address is incorrect",
"invalid_password": "Entered password is incorrect",
"unknown": "Unknown error occured"
},
"step": {
"reauth_confirm": {
"data": {
"password": "Password"
},
"title": "Reauthentiaction required for Owlet"
},
"user": {
"data": {
"password": "Password",
"region": "Region",
"username": "Email"
}
}
}
},
"reauth_confirm":{
"title": "Reauthentiaction required for Owlet",
"data":{
"password": "Password"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_email": "Entered email address is incorrect",
"invalid_password": "Entered password is incorrect",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"options": {
"step": {
"init":{
"title":"Configure options for Owlet",
"data":{
"pollinterval": "Polling interval in seconds, min 10"
"entity": {
"binary_sensor": {
"awake": {
"name": "Awake"
},
"charging": {
"name": "Charging"
},
"crit_batt_alrt": {
"name": "Critical battery alert"
},
"crit_ox_alrt": {
"name": "Critical oxygen alert"
},
"high_hr_alrt": {
"name": "High heart rate alert"
},
"high_ox_alrt": {
"name": "High oxygen alert"
},
"lost_pwr_alrt": {
"name": "Lost power alert"
},
"low_batt_alrt": {
"name": "Low battery alert"
},
"low_hr_alrt": {
"name": "Low heart rate alert"
},
"low_ox_alrt": {
"name": "Low oxygen alert"
},
"sock_discon_alrt": {
"name": "Sock disconnected alert"
},
"sock_off": {
"name": "Sock off"
}
},
"sensor": {
"batterymin": {
"name": "Battery remaining"
},
"batterypercent": {
"name": "Battery percentage"
},
"heartrate": {
"name": "Heart rate"
},
"movement": {
"name": "Movement"
},
"movementbucket": {
"name": "Movement bucket"
},
"o2saturation": {
"name": "O2 saturation"
},
"o2saturation10a": {
"name": "O2 saturation 10 minute average"
},
"signalstrength": {
"name": "Signal strength"
},
"skintemp": {
"name": "Skin temperature"
},
"sleepstate": {
"name": "Sleep state",
"state": {
"awake": "Awake",
"deep_sleep": "Deep sleep",
"light_sleep": "Light sleep",
"unknown": "Unknown"
}
}
},
"switch": {
"base_on": {
"name": "Base station on"
}
}
},
"options": {
"step": {
"init": {
"data": {
"pollinterval": "Polling interval in seconds, min 10"
},
"title": "Configure options for Owlet"
}
}
}
}
}
}

View File

@@ -0,0 +1,123 @@
{
"config": {
"step": {
"user": {
"data": {
"region": "Région",
"username": "Email",
"password": "Mot de passe"
}
},
"reauth_confirm": {
"title": "Réauthentification requise pour Owlet",
"data": {
"password": "Mot de passe"
}
}
},
"error": {
"cannot_connect": "N'a pas réussi à se connecter",
"invalid_email": "L'adresse e-mail saisie est incorrecte",
"invalid_password": "Le mot de passe saisi est incorrect",
"invalid_credentials": "Les informations d'identification saisies sont incorrectes",
"unknown": "Une erreur inconnue est survenue"
},
"abort": {
"already_configured": "Appareil déjà configuré",
"reauth_successful": "Réauthentification réussie"
}
},
"options": {
"step": {
"init": {
"title": "Configurer les options pour Owlet",
"data": {
"pollinterval": "Intervalle de sondage en secondes, minimum 10"
}
}
}
},
"entity": {
"binary_sensor": {
"charging": {
"name": "En charge"
},
"high_hr_alrt": {
"name": "Alerte fréquence cardiaque élevée"
},
"low_hr_alrt": {
"name": "Alerte fréquence cardiaque basse"
},
"high_ox_alrt": {
"name": "Alerte oxygène élevé"
},
"low_ox_alrt": {
"name": "Alerte oxygène faible"
},
"crit_ox_alrt": {
"name": "Alerte oxygène critique"
},
"low_batt_alrt": {
"name": "Alerte batterie faible"
},
"crit_batt_alrt": {
"name": "Alerte batterie critique"
},
"lost_pwr_alrt": {
"name": "Alerte perte d'alimentation"
},
"sock_discon_alrt": {
"name": "Alerte chaussette déconnectée"
},
"sock_off": {
"name": "Chaussette retirée"
},
"awake": {
"name": "Réveillé"
}
},
"sensor": {
"batterypercent": {
"name": "Pourcentage de batterie"
},
"signalstrength": {
"name": "Force du signal"
},
"o2saturation": {
"name": "Saturation O2"
},
"o2saturation10a": {
"name": "Moyenne de saturation O2 sur 10 minutes"
},
"heartrate": {
"name": "Fréquence cardiaque"
},
"batterymin": {
"name": "Autonomie de la batterie restante"
},
"skintemp": {
"name": "Température de la peau"
},
"sleepstate": {
"name": "État de sommeil",
"state": {
"unknown": "Inconnu",
"awake": "Réveillé",
"light_sleep": "Sommeil léger",
"deep_sleep": "Sommeil profond"
}
},
"movement": {
"name": "Mouvement"
},
"movementbucket": {
"name": "Seuil de mouvement"
}
},
"switch": {
"base_on": {
"name": "Station de base allumée"
}
}
}
}

View File

@@ -1,39 +1,123 @@
{
"config": {
"step": {
"user":{
"title": "Enter login details",
"data":{
"user": {
"data": {
"region": "Region",
"username": "Email",
"password": "Password"
}
},
"reauth_confirm":{
"reauth_confirm": {
"title": "Reauthentiaction required for Owlet",
"data":{
"data": {
"password": "Password"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"cannot_connect": "Failed to connect",
"invalid_email": "Entered email address is incorrect",
"invalid_password": "Entered password is incorrect",
"unknown": "[%key:common::config_flow::error::unknown%]"
"invalid_credentials": "Entered credentials are incorrect",
"unknown": "Unknown error occured"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
"already_configured": "Device already configured",
"reauth_successful": "Reauthentication successful"
}
},
"options": {
"step": {
"init":{
"title":"Configure options for Owlet",
"data":{
"init": {
"title": "Configure options for Owlet",
"data": {
"pollinterval": "Polling interval in seconds, min 10"
}
}
}
},
"entity": {
"binary_sensor": {
"charging": {
"name": "Charging"
},
"high_hr_alrt": {
"name": "High heart rate alert"
},
"low_hr_alrt": {
"name": "Low heart rate alert"
},
"high_ox_alrt": {
"name": "High oxygen alert"
},
"low_ox_alrt": {
"name": "Low oxygen alert"
},
"crit_ox_alrt": {
"name": "Critical oxygen alert"
},
"low_batt_alrt": {
"name": "Low battery alert"
},
"crit_batt_alrt": {
"name": "Critical battery alert"
},
"lost_pwr_alrt": {
"name": "Lost power alert"
},
"sock_discon_alrt": {
"name": "Sock disconnected alert"
},
"sock_off": {
"name": "Sock off"
},
"awake": {
"name": "Awake"
}
},
"sensor": {
"batterypercent": {
"name": "Battery percentage"
},
"signalstrength": {
"name": "Signal strength"
},
"o2saturation": {
"name": "O2 saturation"
},
"o2saturation10a": {
"name": "O2 saturation 10 minute average"
},
"heartrate": {
"name": "Heart rate"
},
"batterymin": {
"name": "Battery remaining"
},
"skintemp": {
"name": "Skin temperature"
},
"sleepstate": {
"name": "Sleep state",
"state": {
"unknown": "Unknown",
"awake": "Awake",
"light_sleep": "Light sleep",
"deep_sleep": "Deep sleep"
}
},
"movement": {
"name": "Movement"
},
"movementbucket": {
"name": "Movement bucket"
}
},
"switch": {
"base_on": {
"name": "Base station on"
}
}
}
}

View File

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

View File

@@ -8,9 +8,7 @@
[![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.
A custom component for the Owlet smart sock
## Installation
@@ -34,4 +32,4 @@ If you have a smart sock 2 and would like to contribute then please do so.
[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
[add-integration-badge]: https://my.home-assistant.io/badges/config_flow_start.svg

60
tests/__init__.py Normal file
View File

@@ -0,0 +1,60 @@
"""Tests for the Owlet integration."""
from __future__ import annotations
import json
from unittest.mock import patch
from homeassistant.components.owlet.const import (
CONF_OWLET_EXPIRY,
CONF_OWLET_REFRESH,
DOMAIN,
POLLING_INTERVAL,
)
from homeassistant.const import (
CONF_API_TOKEN,
CONF_REGION,
CONF_SCAN_INTERVAL,
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry, load_fixture
async def async_init_integration(
hass: HomeAssistant,
skip_setup: bool = False,
properties_fixture: str = "update_properties_charging.json",
devices_fixture: str = "get_devices.json",
) -> MockConfigEntry:
"""Set up integration entry."""
entry = MockConfigEntry(
domain=DOMAIN,
title="sample@gmail.com",
unique_id="sample@gmail.com",
data={
CONF_REGION: "europe",
CONF_USERNAME: "sample@gmail.com",
CONF_API_TOKEN: "api_token",
CONF_OWLET_EXPIRY: 100,
CONF_OWLET_REFRESH: "refresh_token",
},
options={CONF_SCAN_INTERVAL: POLLING_INTERVAL},
)
entry.add_to_hass(hass)
if not skip_setup:
with patch(
"homeassistant.components.owlet.OwletAPI.get_properties",
return_value=json.loads(load_fixture(properties_fixture, "owlet")),
), patch(
"homeassistant.components.owlet.OwletAPI.authenticate", return_value=None
), patch(
"homeassistant.components.owlet.OwletAPI.get_devices",
return_value=json.loads(load_fixture(devices_fixture, "owlet")),
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
return entry

14
tests/const.py Normal file
View File

@@ -0,0 +1,14 @@
"""Constants used for Owlet tests."""
AUTH_RETURN = {
"api_token": "api_token",
"expiry": 100,
"refresh": "refresh_token",
}
CONF_INPUT = {
"region": "europe",
"username": "sample@gmail.com",
"password": "sample",
}

32
tests/fixtures/get_devices.json vendored Normal file
View File

@@ -0,0 +1,32 @@
{
"response": [
{
"device": {
"product_name": "Owlet Baby Monitors",
"model": "AY001MTL1",
"dsn": "SERIAL_NUMBER",
"oem_model": "SS3-OBL-EU",
"sw_version": "bc 2.9.7-beta 05/01/19 16:15:18 ID beb2858",
"template_id": 48775,
"mac": "MAC",
"unique_hardware_id": "None",
"hwsig": "123-123-123",
"lan_ip": "192.0.0.1",
"connected_at": "2023-05-23T01:21:29Z",
"key": 123456,
"lan_enabled": false,
"connection_priority": [],
"has_properties": true,
"product_class": "None",
"connection_status": "Online",
"lat": "0.0",
"lng": "0.0",
"locality": "QW12",
"device_type": "Wifi",
"dealer": "None",
"manuf_model": "LBEE5PA1LD-222"
}
}
],
"tokens": ""
}

View File

@@ -0,0 +1,36 @@
{
"response": [
{
"device": {
"product_name": "Owlet Baby Monitors",
"model": "AY001MTL1",
"dsn": "SERIAL_NUMBER",
"oem_model": "SS3-OBL-EU",
"sw_version": "bc 2.9.7-beta 05/01/19 16:15:18 ID beb2858",
"template_id": 48775,
"mac": "MAC",
"unique_hardware_id": "None",
"hwsig": "123-123-123",
"lan_ip": "192.0.0.1",
"connected_at": "2023-05-23T01:21:29Z",
"key": 123456,
"lan_enabled": false,
"connection_priority": [],
"has_properties": true,
"product_class": "None",
"connection_status": "Online",
"lat": "0.0",
"lng": "0.0",
"locality": "QW12",
"device_type": "Wifi",
"dealer": "None",
"manuf_model": "LBEE5PA1LD-222"
}
}
],
"tokens": {
"api_token": "new_api_token",
"expiry": 200,
"refresh": "new_refresh_token"
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1211
tests/fixtures/update_properties_v2.json vendored Normal file

File diff suppressed because it is too large Load Diff

188
tests/test_binary_sensor.py Normal file
View File

@@ -0,0 +1,188 @@
"""Test Owlet Sensor."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from . import async_init_integration
async def test_sensors_asleep(hass: HomeAssistant) -> None:
"""Test sensor values."""
await async_init_integration(
hass, properties_fixture="update_properties_asleep.json"
)
assert len(hass.states.async_all("binary_sensor")) == 10
assert hass.states.get("binary_sensor.owlet_baby_care_sock_charging").state == "off"
assert (
hass.states.get(
"binary_sensor.owlet_baby_care_sock_high_heart_rate_alert"
).state
== "off"
)
assert (
hass.states.get("binary_sensor.owlet_baby_care_sock_low_heart_rate_alert").state
== "off"
)
assert (
hass.states.get("binary_sensor.owlet_baby_care_sock_high_oxygen_alert").state
== "off"
)
assert (
hass.states.get("binary_sensor.owlet_baby_care_sock_low_oxygen_alert").state
== "off"
)
assert (
hass.states.get("binary_sensor.owlet_baby_care_sock_low_battery_alert").state
== "off"
)
assert (
hass.states.get("binary_sensor.owlet_baby_care_sock_lost_power_alert").state
== "off"
)
assert (
hass.states.get(
"binary_sensor.owlet_baby_care_sock_sock_disconnected_alert"
).state
== "off"
)
assert hass.states.get("binary_sensor.owlet_baby_care_sock_sock_off").state == "off"
assert hass.states.get("binary_sensor.owlet_baby_care_sock_awake").state == "off"
async def test_sensors_awake(hass: HomeAssistant) -> None:
"""Test sensor values."""
await async_init_integration(
hass, properties_fixture="update_properties_awake.json"
)
assert len(hass.states.async_all("binary_sensor")) == 10
assert hass.states.get("binary_sensor.owlet_baby_care_sock_charging").state == "off"
assert (
hass.states.get(
"binary_sensor.owlet_baby_care_sock_high_heart_rate_alert"
).state
== "on"
)
assert (
hass.states.get("binary_sensor.owlet_baby_care_sock_low_heart_rate_alert").state
== "on"
)
assert (
hass.states.get("binary_sensor.owlet_baby_care_sock_high_oxygen_alert").state
== "on"
)
assert (
hass.states.get("binary_sensor.owlet_baby_care_sock_low_oxygen_alert").state
== "on"
)
assert (
hass.states.get("binary_sensor.owlet_baby_care_sock_low_battery_alert").state
== "on"
)
assert (
hass.states.get("binary_sensor.owlet_baby_care_sock_lost_power_alert").state
== "on"
)
assert (
hass.states.get(
"binary_sensor.owlet_baby_care_sock_sock_disconnected_alert"
).state
== "off"
)
assert hass.states.get("binary_sensor.owlet_baby_care_sock_sock_off").state == "off"
assert hass.states.get("binary_sensor.owlet_baby_care_sock_awake").state == "on"
async def test_sensors_charging(hass: HomeAssistant) -> None:
"""Test sensor values."""
await async_init_integration(
hass, properties_fixture="update_properties_charging.json"
)
assert len(hass.states.async_all("binary_sensor")) == 10
assert hass.states.get("binary_sensor.owlet_baby_care_sock_charging").state == "on"
assert (
hass.states.get(
"binary_sensor.owlet_baby_care_sock_high_heart_rate_alert"
).state
== "off"
)
assert (
hass.states.get("binary_sensor.owlet_baby_care_sock_low_heart_rate_alert").state
== "off"
)
assert (
hass.states.get("binary_sensor.owlet_baby_care_sock_high_oxygen_alert").state
== "off"
)
assert (
hass.states.get("binary_sensor.owlet_baby_care_sock_low_oxygen_alert").state
== "off"
)
assert (
hass.states.get("binary_sensor.owlet_baby_care_sock_low_battery_alert").state
== "off"
)
assert (
hass.states.get("binary_sensor.owlet_baby_care_sock_lost_power_alert").state
== "off"
)
assert (
hass.states.get(
"binary_sensor.owlet_baby_care_sock_sock_disconnected_alert"
).state
== "off"
)
assert hass.states.get("binary_sensor.owlet_baby_care_sock_sock_off").state == "off"
assert (
hass.states.get("binary_sensor.owlet_baby_care_sock_awake").state == "unknown"
)
async def test_sensors_v2(hass: HomeAssistant) -> None:
"""Test sensor values."""
await async_init_integration(hass, properties_fixture="update_properties_v2.json")
assert len(hass.states.async_all("binary_sensor")) == 9
assert hass.states.get("binary_sensor.owlet_baby_care_sock_charging").state == "off"
assert (
hass.states.get(
"binary_sensor.owlet_baby_care_sock_high_heart_rate_alert"
).state
== "off"
)
assert (
hass.states.get("binary_sensor.owlet_baby_care_sock_low_heart_rate_alert").state
== "off"
)
assert (
hass.states.get("binary_sensor.owlet_baby_care_sock_low_oxygen_alert").state
== "off"
)
assert (
hass.states.get("binary_sensor.owlet_baby_care_sock_low_battery_alert").state
== "off"
)
assert (
hass.states.get(
"binary_sensor.owlet_baby_care_sock_critical_battery_alert"
).state
== "off"
)
assert (
hass.states.get(
"binary_sensor.owlet_baby_care_sock_critical_oxygen_alert"
).state
== "off"
)
assert (
hass.states.get(
"binary_sensor.owlet_baby_care_sock_sock_disconnected_alert"
).state
== "off"
)
assert hass.states.get("binary_sensor.owlet_baby_care_sock_sock_off").state == "off"

237
tests/test_config_flow.py Normal file
View File

@@ -0,0 +1,237 @@
"""Test Owlet config flow."""
from __future__ import annotations
from unittest.mock import patch
from pyowletapi.exceptions import (
OwletCredentialsError,
OwletDevicesError,
OwletEmailError,
OwletPasswordError,
)
from homeassistant import config_entries
from homeassistant.components.owlet.const import DOMAIN, POLLING_INTERVAL
from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER
from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from . import async_init_integration
from .const import AUTH_RETURN, CONF_INPUT
async def test_form(hass: HomeAssistant) -> None:
"""Test that the form is served with no input."""
# await async_init_integration(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
with patch(
"homeassistant.components.owlet.config_flow.OwletAPI.authenticate",
return_value=AUTH_RETURN,
), patch(
"homeassistant.components.owlet.config_flow.OwletAPI.validate_authentication"
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=CONF_INPUT,
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == "sample@gmail.com"
assert result["data"] == {
"region": "europe",
"username": "sample@gmail.com",
"api_token": "api_token",
"expiry": 100,
"refresh": "refresh_token",
}
assert result["options"] == {"scan_interval": POLLING_INTERVAL}
async def test_flow_wrong_password(hass: HomeAssistant) -> None:
"""Test incorrect login throwing error."""
with patch(
"homeassistant.components.owlet.config_flow.OwletAPI.authenticate",
side_effect=OwletPasswordError(),
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=CONF_INPUT,
)
assert result["type"] == FlowResultType.FORM
assert result["errors"] == {"password": "invalid_password"}
async def test_flow_wrong_email(hass: HomeAssistant) -> None:
"""Test incorrect login throwing error."""
with patch(
"homeassistant.components.owlet.config_flow.OwletAPI.authenticate",
side_effect=OwletEmailError(),
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=CONF_INPUT,
)
assert result["type"] == FlowResultType.FORM
assert result["errors"] == {"username": "invalid_email"}
async def test_flow_credentials_error(hass: HomeAssistant) -> None:
"""Test incorrect login throwing error."""
with patch(
"homeassistant.components.owlet.config_flow.OwletAPI.authenticate",
side_effect=OwletCredentialsError(),
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=CONF_INPUT,
)
assert result["type"] == FlowResultType.FORM
assert result["errors"] == {"base": "invalid_credentials"}
async def test_flow_unknown_error(hass: HomeAssistant) -> None:
"""Test unknown error throwing error."""
with patch(
"homeassistant.components.owlet.config_flow.OwletAPI.authenticate",
side_effect=Exception(),
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=CONF_INPUT,
)
assert result["type"] == FlowResultType.FORM
assert result["errors"] == {"base": "unknown"}
async def test_flow_no_devices(hass: HomeAssistant) -> None:
"""Test unknown error throwing error."""
with patch(
"homeassistant.components.owlet.config_flow.OwletAPI.authenticate"
), patch(
"homeassistant.components.owlet.config_flow.OwletAPI.validate_authentication",
side_effect=OwletDevicesError(),
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=CONF_INPUT,
)
assert result["type"] == FlowResultType.FORM
assert result["errors"] == {"base": "no_devices"}
async def test_reauth_success(hass: HomeAssistant) -> None:
"""Test reauth form."""
entry = await async_init_integration(hass, skip_setup=True)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_REAUTH, "entry_id": entry.entry_id}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
with patch(
"homeassistant.components.owlet.config_flow.OwletAPI.authenticate",
return_value=AUTH_RETURN,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_PASSWORD: "sample"},
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
await hass.config_entries.async_unload(entry.entry_id)
async def test_reauth_invalid_password(hass: HomeAssistant) -> None:
"""Test reauth with invalid password errir."""
entry = await async_init_integration(hass, skip_setup=True)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_REAUTH, "entry_id": entry.entry_id}
)
with patch(
"homeassistant.components.owlet.config_flow.OwletAPI.authenticate",
side_effect=OwletPasswordError(),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_PASSWORD: "sample"}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
assert result["errors"] == {"password": "invalid_password"}
async def test_reauth_unknown_error(hass: HomeAssistant) -> None:
"""Test reauthing with an unknown error."""
entry = await async_init_integration(hass, skip_setup=True)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_REAUTH, "entry_id": entry.entry_id}
)
with patch(
"homeassistant.components.owlet.config_flow.OwletAPI.authenticate",
side_effect=Exception(),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_PASSWORD: "sample"}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
async def test_options_flow(hass: HomeAssistant) -> None:
"""Test that the form is served with no input."""
entry = await async_init_integration(hass, skip_setup=True)
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={CONF_SCAN_INTERVAL: 10},
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == ""
assert result["data"] == {CONF_SCAN_INTERVAL: 10}

74
tests/test_coordinator.py Normal file
View File

@@ -0,0 +1,74 @@
"""Test owlet coordinator."""
from __future__ import annotations
import json
from unittest.mock import patch
from pyowletapi.exceptions import OwletAuthenticationError, OwletConnectionError
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from . import async_init_integration
from tests.common import load_fixture
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
async def test_coordinator_auth_error(hass: HomeAssistant) -> None:
"""Test coordinator setup authentication error."""
entry = await async_init_integration(hass, skip_setup=True)
with patch(
"homeassistant.components.owlet.Sock.update_properties",
side_effect=OwletAuthenticationError(),
), patch(
"homeassistant.components.owlet.OwletAPI.authenticate", return_value=None
), patch(
"homeassistant.components.owlet.OwletAPI.get_devices",
return_value=json.loads(load_fixture("get_devices.json", "owlet")),
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state == ConfigEntryState.SETUP_RETRY
async def test_coordinator_connection_error(hass: HomeAssistant) -> None:
"""Test coordinator setup connection error error."""
entry = await async_init_integration(hass, skip_setup=True)
with patch(
"homeassistant.components.owlet.Sock.update_properties",
side_effect=OwletConnectionError(),
), patch(
"homeassistant.components.owlet.OwletAPI.authenticate", return_value=None
), patch(
"homeassistant.components.owlet.OwletAPI.get_devices",
return_value=json.loads(load_fixture("get_devices.json", "owlet")),
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state == ConfigEntryState.SETUP_RETRY
async def test_coordinator_error(hass: HomeAssistant) -> None:
"""Test coordinator setup generic error."""
entry = await async_init_integration(hass, skip_setup=True)
with patch(
"homeassistant.components.owlet.Sock.update_properties",
side_effect=Exception(),
), patch(
"homeassistant.components.owlet.OwletAPI.authenticate", return_value=None
), patch(
"homeassistant.components.owlet.OwletAPI.get_devices",
return_value=json.loads(load_fixture("get_devices.json", "owlet")),
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state == ConfigEntryState.SETUP_RETRY

136
tests/test_init.py Normal file
View File

@@ -0,0 +1,136 @@
"""Test Owlet init."""
from __future__ import annotations
from unittest.mock import patch
from pyowletapi.exceptions import (
OwletAuthenticationError,
OwletConnectionError,
OwletDevicesError,
OwletError,
)
from homeassistant.components.owlet.const import (
CONF_OWLET_EXPIRY,
CONF_OWLET_REFRESH,
DOMAIN,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_API_TOKEN, CONF_REGION, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from . import async_init_integration
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
async def test_async_setup_entry(hass: HomeAssistant) -> None:
"""Test setting up entry."""
entry = await async_init_integration(hass)
assert entry.state == ConfigEntryState.LOADED
device_registry = dr.async_get(hass)
device_entry = device_registry.async_get_or_create(
config_entry_id=entry.entry_id, identifiers={(DOMAIN, "SERIAL_NUMBER")}
)
assert device_entry.name == "Owlet Baby Care Sock"
entity_registry = er.async_get(hass)
entities = er.async_entries_for_device(entity_registry, device_entry.id)
assert len(entities) == 18
await entry.async_unload(hass)
assert entry.state == ConfigEntryState.NOT_LOADED
async def test_async_setup_entry_new_tokens(hass: HomeAssistant) -> None:
"""Test setting up entry and getting new tokens."""
entry = await async_init_integration(
hass, devices_fixture="get_devices_with_tokens.json"
)
assert entry.data == {
CONF_REGION: "europe",
CONF_USERNAME: "sample@gmail.com",
CONF_API_TOKEN: "new_api_token",
CONF_OWLET_EXPIRY: 200,
CONF_OWLET_REFRESH: "new_refresh_token",
}
assert entry.state == ConfigEntryState.LOADED
await entry.async_unload(hass)
assert entry.state == ConfigEntryState.NOT_LOADED
async def test_async_setup_entry_auth_error(hass: HomeAssistant) -> None:
"""Test setting up entry with auth error."""
entry = await async_init_integration(hass, skip_setup=True)
with patch(
"homeassistant.components.owlet.OwletAPI.authenticate",
side_effect=OwletAuthenticationError(),
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state == ConfigEntryState.SETUP_ERROR
await entry.async_unload(hass)
async def test_async_setup_entry_connection_error(hass: HomeAssistant) -> None:
"""Test setting up entry with connection error."""
entry = await async_init_integration(hass, skip_setup=True)
with patch(
"homeassistant.components.owlet.OwletAPI.authenticate",
side_effect=OwletConnectionError(),
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state == ConfigEntryState.SETUP_RETRY
await entry.async_unload(hass)
async def test_async_setup_entry_devices_error(hass: HomeAssistant) -> None:
"""Test setting up entry with device error."""
entry = await async_init_integration(hass, skip_setup=True)
with patch(
"homeassistant.components.owlet.OwletAPI.authenticate", return_value=None
), patch(
"homeassistant.components.owlet.OwletAPI.get_devices",
side_effect=OwletDevicesError(),
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state == ConfigEntryState.SETUP_ERROR
await entry.async_unload(hass)
async def test_async_setup_entry_error(hass: HomeAssistant) -> None:
"""Test setting up entry with unknown error."""
entry = await async_init_integration(hass, skip_setup=True)
with patch(
"homeassistant.components.owlet.OwletAPI.authenticate",
side_effect=OwletError(),
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state == ConfigEntryState.SETUP_ERROR
await entry.async_unload(hass)

126
tests/test_sensor.py Normal file
View File

@@ -0,0 +1,126 @@
"""Test Owlet Sensor."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from . import async_init_integration
async def test_sensors_asleep(hass: HomeAssistant) -> None:
"""Test sensor values."""
await async_init_integration(
hass, properties_fixture="update_properties_asleep.json"
)
assert len(hass.states.async_all("sensor")) == 8
assert (
hass.states.get("sensor.owlet_baby_care_sock_battery_percentage").state
== "50.0"
)
assert (
hass.states.get("sensor.owlet_baby_care_sock_battery_remaining").state
== "400.0"
)
assert hass.states.get("sensor.owlet_baby_care_sock_heart_rate").state == "97.0"
assert hass.states.get("sensor.owlet_baby_care_sock_o2_saturation").state == "99.0"
assert (
hass.states.get(
"sensor.owlet_baby_care_sock_o2_saturation_10_minute_average"
).state
== "97.0"
)
assert (
hass.states.get("sensor.owlet_baby_care_sock_signal_strength").state == "30.0"
)
assert hass.states.get("sensor.owlet_baby_care_sock_skin_temperature").state == "34"
assert (
hass.states.get("sensor.owlet_baby_care_sock_sleep_state").state
== "light_sleep"
)
async def test_sensors_awake(hass: HomeAssistant) -> None:
"""Test sensor values."""
await async_init_integration(
hass, properties_fixture="update_properties_awake.json"
)
assert len(hass.states.async_all("sensor")) == 8
assert (
hass.states.get("sensor.owlet_baby_care_sock_battery_percentage").state
== "80.0"
)
assert (
hass.states.get("sensor.owlet_baby_care_sock_battery_remaining").state
== "600.0"
)
assert hass.states.get("sensor.owlet_baby_care_sock_heart_rate").state == "110.0"
assert hass.states.get("sensor.owlet_baby_care_sock_o2_saturation").state == "98.0"
assert (
hass.states.get(
"sensor.owlet_baby_care_sock_o2_saturation_10_minute_average"
).state
== "98.0"
)
assert (
hass.states.get("sensor.owlet_baby_care_sock_signal_strength").state == "34.0"
)
assert hass.states.get("sensor.owlet_baby_care_sock_skin_temperature").state == "35"
assert hass.states.get("sensor.owlet_baby_care_sock_sleep_state").state == "awake"
async def test_sensors_charging(hass: HomeAssistant) -> None:
"""Test sensor values."""
await async_init_integration(
hass, properties_fixture="update_properties_charging.json"
)
assert len(hass.states.async_all("sensor")) == 8
assert (
hass.states.get("sensor.owlet_baby_care_sock_battery_percentage").state
== "100.0"
)
assert (
hass.states.get("sensor.owlet_baby_care_sock_battery_remaining").state
== "unknown"
)
assert hass.states.get("sensor.owlet_baby_care_sock_heart_rate").state == "unknown"
assert (
hass.states.get("sensor.owlet_baby_care_sock_o2_saturation").state == "unknown"
)
assert (
hass.states.get(
"sensor.owlet_baby_care_sock_o2_saturation_10_minute_average"
).state
== "unknown"
)
assert (
hass.states.get("sensor.owlet_baby_care_sock_signal_strength").state == "34.0"
)
assert (
hass.states.get("sensor.owlet_baby_care_sock_skin_temperature").state
== "unknown"
)
assert hass.states.get("sensor.owlet_baby_care_sock_sleep_state").state == "unknown"
async def test_sensors_v2(hass: HomeAssistant) -> None:
"""Test sensor values."""
await async_init_integration(hass, properties_fixture="update_properties_v2.json")
assert len(hass.states.async_all("sensor")) == 4
assert (
hass.states.get("sensor.owlet_baby_care_sock_battery_percentage").state == "29"
)
assert hass.states.get("sensor.owlet_baby_care_sock_heart_rate").state == "145"
assert hass.states.get("sensor.owlet_baby_care_sock_o2_saturation").state == "99"
assert (
hass.states.get("sensor.owlet_baby_care_sock_signal_strength").state == "98.0"
)