25 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
17 changed files with 818 additions and 129 deletions

View File

@@ -1,6 +1,34 @@
# 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))

View File

@@ -12,7 +12,7 @@ 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".
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".

View File

@@ -30,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__)
@@ -68,7 +68,7 @@ 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"]}
)

View File

@@ -92,12 +92,6 @@ SENSORS: tuple[OwletBinarySensorEntityDescription, ...] = (
device_class=BinarySensorDeviceClass.POWER,
available_during_charging=True,
),
OwletBinarySensorEntityDescription(
key="base_station_on",
translation_key="base_on",
device_class=BinarySensorDeviceClass.POWER,
available_during_charging=True,
),
)
@@ -112,9 +106,11 @@ async def async_setup_entry(
sensors = []
for coordinator in coordinators:
for sensor in SENSORS:
if sensor.key in coordinator.sock.properties:
sensors.append(OwletBinarySensor(coordinator, sensor))
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))
@@ -146,13 +142,6 @@ class OwletBinarySensor(OwletBaseEntity, BinarySensorEntity):
@property
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
if self.entity_description.key == "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]
@@ -177,4 +166,4 @@ class OwletAwakeSensor(OwletBinarySensor):
@property
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
return False if self.sock.properties[self.entity_description.key] in [8, 15] else True
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,4 +1,5 @@
"""Config flow for Owlet Smart Sock integration."""
from __future__ import annotations
from collections.abc import Mapping
@@ -15,19 +16,17 @@ from pyowletapi.exceptions import (
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__)
@@ -51,7 +50,7 @@ class OwletConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
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:
@@ -101,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"]
@@ -110,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] = {}
@@ -124,13 +125,14 @@ 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")
@@ -155,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)

View File

@@ -42,7 +42,7 @@ class OwletCoordinator(DataUpdateCoordinator):
"""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"]},

View File

@@ -1,11 +1,11 @@
"""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 homeassistant.helpers.device_registry import DeviceInfo
from .coordinator import OwletCoordinator
from .const import DOMAIN, MANUFACTURER
from .coordinator import OwletCoordinator
class OwletBaseEntity(CoordinatorEntity[OwletCoordinator], Entity):
@@ -19,6 +19,7 @@ class OwletBaseEntity(CoordinatorEntity[OwletCoordinator], Entity):
) -> None:
"""Initialize the base entity."""
super().__init__(coordinator)
self.coordinator = coordinator
self.sock = coordinator.sock
@property
@@ -26,9 +27,12 @@ class OwletBaseEntity(CoordinatorEntity[OwletCoordinator], Entity):
"""Return the device info of the device."""
return DeviceInfo(
identifiers={(DOMAIN, self.sock.serial)},
name="Owlet Baby Care Sock",
manufacturer=MANUFACTURER,
model=self.sock.model,
sw_version=self.sock.sw_version,
hw_version=f"{self.sock.version}r{self.sock.revision}",
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,11 +5,11 @@
"@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==2024.6.1"
"pyowletapi==2025.4.1"
],
"version": "2024.6.1"
}
"version": "2025.4.3"
}

View File

@@ -63,6 +63,7 @@ SENSORS: tuple[OwletSensorEntityDescription, ...] = (
translation_key="batterymin",
native_unit_of_measurement=UnitOfTime.MINUTES,
device_class=SensorDeviceClass.DURATION,
state_class=SensorStateClass.MEASUREMENT,
available_during_charging=False,
),
OwletSensorEntityDescription(
@@ -114,13 +115,18 @@ async def async_setup_entry(
sensors = []
for coordinator in coordinators:
for sensor in SENSORS:
if sensor.key in coordinator.sock.properties:
sensors.append(OwletSensor(coordinator, sensor))
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:
if (
OwletOxygenAverageSensor.entity_description.key
in coordinator.sock.properties
):
sensors.append(OwletOxygenAverageSensor(coordinator))
async_add_entities(sensors)
@@ -187,6 +193,7 @@ class OwletOxygenAverageSensor(OwletSensor):
native_unit_of_measurement=PERCENTAGE,
icon="mdi:leaf",
available_during_charging=False,
state_class=SensorStateClass.MEASUREMENT,
)
def __init__(
@@ -199,8 +206,14 @@ class OwletOxygenAverageSensor(OwletSensor):
@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
) and (self.sock.properties["oxygen_10_av"] >= 0 and self.sock.properties["oxygen_10_av"] <= 100)
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

@@ -74,9 +74,6 @@
},
"awake": {
"name": "Awake"
},
"base_on": {
"name": "Base station on"
}
},
"sensor": {
@@ -116,6 +113,11 @@
"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,90 +1,86 @@
{
"config": {
"step": {
"user": {
"data": {
"region": "Region",
"username": "Email",
"password": "[%key:common::config_flow::data::password%]"
}
},
"reauth_confirm": {
"title": "Reauthentiaction required for Owlet",
"data": {
"password": "[%key:common::config_flow::data::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": {
},
"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": {
"init": {
"title": "Configure options for Owlet",
"reauth_confirm": {
"data": {
"pollinterval": "Polling interval in seconds, min 10"
"password": "Password"
},
"title": "Reauthentiaction required for Owlet"
},
"user": {
"data": {
"password": "Password",
"region": "Region",
"username": "Email"
}
}
}
},
"entity": {
"binary_sensor": {
"awake": {
"name": "Awake"
},
"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"
},
"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"
},
"awake": {
"name": "Awake"
},
"base_on": {
"name": "Base station on"
}
},
"sensor": {
"batterymin": {
"name": "Battery remaining"
},
"batterypercent": {
"name": "Battery percentage"
},
"signalstrength": {
"name": "Signal strength"
"heartrate": {
"name": "Heart rate"
},
"movement": {
"name": "Movement"
},
"movementbucket": {
"name": "Movement bucket"
},
"o2saturation": {
"name": "O2 saturation"
@@ -92,11 +88,8 @@
"o2saturation10a": {
"name": "O2 saturation 10 minute average"
},
"heartrate": {
"name": "Heart rate"
},
"batterymin": {
"name": "Battery remaining"
"signalstrength": {
"name": "Signal strength"
},
"skintemp": {
"name": "Skin temperature"
@@ -104,17 +97,26 @@
"sleepstate": {
"name": "Sleep state",
"state": {
"unknown": "Unknown",
"awake": "Awake",
"deep_sleep": "Deep sleep",
"light_sleep": "Light sleep",
"deep_sleep": "Deep sleep"
"unknown": "Unknown"
}
},
"movement": {
"name": "Movement"
},
"movementbucket": {
"name": "Movement bucket"
}
},
"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

@@ -5,18 +5,18 @@
"data": {
"region": "Region",
"username": "Email",
"password": "[%key:common::config_flow::data::password%]"
"password": "Password"
}
},
"reauth_confirm": {
"title": "Reauthentiaction required for Owlet",
"data": {
"password": "[%key:common::config_flow::data::password%]"
"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",
"invalid_credentials": "Entered credentials are incorrect",
@@ -74,9 +74,6 @@
},
"awake": {
"name": "Awake"
},
"base_on": {
"name": "Base station on"
}
},
"sensor": {
@@ -116,6 +113,11 @@
"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"
}