Skip to content

Commit

Permalink
Merge pull request #58 from Nezz/feature/base
Browse files Browse the repository at this point in the history
Add support for the Base
  • Loading branch information
lukas-clarke authored Oct 13, 2024
2 parents 5bd24d2 + 4f0684c commit cac7379
Show file tree
Hide file tree
Showing 12 changed files with 475 additions and 162 deletions.
2 changes: 2 additions & 0 deletions .flake8
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[flake8]
max-line-length = 120
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,20 @@ There are a few possible sensor values for each Eight Sleep side. Some ones with
| Sleep Stage | Side | String | **No** | |
| Time Slept | Side | Duration | Yes | |
| Side | Side | String | Yes | The current side that this user side is set to |

Sensor values are updated every 5 minutes

When the Base is installed, the following entities are available:

| Entity | Type | Notes |
|---|---|---|
| Snore Mitigation | Boolean | Indicates that the snore mitigation is active, raising the head |
| Feet Angle | Number | Can be changed from the UI |
| Head Angle | Number | Can be changed from the UI |
| Base Preset | Select | The app currently offers three presets for the base: sleep, relaxing, and reading. |

These values are updated every minute.

## TODO ##
- Translate "Heat Set" and "Heat Increment" values to temperature values in degrees for easier use.
- Add device actions, so they can be used instead of service calls.
Expand Down
89 changes: 61 additions & 28 deletions custom_components/eight_sleep/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.httpx_client import get_async_client
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device_registry import DeviceInfo, async_get
from homeassistant.helpers.device_registry import async_get
from homeassistant.helpers.typing import UNDEFINED, ConfigType
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
Expand All @@ -38,10 +40,11 @@

_LOGGER = logging.getLogger(__name__)

PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.NUMBER, Platform.SELECT]

HEAT_SCAN_INTERVAL = timedelta(seconds=60)
DEVICE_SCAN_INTERVAL = timedelta(seconds=60)
USER_SCAN_INTERVAL = timedelta(seconds=300)
BASE_SCAN_INTERVAL = timedelta(seconds=60)

CONFIG_SCHEMA = vol.Schema(
{
Expand All @@ -63,16 +66,26 @@ class EightSleepConfigEntryData:
"""Data used for all entities for a given config entry."""

api: EightSleep
heat_coordinator: DataUpdateCoordinator
device_coordinator: DataUpdateCoordinator
user_coordinator: DataUpdateCoordinator
base_coordinator: DataUpdateCoordinator


def _get_device_unique_id(eight: EightSleep, user_obj: EightUser | None = None) -> str:
def _get_device_unique_id(
eight: EightSleep,
user_obj: EightUser | None = None,
base_entity: bool = False
) -> str:
"""Get the device's unique ID."""
unique_id = eight.device_id
assert unique_id

if base_entity:
return f"{unique_id}.base"

if user_obj:
unique_id = f"{unique_id}.{user_obj.user_id}"
return f"{unique_id}.{user_obj.user_id}"

return unique_id


Expand Down Expand Up @@ -110,6 +123,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
client_id,
client_secret,
client_session=async_get_clientsession(hass),
httpx_client=get_async_client(hass)
)
# Authenticate, build sensors
try:
Expand All @@ -120,11 +134,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Authentication failed, cannot continue
return False

heat_coordinator: DataUpdateCoordinator = DataUpdateCoordinator(
device_coordinator: DataUpdateCoordinator = DataUpdateCoordinator(
hass,
_LOGGER,
name=f"{DOMAIN}_heat",
update_interval=HEAT_SCAN_INTERVAL,
name=f"{DOMAIN}_device",
update_interval=DEVICE_SCAN_INTERVAL,
update_method=eight.update_device_data,
)
user_coordinator: DataUpdateCoordinator = DataUpdateCoordinator(
Expand All @@ -134,8 +148,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
update_interval=USER_SCAN_INTERVAL,
update_method=eight.update_user_data,
)
await heat_coordinator.async_config_entry_first_refresh()
base_coordinator: DataUpdateCoordinator = DataUpdateCoordinator(
hass,
_LOGGER,
name=f"{DOMAIN}_base",
update_interval=BASE_SCAN_INTERVAL,
update_method=eight.update_base_data,
)
await device_coordinator.async_config_entry_first_refresh()
await user_coordinator.async_config_entry_first_refresh()
await base_coordinator.async_config_entry_first_refresh()

if not eight.users:
# No users, cannot continue
Expand All @@ -159,6 +181,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
for user in eight.users.values():
assert user.user_profile

dev_reg.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, _get_device_unique_id(eight, user))},
Expand All @@ -167,8 +190,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
**device_data,
)

if eight.base_user:
base_hardware_info = eight.base_user.base_data.get("hardwareInfo", {})
base_device_data = {
ATTR_MANUFACTURER: "Eight Sleep",
ATTR_MODEL: base_hardware_info['sku'],
ATTR_HW_VERSION: base_hardware_info['hardwareVersion'],
ATTR_SW_VERSION: base_hardware_info['softwareVersion'],
}

dev_reg.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, _get_device_unique_id(eight, base_entity=True))},
name=f"{entry.data[CONF_USERNAME]}'s Base",
via_device=(DOMAIN, _get_device_unique_id(eight)),
**base_device_data,
)

hass.data.setdefault(DOMAIN, {})[entry.entry_id] = EightSleepConfigEntryData(
eight, heat_coordinator, user_coordinator
eight, device_coordinator, user_coordinator, base_coordinator
)

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
Expand All @@ -192,36 +232,29 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
class EightSleepBaseEntity(CoordinatorEntity[DataUpdateCoordinator]):
"""The base Eight Sleep entity class."""

_attr_has_entity_name = True

def __init__(
self,
entry: ConfigEntry,
coordinator: DataUpdateCoordinator,
eight: EightSleep,
user_id: str | None,
user: EightUser | None,
sensor: str,
base_entity: bool = False
) -> None:
"""Initialize the data object."""
super().__init__(coordinator)
self._config_entry = entry
self._eight = eight
self._user_id = user_id
self._sensor = sensor
self._user_obj: EightUser | None = None
if user_id:
self._user_obj = self._eight.users[user_id]
self._user_obj = user

mapped_name = str(NAME_MAP.get(sensor, sensor.replace("_", " ").title()))
self._attr_name = str(NAME_MAP.get(sensor, sensor.replace("_", " ").title()))

if self._user_obj is not None:
assert self._user_obj.user_profile
name = f"{self._user_obj.user_profile['firstName']}'s {mapped_name}"
self._attr_name = name
else:
self._attr_name = f"Eight Sleep {mapped_name}"
unique_id = f"{_get_device_unique_id(eight, self._user_obj)}.{sensor}"
self._attr_unique_id = unique_id
identifiers = {(DOMAIN, _get_device_unique_id(eight, self._user_obj))}
self._attr_device_info = DeviceInfo(identifiers=identifiers)
device_id = _get_device_unique_id(eight, self._user_obj, base_entity)
self._attr_unique_id = f"{device_id}.{sensor}"
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_id)})

async def _generic_service_call(self, service_method):
if self._user_obj is None:
Expand All @@ -232,7 +265,7 @@ async def _generic_service_call(self, service_method):
config_entry_data: EightSleepConfigEntryData = self.hass.data[DOMAIN][
self._config_entry.entry_id
]
await config_entry_data.heat_coordinator.async_request_refresh()
await config_entry_data.device_coordinator.async_request_refresh()

async def async_heat_set(
self, target: int, duration: int, sleep_stage: str
Expand Down
75 changes: 48 additions & 27 deletions custom_components/eight_sleep/binary_sensor.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
"""Support for Eight Sleep binary sensors."""
from __future__ import annotations
from typing import Callable

import logging
from custom_components.eight_sleep.pyEight.user import EightUser

from .pyEight.eight import EightSleep

from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
Expand All @@ -17,8 +19,18 @@
from . import EightSleepBaseEntity, EightSleepConfigEntryData
from .const import DOMAIN

_LOGGER = logging.getLogger(__name__)
BINARY_SENSORS = ["bed_presence"]
BED_PRESENCE_DESCRIPTION = BinarySensorEntityDescription(
key="bed_presence",
name="Bed Presence",
device_class=BinarySensorDeviceClass.OCCUPANCY,
)

SNORE_MITIGATION_DESCRIPTION = BinarySensorEntityDescription(
key="snore_mitigation",
name="Snore Mitigaton",
icon="mdi:account-alert",
device_class=BinarySensorDeviceClass.RUNNING,
)


async def async_setup_entry(
Expand All @@ -27,39 +39,48 @@ async def async_setup_entry(
"""Set up the eight sleep binary sensor."""
config_entry_data: EightSleepConfigEntryData = hass.data[DOMAIN][entry.entry_id]
eight = config_entry_data.api
heat_coordinator = config_entry_data.heat_coordinator
async_add_entities(
EightHeatSensor(entry, heat_coordinator, eight, user.user_id, binary_sensor)
for user in eight.users.values()
for binary_sensor in BINARY_SENSORS
)

entities: list[BinarySensorEntity] = []

for user in eight.users.values():
entities.append(EightBinaryEntity(
entry,
config_entry_data.device_coordinator,
eight,
user,
BED_PRESENCE_DESCRIPTION,
lambda: user.bed_presence))

if eight.base_user:
entities.append(EightBinaryEntity(
entry,
config_entry_data.base_coordinator,
eight,
None,
SNORE_MITIGATION_DESCRIPTION,
lambda: eight.base_user.in_snore_mitigation,
base_entity=True))

async_add_entities(entities)

class EightHeatSensor(EightSleepBaseEntity, BinarySensorEntity):
"""Representation of a Eight Sleep heat-based sensor."""

_attr_device_class = BinarySensorDeviceClass.OCCUPANCY
class EightBinaryEntity(EightSleepBaseEntity, BinarySensorEntity):
"""Representation of an Eight Sleep binary entity."""

def __init__(
self,
entry: ConfigEntry,
coordinator: DataUpdateCoordinator,
eight: EightSleep,
user_id: str | None,
sensor: str,
user: EightUser | None,
entity_description: BinarySensorEntityDescription,
value_getter: Callable[[], bool | None],
base_entity: bool = False
) -> None:
"""Initialize the sensor."""
super().__init__(entry, coordinator, eight, user_id, sensor)
assert self._user_obj
_LOGGER.debug(
"Presence Sensor: %s, Side: %s, User: %s",
sensor,
self._user_obj.side,
user_id,
)
super().__init__(entry, coordinator, eight, user, entity_description.key, base_entity)
self.entity_description = entity_description
self._value_getter = value_getter

@property
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
assert self._user_obj
return bool(self._user_obj.bed_presence)
def is_on(self) -> bool | None:
return self._value_getter()
2 changes: 2 additions & 0 deletions custom_components/eight_sleep/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
)
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
Expand Down Expand Up @@ -69,6 +70,7 @@ async def _validate_data(self, config: dict[str, str]) -> str | None:
client_id,
client_secret,
client_session=async_get_clientsession(self.hass),
httpx_client=get_async_client(self.hass),
)

try:
Expand Down
28 changes: 17 additions & 11 deletions custom_components/eight_sleep/const.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,21 @@
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass

"""Eight Sleep constants."""
DOMAIN = "eight_sleep"

HEAT_ENTITY = "heat"
USER_ENTITY = "user"


class NameMapEntity:
def __init__(
self, name, measurement=None, state_class=None, device_class=None
self,
name: str,
measurement: str | None = None,
device_class: SensorDeviceClass | None = None,
state_class: SensorStateClass | None = SensorStateClass.MEASUREMENT
) -> None:
self.name = name
self.measurement = measurement
self.state_class = state_class
self.device_class = device_class
self.state_class = state_class

def __str__(self) -> str:
return self.name
Expand All @@ -32,13 +29,22 @@ def __str__(self) -> str:
"current_hrv": NameMapEntity("HRV", "ms"),
"current_breath_rate": NameMapEntity("Breath Rate", "/min"),
"time_slept": NameMapEntity(
"Time Slept", "s", SensorDeviceClass.DURATION, SensorDeviceClass.DURATION
"Time Slept", "s", SensorDeviceClass.DURATION
),
"presence_start": NameMapEntity(
"Previous Presence Start",
device_class=SensorDeviceClass.TIMESTAMP,
state_class=None
),
"presence_end": NameMapEntity(
"Previous Presence End",
device_class=SensorDeviceClass.TIMESTAMP,
state_class=None
),
"next_alarm": NameMapEntity(
"Next Alarm",
device_class=SensorDeviceClass.TIMESTAMP,
state_class=None
),
}

Expand Down
Loading

0 comments on commit cac7379

Please sign in to comment.