Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb600e0a97 | |||
|
|
f8e0067a1e | ||
|
|
45e65f384b | ||
|
|
df6b45621e | ||
|
|
1244bffcb4 | ||
|
|
d323cbfd11 | ||
|
|
2accec2b49 | ||
|
|
6b343a76ca | ||
|
|
fa2e06dcf4 | ||
|
|
975e98c337 | ||
|
|
268365ccd4 | ||
|
|
ac9c8c6111 | ||
|
|
4fa40f8621 | ||
|
|
c04d6b7bf8 | ||
|
|
dd17aca283 | ||
|
|
91578464de | ||
|
|
e28b9ddf3e | ||
|
|
f63e0a6dfe | ||
|
|
82823be1c8 | ||
|
|
14787e03c4 | ||
|
|
0991eb31d9 | ||
|
|
ad91a851fc | ||
|
|
339dc43d6d | ||
|
|
f3c853e2d7 | ||
|
|
dfc2ffc0e1 | ||
|
|
dc28ebb02f | ||
|
|
52710ba7de | ||
|
|
d8449c14a4 | ||
|
|
3610262855 | ||
|
|
904023e93a | ||
|
|
ceade24851 | ||
|
|
ab486d5519 | ||
|
|
5e17ecdeb2 | ||
|
|
4b90ce0d61 | ||
|
|
50c55dcfd3 | ||
|
|
faefd0b18b | ||
|
|
1192b833ca | ||
|
|
d440fed621 | ||
|
|
50fe1a8765 | ||
|
|
1cfff537d7 | ||
|
|
3acf847352 | ||
|
|
c0bf404f6a | ||
|
|
0a7f703100 | ||
|
|
cab737cae4 | ||
|
|
092321cbae | ||
|
|
835786b89b | ||
|
|
2cd46c18f8 | ||
|
|
575b213ddd | ||
|
|
3afa43c82c |
90
CHANGELOG.md
90
CHANGELOG.md
@@ -1,6 +1,82 @@
|
||||
# Changelog
|
||||
|
||||
<!--next-version-placeholder-->
|
||||
## 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))
|
||||
@@ -9,11 +85,11 @@
|
||||
### Fix
|
||||
* Bumping pyowletapi to 2023.7.1 ([`c693fef`](https://github.com/ryanbdclark/owlet/commit/c693fefbf3dba8f35802b87d064401dadbb211b5))
|
||||
|
||||
## 2023.05.7 (2023-05-30)
|
||||
## 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.05.6 (2023-05-30)
|
||||
## 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
|
||||
@@ -23,25 +99,25 @@
|
||||
### Feature
|
||||
* Tests added
|
||||
|
||||
## 2023.05.5 (2023-05-19)
|
||||
## 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.05.4 (2023-05-17)
|
||||
## 2023.5.4 (2023-05-17)
|
||||
#### Fix
|
||||
* Bumping to pyowletapi 2023.5.25
|
||||
|
||||
## 2023.05.3 (2023-05-17)
|
||||
## 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""The Owlet Smart Sock integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
@@ -29,7 +30,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
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__)
|
||||
|
||||
@@ -67,20 +68,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
_LOGGER.error("No owlet devices found to set up")
|
||||
return False
|
||||
|
||||
if devices["tokens"]:
|
||||
if "tokens" in devices:
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, data={**entry.data, **devices["tokens"]}
|
||||
)
|
||||
|
||||
socks = {
|
||||
device["device"]["dsn"]: Sock(owlet_api, device["device"])
|
||||
for device in devices["response"]
|
||||
}
|
||||
|
||||
scan_interval = entry.options.get(CONF_SCAN_INTERVAL)
|
||||
coordinators = {
|
||||
serial: OwletCoordinator(hass, sock, scan_interval)
|
||||
for (serial, sock) in socks.items()
|
||||
device["device"]["dsn"]: OwletCoordinator(
|
||||
hass, Sock(owlet_api, device["device"]), scan_interval, entry
|
||||
)
|
||||
for device in devices["response"]
|
||||
}
|
||||
|
||||
await asyncio.gather(
|
||||
|
||||
@@ -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",
|
||||
translation_key="charging",
|
||||
device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
|
||||
element="charging",
|
||||
available_during_charging=True,
|
||||
),
|
||||
OwletBinarySensorEntityDescription(
|
||||
key="highhr",
|
||||
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",
|
||||
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",
|
||||
key="high_oxygen_alert",
|
||||
translation_key="high_ox_alrt",
|
||||
device_class=BinarySensorDeviceClass.SOUND,
|
||||
element="high_oxygen_alert",
|
||||
available_during_charging=True,
|
||||
),
|
||||
OwletBinarySensorEntityDescription(
|
||||
key="lowo2",
|
||||
key="low_oxygen_alert",
|
||||
translation_key="low_ox_alrt",
|
||||
device_class=BinarySensorDeviceClass.SOUND,
|
||||
element="low_oxygen_alert",
|
||||
available_during_charging=True,
|
||||
),
|
||||
OwletBinarySensorEntityDescription(
|
||||
key="lowbattery",
|
||||
key="critical_oxygen_alert",
|
||||
translation_key="crit_ox_alrt",
|
||||
device_class=BinarySensorDeviceClass.SOUND,
|
||||
available_during_charging=True,
|
||||
),
|
||||
OwletBinarySensorEntityDescription(
|
||||
key="low_battery_alert",
|
||||
translation_key="low_batt_alrt",
|
||||
device_class=BinarySensorDeviceClass.SOUND,
|
||||
element="low_battery_alert",
|
||||
available_during_charging=True,
|
||||
),
|
||||
OwletBinarySensorEntityDescription(
|
||||
key="lostpower",
|
||||
key="critical_battery_alert",
|
||||
translation_key="crit_batt_alrt",
|
||||
device_class=BinarySensorDeviceClass.SOUND,
|
||||
available_during_charging=True,
|
||||
),
|
||||
OwletBinarySensorEntityDescription(
|
||||
key="lost_power_alert",
|
||||
translation_key="lost_pwr_alrt",
|
||||
device_class=BinarySensorDeviceClass.SOUND,
|
||||
element="lost_power_alert",
|
||||
available_during_charging=True,
|
||||
),
|
||||
OwletBinarySensorEntityDescription(
|
||||
key="sockdisconnected",
|
||||
key="sock_disconnected",
|
||||
translation_key="sock_discon_alrt",
|
||||
device_class=BinarySensorDeviceClass.SOUND,
|
||||
element="sock_disconnected",
|
||||
available_during_charging=True,
|
||||
),
|
||||
OwletBinarySensorEntityDescription(
|
||||
key="sock_off",
|
||||
translation_key="sock_off",
|
||||
device_class=BinarySensorDeviceClass.POWER,
|
||||
element="sock_off",
|
||||
),
|
||||
OwletBinarySensorEntityDescription(
|
||||
key="awake",
|
||||
translation_key="awake",
|
||||
element="sleep_state",
|
||||
icon="mdi:sleep",
|
||||
available_during_charging=True,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -104,11 +104,18 @@ async def async_setup_entry(
|
||||
|
||||
coordinators: OwletCoordinator = hass.data[DOMAIN][config_entry.entry_id].values()
|
||||
|
||||
async_add_entities(
|
||||
OwletBinarySensor(coordinator, sensor)
|
||||
for coordinator in coordinators
|
||||
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
|
||||
])
|
||||
|
||||
if OwletAwakeSensor.entity_description.key in coordinator.sock.properties:
|
||||
sensors.append(OwletAwakeSensor(coordinator))
|
||||
|
||||
async_add_entities(sensors)
|
||||
|
||||
|
||||
class OwletBinarySensor(OwletBaseEntity, BinarySensorEntity):
|
||||
@@ -117,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.translation_key}"
|
||||
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]
|
||||
|
||||
217
custom_components/owlet/camera.py
Normal file
217
custom_components/owlet/camera.py
Normal 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
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Config flow for Owlet Smart Sock integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
@@ -12,31 +13,28 @@ from pyowletapi.exceptions import (
|
||||
OwletEmailError,
|
||||
OwletPasswordError,
|
||||
)
|
||||
from pyowletapi.sock import Sock
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries, exceptions
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigFlowResult
|
||||
from homeassistant.const import (
|
||||
CONF_API_TOKEN,
|
||||
CONF_PASSWORD,
|
||||
CONF_REGION,
|
||||
CONF_SCAN_INTERVAL,
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import CONF_OWLET_EXPIRY, CONF_OWLET_REFRESH, DOMAIN, POLLING_INTERVAL
|
||||
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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -45,34 +43,25 @@ 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:
|
||||
"""Initialise config flow."""
|
||||
self._entry: ConfigEntry
|
||||
self._region: str
|
||||
self._username: str
|
||||
self._password: str
|
||||
self._devices: dict[str, Sock]
|
||||
self.reauth_entry: ConfigEntry | None = None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> 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(
|
||||
region=self._region,
|
||||
user=self._username,
|
||||
password=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:
|
||||
@@ -82,9 +71,9 @@ class OwletConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
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
|
||||
@@ -92,13 +81,11 @@ class OwletConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=self._username,
|
||||
title=user_input[CONF_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],
|
||||
CONF_REGION: user_input[CONF_REGION],
|
||||
CONF_USERNAME: user_input[CONF_USERNAME],
|
||||
**token,
|
||||
},
|
||||
options={CONF_SCAN_INTERVAL: POLLING_INTERVAL},
|
||||
)
|
||||
@@ -113,7 +100,9 @@ class OwletConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Get the options flow for this handler."""
|
||||
return OptionsFlowHandler(config_entry)
|
||||
|
||||
async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult:
|
||||
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"]
|
||||
@@ -122,7 +111,7 @@ class OwletConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
) -> ConfigFlowResult:
|
||||
"""Dialog that informs the user that reauth is required."""
|
||||
assert self.reauth_entry is not None
|
||||
errors: dict[str, str] = {}
|
||||
@@ -136,18 +125,19 @@ 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 OwletPasswordError:
|
||||
errors["base"] = "invalid_password"
|
||||
errors[CONF_PASSWORD] = "invalid_password"
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Error reauthenticating")
|
||||
|
||||
@@ -167,7 +157,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
) -> 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)),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -15,10 +15,9 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_EMAIL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, MANUFACTURER
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -26,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) -> None:
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, sock: Sock, interval, entry: ConfigEntry
|
||||
) -> None:
|
||||
"""Initialise a custom coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
@@ -34,23 +35,14 @@ class OwletCoordinator(DataUpdateCoordinator):
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(seconds=interval),
|
||||
)
|
||||
assert self.config_entry is not None
|
||||
self.config_entry: ConfigEntry
|
||||
self.sock = sock
|
||||
self.device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, sock.serial)},
|
||||
name="Owlet Baby Care Sock",
|
||||
manufacturer=MANUFACTURER,
|
||||
model=sock.model,
|
||||
sw_version=sock.sw_version,
|
||||
hw_version=sock.version,
|
||||
)
|
||||
self.config_entry: ConfigEntry = entry
|
||||
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Fetch the data from the device."""
|
||||
try:
|
||||
properties = await self.sock.update_properties()
|
||||
if properties["tokens"]:
|
||||
if "tokens" in properties:
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self.config_entry,
|
||||
data={**self.config_entry.data, **properties["tokens"]},
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
213
custom_components/owlet/kinesis_client.py
Normal file
213
custom_components/owlet/kinesis_client.py
Normal 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
|
||||
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"domain": "owlet",
|
||||
"name": "Owlet Smart Sock",
|
||||
"codeowners": ["@ryanbdclark"],
|
||||
"codeowners": [
|
||||
"@ryanbdclark"
|
||||
],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/owlet",
|
||||
"documentation":"https://github.com/ryanbdclark/owlet",
|
||||
"iot_class": "cloud_polling",
|
||||
"issue_tracker": "https://github.com/ryanbdclark/owlet/issues",
|
||||
"requirements": ["pyowletapi==2023.7.2"],
|
||||
"version":"2023.7.2"
|
||||
"requirements": [
|
||||
"pyowletapi==2025.4.1"
|
||||
],
|
||||
"version": "2025.4.3"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Support for Owlet sensors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
@@ -25,76 +26,77 @@ 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",
|
||||
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",
|
||||
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",
|
||||
translation_key="o2saturation10a",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
element="oxygen_10_av",
|
||||
icon="mdi:leaf",
|
||||
),
|
||||
OwletSensorEntityDescription(
|
||||
key="heartrate",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -110,15 +112,24 @@ async def async_setup_entry(
|
||||
hass.data[DOMAIN][config_entry.entry_id].values()
|
||||
)
|
||||
|
||||
async_add_entities(
|
||||
OwletSensor(coordinator, sensor)
|
||||
for coordinator in coordinators
|
||||
for sensor in SENSORS
|
||||
)
|
||||
sensors = []
|
||||
|
||||
async_add_entities(
|
||||
OwletSleepStateSensor(coordinator) for coordinator in coordinators
|
||||
)
|
||||
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):
|
||||
@@ -127,60 +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: OwletSensorEntityDescription = sensor_description
|
||||
self._attr_unique_id = (
|
||||
f"{self.sock.serial}-{self.entity_description.translation_key}"
|
||||
self.entity_description: OwletSensorEntityDescription = 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 native_value(self) -> StateType:
|
||||
"""Return sensor value."""
|
||||
|
||||
if (
|
||||
self.entity_description.element
|
||||
in [
|
||||
"heart_rate",
|
||||
"battery_minutes",
|
||||
"oxygen_saturation",
|
||||
"skin_temperature",
|
||||
"oxygen_10_av",
|
||||
]
|
||||
and self.sock.properties["charging"]
|
||||
):
|
||||
return None
|
||||
|
||||
properties = self.sock.properties
|
||||
|
||||
return 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}-sleepstate"
|
||||
self._attr_icon = "mdi:sleep"
|
||||
self._attr_device_class = SensorDeviceClass.ENUM
|
||||
self._attr_translation_key = "sleepstate"
|
||||
super().__init__(coordinator, self.entity_description)
|
||||
|
||||
@property
|
||||
def native_value(self) -> str:
|
||||
def native_value(self) -> StateType:
|
||||
"""Return sensor value."""
|
||||
if self.sock.properties["charging"]:
|
||||
return "unknown"
|
||||
|
||||
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]:
|
||||
"""Set options for sleep state."""
|
||||
return list(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
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2,17 +2,16 @@
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Enter login details",
|
||||
"data": {
|
||||
"region": "Region",
|
||||
"username": "Email",
|
||||
"password": "Password"
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "Reauthentiaction required for Owlet",
|
||||
"data": {
|
||||
"password": "Password"
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -39,69 +38,86 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor":{
|
||||
"binary_sensor": {
|
||||
"charging": {
|
||||
"name": "Charging"
|
||||
},
|
||||
"high_hr_alrt":{
|
||||
"name": "High Heart Rate Alert"
|
||||
"high_hr_alrt": {
|
||||
"name": "High heart rate alert"
|
||||
},
|
||||
"low_hr_alrt":{
|
||||
"name":"Low Heart Rate Alert"
|
||||
"low_hr_alrt": {
|
||||
"name": "Low heart rate alert"
|
||||
},
|
||||
"high_ox_alrt":{
|
||||
"name":"High Oxygen Alert"
|
||||
"high_ox_alrt": {
|
||||
"name": "High oxygen alert"
|
||||
},
|
||||
"low_ox_alrt":{
|
||||
"name":"Low Oxygen Alert"
|
||||
"low_ox_alrt": {
|
||||
"name": "Low oxygen alert"
|
||||
},
|
||||
"low_batt_alrt":{
|
||||
"name": "Low Battery Alert"
|
||||
"crit_ox_alrt": {
|
||||
"name": "Critical oxygen alert"
|
||||
},
|
||||
"lost_pwr_alrt":{
|
||||
"name": "Lost Power Alert"
|
||||
"low_batt_alrt": {
|
||||
"name": "Low battery alert"
|
||||
},
|
||||
"sock_discon_alrt":{
|
||||
"name": "Sock Diconnected Alert"
|
||||
"crit_batt_alrt": {
|
||||
"name": "Critical battery alert"
|
||||
},
|
||||
"sock_off":{
|
||||
"name":"Sock Off"
|
||||
"lost_pwr_alrt": {
|
||||
"name": "Lost power alert"
|
||||
},
|
||||
"awake":{
|
||||
"name":"Awake"
|
||||
"sock_discon_alrt": {
|
||||
"name": "Sock disconnected alert"
|
||||
},
|
||||
"sock_off": {
|
||||
"name": "Sock off"
|
||||
},
|
||||
"awake": {
|
||||
"name": "Awake"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"batterypercent": {
|
||||
"name": "Battery Percentage"
|
||||
},
|
||||
"o2saturation": {
|
||||
"name": "O2 Saturation"
|
||||
},
|
||||
"o2saturation10a": {
|
||||
"name": "O2 Saturation 10 Minute Average"
|
||||
},
|
||||
"heartrate": {
|
||||
"name": "Heart Rate"
|
||||
},
|
||||
"batterymin": {
|
||||
"name": "Battery Remaining"
|
||||
"name": "Battery percentage"
|
||||
},
|
||||
"signalstrength": {
|
||||
"name": "Signal Strength"
|
||||
"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"
|
||||
"name": "Skin temperature"
|
||||
},
|
||||
"sleepstate": {
|
||||
"name": "Sleep State",
|
||||
"name": "Sleep state",
|
||||
"state": {
|
||||
"unknown": "Unknown",
|
||||
"awake": "Awake",
|
||||
"light_sleep": "Light Sleep",
|
||||
"deep_sleep": "Deep Sleep"
|
||||
"light_sleep": "Light sleep",
|
||||
"deep_sleep": "Deep sleep"
|
||||
}
|
||||
},
|
||||
"movement": {
|
||||
"name": "Movement"
|
||||
},
|
||||
"movementbucket": {
|
||||
"name": "Movement bucket"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"base_on": {
|
||||
"name": "Base station on"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
94
custom_components/owlet/switch.py
Normal file
94
custom_components/owlet/switch.py
Normal 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)
|
||||
@@ -1,107 +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",
|
||||
"invalid_credentials": "Entered credentials are incorrect",
|
||||
"unknown": "Unknown error occured"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Device already configured",
|
||||
"reauth_successful": "Reauthentication successful"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"low_batt_alrt":{
|
||||
"name": "Low Battery Alert"
|
||||
},
|
||||
"lost_pwr_alrt":{
|
||||
"name": "Lost Power Alert"
|
||||
},
|
||||
"sock_discon_alrt":{
|
||||
"name": "Sock Diconnected Alert"
|
||||
},
|
||||
"sock_off":{
|
||||
"name":"Sock Off"
|
||||
},
|
||||
"awake":{
|
||||
"name":"Awake"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"batterypercent": {
|
||||
"name": "Battery Percentage"
|
||||
},
|
||||
"o2saturation": {
|
||||
"name": "O2 Saturation"
|
||||
},
|
||||
"o2saturation10a": {
|
||||
"name": "O2 Saturation 10 Minute Average"
|
||||
},
|
||||
"heartrate": {
|
||||
"name": "Heart Rate"
|
||||
},
|
||||
"batterymin": {
|
||||
"name": "Battery Remaining"
|
||||
},
|
||||
"signalstrength": {
|
||||
"name": "Signal Strength"
|
||||
},
|
||||
"skintemp": {
|
||||
"name": "Skin Temperature"
|
||||
},
|
||||
"sleepstate": {
|
||||
"name": "Sleep State",
|
||||
"state": {
|
||||
"unknown": "Unknown",
|
||||
"awake": "Awake",
|
||||
"light_sleep": "Light Sleep",
|
||||
"deep_sleep": "Deep Sleep"
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"pollinterval": "Polling interval in seconds, min 10"
|
||||
},
|
||||
"title": "Configure options for Owlet"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
123
custom_components/owlet/translations/fr.json
Normal file
123
custom_components/owlet/translations/fr.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Enter login details",
|
||||
"data": {
|
||||
"region": "Region",
|
||||
"username": "Email",
|
||||
@@ -17,7 +16,7 @@
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"invalid_credentials": "Entered credentials are incorrect",
|
||||
@@ -39,69 +38,86 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor":{
|
||||
"binary_sensor": {
|
||||
"charging": {
|
||||
"name": "Charging"
|
||||
},
|
||||
"high_hr_alrt":{
|
||||
"name": "High Heart Rate Alert"
|
||||
"high_hr_alrt": {
|
||||
"name": "High heart rate alert"
|
||||
},
|
||||
"low_hr_alrt":{
|
||||
"name":"Low Heart Rate Alert"
|
||||
"low_hr_alrt": {
|
||||
"name": "Low heart rate alert"
|
||||
},
|
||||
"high_ox_alrt":{
|
||||
"name":"High Oxygen Alert"
|
||||
"high_ox_alrt": {
|
||||
"name": "High oxygen alert"
|
||||
},
|
||||
"low_ox_alrt":{
|
||||
"name":"Low Oxygen Alert"
|
||||
"low_ox_alrt": {
|
||||
"name": "Low oxygen alert"
|
||||
},
|
||||
"low_batt_alrt":{
|
||||
"name": "Low Battery Alert"
|
||||
"crit_ox_alrt": {
|
||||
"name": "Critical oxygen alert"
|
||||
},
|
||||
"lost_pwr_alrt":{
|
||||
"name": "Lost Power Alert"
|
||||
"low_batt_alrt": {
|
||||
"name": "Low battery alert"
|
||||
},
|
||||
"sock_discon_alrt":{
|
||||
"name": "Sock Diconnected Alert"
|
||||
"crit_batt_alrt": {
|
||||
"name": "Critical battery alert"
|
||||
},
|
||||
"sock_off":{
|
||||
"name":"Sock Off"
|
||||
"lost_pwr_alrt": {
|
||||
"name": "Lost power alert"
|
||||
},
|
||||
"awake":{
|
||||
"name":"Awake"
|
||||
"sock_discon_alrt": {
|
||||
"name": "Sock disconnected alert"
|
||||
},
|
||||
"sock_off": {
|
||||
"name": "Sock off"
|
||||
},
|
||||
"awake": {
|
||||
"name": "Awake"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"batterypercent": {
|
||||
"name": "Battery Percentage"
|
||||
},
|
||||
"o2saturation": {
|
||||
"name": "O2 Saturation"
|
||||
},
|
||||
"o2saturation10a": {
|
||||
"name": "O2 Saturation 10 Minute Average"
|
||||
},
|
||||
"heartrate": {
|
||||
"name": "Heart Rate"
|
||||
},
|
||||
"batterymin": {
|
||||
"name": "Battery Remaining"
|
||||
"name": "Battery percentage"
|
||||
},
|
||||
"signalstrength": {
|
||||
"name": "Signal Strength"
|
||||
"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"
|
||||
"name": "Skin temperature"
|
||||
},
|
||||
"sleepstate": {
|
||||
"name": "Sleep State",
|
||||
"name": "Sleep state",
|
||||
"state": {
|
||||
"unknown": "Unknown",
|
||||
"awake": "Awake",
|
||||
"light_sleep": "Light Sleep",
|
||||
"deep_sleep": "Deep Sleep"
|
||||
"light_sleep": "Light sleep",
|
||||
"deep_sleep": "Deep sleep"
|
||||
}
|
||||
},
|
||||
"movement": {
|
||||
"name": "Movement"
|
||||
},
|
||||
"movementbucket": {
|
||||
"name": "Movement bucket"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"base_on": {
|
||||
"name": "Base station on"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "Owlet",
|
||||
"hacs": "1.32.1",
|
||||
"homeassistant": "2023.04.1",
|
||||
"homeassistant": "2024.1",
|
||||
"zip_release": true,
|
||||
"filename": "owlet.zip"
|
||||
}
|
||||
|
||||
6
info.md
6
info.md
@@ -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
|
||||
|
||||
@@ -1052,4 +1052,4 @@
|
||||
"expiry": 200,
|
||||
"refresh": "new_refresh_token"
|
||||
}
|
||||
}
|
||||
}
|
||||
1211
tests/fixtures/update_properties_v2.json
vendored
Normal file
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
188
tests/test_binary_sensor.py
Normal 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"
|
||||
@@ -70,7 +70,7 @@ async def test_flow_wrong_password(hass: HomeAssistant) -> None:
|
||||
user_input=CONF_INPUT,
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "invalid_password"}
|
||||
assert result["errors"] == {"password": "invalid_password"}
|
||||
|
||||
|
||||
async def test_flow_wrong_email(hass: HomeAssistant) -> None:
|
||||
@@ -88,7 +88,7 @@ async def test_flow_wrong_email(hass: HomeAssistant) -> None:
|
||||
user_input=CONF_INPUT,
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "invalid_email"}
|
||||
assert result["errors"] == {"username": "invalid_email"}
|
||||
|
||||
|
||||
async def test_flow_credentials_error(hass: HomeAssistant) -> None:
|
||||
@@ -193,7 +193,7 @@ async def test_reauth_invalid_password(hass: HomeAssistant) -> None:
|
||||
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert result["errors"] == {"base": "invalid_password"}
|
||||
assert result["errors"] == {"password": "invalid_password"}
|
||||
|
||||
|
||||
async def test_reauth_unknown_error(hass: HomeAssistant) -> None:
|
||||
|
||||
@@ -43,7 +43,7 @@ async def test_async_setup_entry(hass: HomeAssistant) -> None:
|
||||
|
||||
entities = er.async_entries_for_device(entity_registry, device_entry.id)
|
||||
|
||||
assert len(entities) == 8
|
||||
assert len(entities) == 18
|
||||
|
||||
await entry.async_unload(hass)
|
||||
|
||||
|
||||
@@ -107,3 +107,20 @@ async def test_sensors_charging(hass: HomeAssistant) -> None:
|
||||
== "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"
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user