Refactor sleepiq as async with config flow (#64850)

Co-authored-by: J. Nick Koston <nick@koston.org>
pull/66664/head
Mike Fugate 2022-02-16 09:51:29 -05:00 committed by GitHub
parent dbc445c2fa
commit 0bd0b4766e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 739 additions and 464 deletions

View File

@ -166,6 +166,7 @@ homeassistant.components.senseme.*
homeassistant.components.shelly.*
homeassistant.components.simplisafe.*
homeassistant.components.slack.*
homeassistant.components.sleepiq.*
homeassistant.components.smhi.*
homeassistant.components.ssdp.*
homeassistant.components.stookalert.*

View File

@ -850,6 +850,8 @@ homeassistant/components/sisyphus/* @jkeljo
homeassistant/components/sky_hub/* @rogerselwyn
homeassistant/components/slack/* @bachya
tests/components/slack/* @bachya
homeassistant/components/sleepiq/* @mfugate1
tests/components/sleepiq/* @mfugate1
homeassistant/components/slide/* @ualex73
homeassistant/components/sma/* @kellerza @rklomp
tests/components/sma/* @kellerza @rklomp

View File

@ -1,115 +1,73 @@
"""Support for SleepIQ from SleepNumber."""
from datetime import timedelta
import logging
from sleepyq import Sleepyq
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import discovery
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import Throttle
from .const import DOMAIN
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30)
from .coordinator import SleepIQDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = vol.Schema(
{
vol.Required(DOMAIN): vol.Schema(
{
vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
}
)
DOMAIN: {
vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
}
},
extra=vol.ALLOW_EXTRA,
)
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the SleepIQ component.
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
Will automatically load sensor components to support
devices discovered on the account.
"""
username = config[DOMAIN][CONF_USERNAME]
password = config[DOMAIN][CONF_PASSWORD]
client = Sleepyq(username, password)
try:
data = SleepIQData(client)
data.update()
except ValueError:
message = """
SleepIQ failed to login, double check your username and password"
"""
_LOGGER.error(message)
return False
hass.data[DOMAIN] = data
discovery.load_platform(hass, Platform.SENSOR, DOMAIN, {}, config)
discovery.load_platform(hass, Platform.BINARY_SENSOR, DOMAIN, {}, config)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up sleepiq component."""
if DOMAIN in config:
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN]
)
)
return True
class SleepIQData:
"""Get the latest data from SleepIQ."""
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up the SleepIQ config entry."""
client = Sleepyq(entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD])
try:
await hass.async_add_executor_job(client.login)
except ValueError:
_LOGGER.error("SleepIQ login failed, double check your username and password")
return False
def __init__(self, client):
"""Initialize the data object."""
self._client = client
self.beds = {}
coordinator = SleepIQDataUpdateCoordinator(
hass,
client=client,
username=entry.data[CONF_USERNAME],
)
self.update()
# Call the SleepIQ API to refresh data
await coordinator.async_config_entry_first_refresh()
@Throttle(MIN_TIME_BETWEEN_UPDATES)
def update(self):
"""Get the latest data from SleepIQ."""
self._client.login()
beds = self._client.beds_with_sleeper_status()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
self.beds = {bed.bed_id: bed for bed in beds}
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
return True
class SleepIQSensor(Entity):
"""Implementation of a SleepIQ sensor."""
def __init__(self, sleepiq_data, bed_id, side):
"""Initialize the sensor."""
self._bed_id = bed_id
self._side = side
self.sleepiq_data = sleepiq_data
self.side = None
self.bed = None
# added by subclass
self._name = None
self.type = None
@property
def name(self):
"""Return the name of the sensor."""
return "SleepNumber {} {} {}".format(
self.bed.name, self.side.sleeper.first_name, self._name
)
@property
def unique_id(self):
"""Return a unique ID for the bed."""
return f"{self._bed_id}-{self._side}-{self.type}"
def update(self):
"""Get the latest data from SleepIQ and updates the states."""
# Call the API for new sleepiq data. Each sensor will re-trigger this
# same exact call, but that's fine. We cache results for a short period
# of time to prevent hitting API limits.
self.sleepiq_data.update()
self.bed = self.sleepiq_data.beds[self._bed_id]
self.side = getattr(self.bed, self._side)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload the config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

View File

@ -1,61 +1,49 @@
"""Support for SleepIQ sensors."""
from __future__ import annotations
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.core import HomeAssistant
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import SleepIQSensor
from .const import DOMAIN, IS_IN_BED, SENSOR_TYPES, SIDES
from .const import BED, DOMAIN, ICON_EMPTY, ICON_OCCUPIED, IS_IN_BED, SIDES
from .coordinator import SleepIQDataUpdateCoordinator
from .entity import SleepIQSensor
def setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config: ConfigType,
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the SleepIQ sensors."""
if discovery_info is None:
return
data = hass.data[DOMAIN]
data.update()
dev = []
for bed_id, bed in data.beds.items():
for side in SIDES:
if getattr(bed, side) is not None:
dev.append(IsInBedBinarySensor(data, bed_id, side))
add_entities(dev)
"""Set up the SleepIQ bed binary sensors."""
coordinator: SleepIQDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
IsInBedBinarySensor(coordinator, bed_id, side)
for side in SIDES
for bed_id in coordinator.data
if getattr(coordinator.data[bed_id][BED], side) is not None
)
class IsInBedBinarySensor(SleepIQSensor, BinarySensorEntity):
"""Implementation of a SleepIQ presence sensor."""
def __init__(self, sleepiq_data, bed_id, side):
"""Initialize the sensor."""
super().__init__(sleepiq_data, bed_id, side)
self._state = None
self.type = IS_IN_BED
self._name = SENSOR_TYPES[self.type]
self.update()
_attr_device_class = BinarySensorDeviceClass.OCCUPANCY
@property
def is_on(self):
"""Return the status of the sensor."""
return self._state is True
def __init__(
self,
coordinator: SleepIQDataUpdateCoordinator,
bed_id: str,
side: str,
) -> None:
"""Initialize the SleepIQ bed side binary sensor."""
super().__init__(coordinator, bed_id, side, IS_IN_BED)
@property
def device_class(self) -> BinarySensorDeviceClass:
"""Return the class of this sensor."""
return BinarySensorDeviceClass.OCCUPANCY
def update(self):
"""Get the latest data from SleepIQ and updates the states."""
super().update()
self._state = self.side.is_in_bed
@callback
def _async_update_attrs(self) -> None:
"""Update sensor attributes."""
super()._async_update_attrs()
self._attr_is_on = getattr(self.side_data, IS_IN_BED)
self._attr_icon = ICON_OCCUPIED if self.is_on else ICON_EMPTY

View File

@ -0,0 +1,85 @@
"""Config flow to configure SleepIQ component."""
from __future__ import annotations
from typing import Any
from sleepyq import Sleepyq
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.data_entry_flow import FlowResult
from .const import DOMAIN, SLEEPYQ_INVALID_CREDENTIALS_MESSAGE
class SleepIQFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a SleepIQ config flow."""
VERSION = 1
async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult:
"""Import a SleepIQ account as a config entry.
This flow is triggered by 'async_setup' for configured accounts.
"""
await self.async_set_unique_id(import_config[CONF_USERNAME].lower())
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=import_config[CONF_USERNAME], data=import_config
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle a flow initialized by the user."""
errors = {}
if user_input is not None:
# Don't allow multiple instances with the same username
await self.async_set_unique_id(user_input[CONF_USERNAME].lower())
self._abort_if_unique_id_configured()
login_error = await self.hass.async_add_executor_job(
try_connection, user_input
)
if not login_error:
return self.async_create_entry(
title=user_input[CONF_USERNAME], data=user_input
)
if SLEEPYQ_INVALID_CREDENTIALS_MESSAGE in login_error:
errors["base"] = "invalid_auth"
else:
errors["base"] = "cannot_connect"
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(
CONF_USERNAME,
default=user_input.get(CONF_USERNAME)
if user_input is not None
else "",
): str,
vol.Required(CONF_PASSWORD): str,
}
),
errors=errors,
last_step=True,
)
def try_connection(user_input: dict[str, Any]) -> str:
"""Test if the given credentials can successfully login to SleepIQ."""
client = Sleepyq(user_input[CONF_USERNAME], user_input[CONF_PASSWORD])
try:
client.login()
except ValueError as error:
return str(error)
return ""

View File

@ -1,7 +1,12 @@
"""Define constants for the SleepIQ component."""
DATA_SLEEPIQ = "data_sleepiq"
DOMAIN = "sleepiq"
SLEEPYQ_INVALID_CREDENTIALS_MESSAGE = "username or password"
BED = "bed"
ICON_EMPTY = "mdi:bed-empty"
ICON_OCCUPIED = "mdi:bed"
IS_IN_BED = "is_in_bed"
SLEEP_NUMBER = "sleep_number"
SENSOR_TYPES = {SLEEP_NUMBER: "SleepNumber", IS_IN_BED: "Is In Bed"}

View File

@ -0,0 +1,40 @@
"""Coordinator for SleepIQ."""
from datetime import timedelta
import logging
from sleepyq import Sleepyq
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import BED
_LOGGER = logging.getLogger(__name__)
UPDATE_INTERVAL = timedelta(seconds=60)
class SleepIQDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]):
"""SleepIQ data update coordinator."""
def __init__(
self,
hass: HomeAssistant,
*,
client: Sleepyq,
username: str,
) -> None:
"""Initialize coordinator."""
super().__init__(
hass, _LOGGER, name=f"{username}@SleepIQ", update_interval=UPDATE_INTERVAL
)
self.client = client
async def _async_update_data(self) -> dict[str, dict]:
return await self.hass.async_add_executor_job(self.update_data)
def update_data(self) -> dict[str, dict]:
"""Get latest data from the client."""
return {
bed.bed_id: {BED: bed} for bed in self.client.beds_with_sleeper_status()
}

View File

@ -0,0 +1,43 @@
"""Entity for the SleepIQ integration."""
from homeassistant.core import callback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import BED, ICON_OCCUPIED, SENSOR_TYPES
from .coordinator import SleepIQDataUpdateCoordinator
class SleepIQSensor(CoordinatorEntity):
"""Implementation of a SleepIQ sensor."""
_attr_icon = ICON_OCCUPIED
def __init__(
self,
coordinator: SleepIQDataUpdateCoordinator,
bed_id: str,
side: str,
name: str,
) -> None:
"""Initialize the SleepIQ side entity."""
super().__init__(coordinator)
self.bed_id = bed_id
self.side = side
self._async_update_attrs()
self._attr_name = f"SleepNumber {self.bed_data.name} {self.side_data.sleeper.first_name} {SENSOR_TYPES[name]}"
self._attr_unique_id = (
f"{self.bed_id}_{self.side_data.sleeper.first_name}_{name}"
)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._async_update_attrs()
super()._handle_coordinator_update()
@callback
def _async_update_attrs(self) -> None:
"""Update sensor attributes."""
self.bed_data = self.coordinator.data[self.bed_id][BED]
self.side_data = getattr(self.bed_data, self.side)

View File

@ -1,9 +1,13 @@
{
"domain": "sleepiq",
"name": "SleepIQ",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/sleepiq",
"requirements": ["sleepyq==0.8.1"],
"codeowners": [],
"codeowners": ["@mfugate1"],
"dhcp": [
{"macaddress": "64DBA0*"}
],
"iot_class": "cloud_polling",
"loggers": ["sleepyq"]
}

View File

@ -1,62 +1,43 @@
"""Support for SleepIQ sensors."""
from __future__ import annotations
from homeassistant.components.sensor import SensorEntity
from homeassistant.core import HomeAssistant
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import SleepIQSensor
from .const import DOMAIN, SENSOR_TYPES, SIDES, SLEEP_NUMBER
ICON = "mdi:bed"
from .const import BED, DOMAIN, SIDES, SLEEP_NUMBER
from .coordinator import SleepIQDataUpdateCoordinator
from .entity import SleepIQSensor
def setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config: ConfigType,
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the SleepIQ sensors."""
if discovery_info is None:
return
data = hass.data[DOMAIN]
data.update()
dev = []
for bed_id, bed in data.beds.items():
for side in SIDES:
if getattr(bed, side) is not None:
dev.append(SleepNumberSensor(data, bed_id, side))
add_entities(dev)
"""Set up the SleepIQ bed sensors."""
coordinator: SleepIQDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
SleepNumberSensor(coordinator, bed_id, side)
for side in SIDES
for bed_id in coordinator.data
if getattr(coordinator.data[bed_id][BED], side) is not None
)
class SleepNumberSensor(SleepIQSensor, SensorEntity):
"""Implementation of a SleepIQ sensor."""
def __init__(self, sleepiq_data, bed_id, side):
"""Initialize the sensor."""
SleepIQSensor.__init__(self, sleepiq_data, bed_id, side)
def __init__(
self,
coordinator: SleepIQDataUpdateCoordinator,
bed_id: str,
side: str,
) -> None:
"""Initialize the SleepIQ sleep number sensor."""
super().__init__(coordinator, bed_id, side, SLEEP_NUMBER)
self._state = None
self.type = SLEEP_NUMBER
self._name = SENSOR_TYPES[self.type]
self.update()
@property
def native_value(self):
"""Return the state of the sensor."""
return self._state
@property
def icon(self):
"""Icon to use in the frontend, if any."""
return ICON
def update(self):
"""Get the latest data from SleepIQ and updates the states."""
SleepIQSensor.update(self)
self._state = self.side.sleep_number
@callback
def _async_update_attrs(self) -> None:
"""Update sensor attributes."""
super()._async_update_attrs()
self._attr_native_value = self.side_data.sleep_number

View File

@ -0,0 +1,19 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
},
"step": {
"user": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
}
}
}
}
}

View File

@ -0,0 +1,19 @@
{
"config": {
"abort": {
"already_configured": "Account is already configured"
},
"error": {
"cannot_connect": "Failed to connect",
"invalid_auth": "Invalid authentication"
},
"step": {
"user": {
"data": {
"password": "Password",
"username": "Username"
}
}
}
}
}

View File

@ -286,6 +286,7 @@ FLOWS = [
"shopping_list",
"sia",
"simplisafe",
"sleepiq",
"sma",
"smappee",
"smart_meter_texas",

View File

@ -88,6 +88,7 @@ DHCP: list[dict[str, str | bool]] = [
{'domain': 'senseme', 'macaddress': '20F85E*'},
{'domain': 'sensibo', 'hostname': 'sensibo*'},
{'domain': 'simplisafe', 'hostname': 'simplisafe*', 'macaddress': '30AEA4*'},
{'domain': 'sleepiq', 'macaddress': '64DBA0*'},
{'domain': 'smartthings', 'hostname': 'st*', 'macaddress': '24FD5B*'},
{'domain': 'smartthings', 'hostname': 'smartthings*', 'macaddress': '24FD5B*'},
{'domain': 'smartthings', 'hostname': 'hub*', 'macaddress': '24FD5B*'},

View File

@ -1635,6 +1635,17 @@ no_implicit_optional = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.sleepiq.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
no_implicit_optional = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.smhi.*]
check_untyped_defs = true
disallow_incomplete_defs = true

View File

@ -0,0 +1,75 @@
"""Common fixtures for sleepiq tests."""
import json
from unittest.mock import patch
import pytest
from sleepyq import Bed, FamilyStatus, Sleeper
from homeassistant.components.sleepiq.const import DOMAIN
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from tests.common import MockConfigEntry, load_fixture
def mock_beds(account_type):
"""Mock sleepnumber bed data."""
return [
Bed(bed)
for bed in json.loads(load_fixture(f"bed{account_type}.json", "sleepiq"))[
"beds"
]
]
def mock_sleepers():
"""Mock sleeper data."""
return [
Sleeper(sleeper)
for sleeper in json.loads(load_fixture("sleeper.json", "sleepiq"))["sleepers"]
]
def mock_bed_family_status(account_type):
"""Mock family status data."""
return [
FamilyStatus(status)
for status in json.loads(
load_fixture(f"familystatus{account_type}.json", "sleepiq")
)["beds"]
]
@pytest.fixture
def config_data():
"""Provide configuration data for tests."""
return {
CONF_USERNAME: "username",
CONF_PASSWORD: "password",
}
@pytest.fixture
def config_entry(config_data):
"""Create a mock config entry."""
return MockConfigEntry(
domain=DOMAIN,
data=config_data,
options={},
)
@pytest.fixture(params=["-single", ""])
async def setup_entry(hass, request, config_entry):
"""Initialize the config entry."""
with patch("sleepyq.Sleepyq.beds", return_value=mock_beds(request.param)), patch(
"sleepyq.Sleepyq.sleepers", return_value=mock_sleepers()
), patch(
"sleepyq.Sleepyq.bed_family_status",
return_value=mock_bed_family_status(request.param),
), patch(
"sleepyq.Sleepyq.login"
):
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
return {"account_type": request.param, "mock_entry": config_entry}

View File

@ -0,0 +1,27 @@
{
"beds" : [
{
"dualSleep" : false,
"base" : "FlexFit",
"sku" : "AILE",
"model" : "ILE",
"size" : "KING",
"isKidsBed" : false,
"sleeperRightId" : "-80",
"accountId" : "-32",
"bedId" : "-31",
"registrationDate" : "2016-07-22T14:00:58Z",
"serial" : null,
"reference" : "95000794555-1",
"macAddress" : "CD13A384BA51",
"version" : null,
"purchaseDate" : "2016-06-22T00:00:00Z",
"sleeperLeftId" : "0",
"zipcode" : "12345",
"returnRequestStatus" : 0,
"name" : "ILE",
"status" : 1,
"timezone" : "US/Eastern"
}
]
}

View File

@ -0,0 +1,27 @@
{
"beds" : [
{
"dualSleep" : true,
"base" : "FlexFit",
"sku" : "AILE",
"model" : "ILE",
"size" : "KING",
"isKidsBed" : false,
"sleeperRightId" : "-80",
"accountId" : "-32",
"bedId" : "-31",
"registrationDate" : "2016-07-22T14:00:58Z",
"serial" : null,
"reference" : "95000794555-1",
"macAddress" : "CD13A384BA51",
"version" : null,
"purchaseDate" : "2016-06-22T00:00:00Z",
"sleeperLeftId" : "-92",
"zipcode" : "12345",
"returnRequestStatus" : 0,
"name" : "ILE",
"status" : 1,
"timezone" : "US/Eastern"
}
]
}

View File

@ -0,0 +1,17 @@
{
"beds" : [
{
"bedId" : "-31",
"rightSide" : {
"alertId" : 0,
"lastLink" : "00:00:00",
"isInBed" : true,
"sleepNumber" : 40,
"alertDetailedMessage" : "No Alert",
"pressure" : -16
},
"status" : 1,
"leftSide" : null
}
]
}

View File

@ -0,0 +1,24 @@
{
"beds" : [
{
"bedId" : "-31",
"rightSide" : {
"alertId" : 0,
"lastLink" : "00:00:00",
"isInBed" : true,
"sleepNumber" : 40,
"alertDetailedMessage" : "No Alert",
"pressure" : -16
},
"status" : 1,
"leftSide" : {
"alertId" : 0,
"lastLink" : "00:00:00",
"sleepNumber" : 80,
"alertDetailedMessage" : "No Alert",
"isInBed" : false,
"pressure" : 2191
}
}
]
}

View File

@ -0,0 +1,7 @@
{
"edpLoginStatus" : 200,
"userId" : "-42",
"registrationState" : 13,
"key" : "0987",
"edpLoginMessage" : "not used"
}

View File

@ -0,0 +1,54 @@
{
"sleepers" : [
{
"timezone" : "US/Eastern",
"firstName" : "Test1",
"weight" : 150,
"birthMonth" : 12,
"birthYear" : "1990",
"active" : true,
"lastLogin" : "2016-08-26 21:43:27 CDT",
"side" : 1,
"accountId" : "-32",
"height" : 60,
"bedId" : "-31",
"username" : "test1@example.com",
"sleeperId" : "-80",
"avatar" : "",
"emailValidated" : true,
"licenseVersion" : 6,
"duration" : null,
"email" : "test1@example.com",
"isAccountOwner" : true,
"sleepGoal" : 480,
"zipCode" : "12345",
"isChild" : false,
"isMale" : true
},
{
"email" : "test2@example.com",
"duration" : null,
"emailValidated" : true,
"licenseVersion" : 5,
"isChild" : false,
"isMale" : false,
"zipCode" : "12345",
"isAccountOwner" : false,
"sleepGoal" : 480,
"side" : 0,
"lastLogin" : "2016-07-17 15:37:30 CDT",
"birthMonth" : 1,
"birthYear" : "1991",
"active" : true,
"weight" : 151,
"firstName" : "Test2",
"timezone" : "US/Eastern",
"avatar" : "",
"username" : "test2@example.com",
"sleeperId" : "-92",
"bedId" : "-31",
"height" : 65,
"accountId" : "-32"
}
]
}

View File

@ -1,45 +1,34 @@
"""The tests for SleepIQ binary sensor platform."""
from unittest.mock import MagicMock
from homeassistant.components.sleepiq import binary_sensor as sleepiq
from homeassistant.setup import async_setup_component
from tests.components.sleepiq.test_init import mock_responses
CONFIG = {"username": "foo", "password": "bar"}
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, ATTR_ICON
from homeassistant.helpers import entity_registry as er
async def test_sensor_setup(hass, requests_mock):
"""Test for successfully setting up the SleepIQ platform."""
mock_responses(requests_mock)
async def test_binary_sensors(hass, setup_entry):
"""Test the SleepIQ binary sensors."""
entity_registry = er.async_get(hass)
await async_setup_component(hass, "sleepiq", {"sleepiq": CONFIG})
state = hass.states.get("binary_sensor.sleepnumber_ile_test1_is_in_bed")
assert state.state == "on"
assert state.attributes.get(ATTR_ICON) == "mdi:bed"
assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.OCCUPANCY
assert state.attributes.get(ATTR_FRIENDLY_NAME) == "SleepNumber ILE Test1 Is In Bed"
device_mock = MagicMock()
sleepiq.setup_platform(hass, CONFIG, device_mock, MagicMock())
devices = device_mock.call_args[0][0]
assert len(devices) == 2
entry = entity_registry.async_get("binary_sensor.sleepnumber_ile_test1_is_in_bed")
assert entry
assert entry.unique_id == "-31_Test1_is_in_bed"
left_side = devices[1]
assert left_side.name == "SleepNumber ILE Test1 Is In Bed"
assert left_side.state == "on"
# If account type is set, only a single bed account was created and there will
# not be a second entity
if setup_entry["account_type"]:
return
right_side = devices[0]
assert right_side.name == "SleepNumber ILE Test2 Is In Bed"
assert right_side.state == "off"
entry = entity_registry.async_get("binary_sensor.sleepnumber_ile_test2_is_in_bed")
assert entry
assert entry.unique_id == "-31_Test2_is_in_bed"
async def test_setup_single(hass, requests_mock):
"""Test for successfully setting up the SleepIQ platform."""
mock_responses(requests_mock, single=True)
await async_setup_component(hass, "sleepiq", {"sleepiq": CONFIG})
device_mock = MagicMock()
sleepiq.setup_platform(hass, CONFIG, device_mock, MagicMock())
devices = device_mock.call_args[0][0]
assert len(devices) == 1
right_side = devices[0]
assert right_side.name == "SleepNumber ILE Test1 Is In Bed"
assert right_side.state == "on"
state = hass.states.get("binary_sensor.sleepnumber_ile_test2_is_in_bed")
assert state.state == "off"
assert state.attributes.get(ATTR_ICON) == "mdi:bed-empty"
assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.OCCUPANCY
assert state.attributes.get(ATTR_FRIENDLY_NAME) == "SleepNumber ILE Test2 Is In Bed"

View File

@ -0,0 +1,80 @@
"""Tests for the SleepIQ config flow."""
from unittest.mock import patch
from homeassistant import config_entries, data_entry_flow, setup
from homeassistant.components.sleepiq.const import (
DOMAIN,
SLEEPYQ_INVALID_CREDENTIALS_MESSAGE,
)
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
SLEEPIQ_CONFIG = {
CONF_USERNAME: "username",
CONF_PASSWORD: "password",
}
async def test_import(hass: HomeAssistant) -> None:
"""Test that we can import a config entry."""
with patch("sleepyq.Sleepyq.login"):
assert await setup.async_setup_component(hass, DOMAIN, {DOMAIN: SLEEPIQ_CONFIG})
await hass.async_block_till_done()
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
entry = hass.config_entries.async_entries(DOMAIN)[0]
assert entry.data[CONF_USERNAME] == SLEEPIQ_CONFIG[CONF_USERNAME]
assert entry.data[CONF_PASSWORD] == SLEEPIQ_CONFIG[CONF_PASSWORD]
async def test_show_set_form(hass: HomeAssistant) -> None:
"""Test that the setup form is served."""
with patch("sleepyq.Sleepyq.login"):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}, data=None
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
async def test_login_invalid_auth(hass: HomeAssistant) -> None:
"""Test we show user form with appropriate error on login failure."""
with patch(
"sleepyq.Sleepyq.login",
side_effect=ValueError(SLEEPYQ_INVALID_CREDENTIALS_MESSAGE),
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}, data=SLEEPIQ_CONFIG
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": "invalid_auth"}
async def test_login_cannot_connect(hass: HomeAssistant) -> None:
"""Test we show user form with appropriate error on login failure."""
with patch(
"sleepyq.Sleepyq.login",
side_effect=ValueError("Unexpected response code"),
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}, data=SLEEPIQ_CONFIG
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": "cannot_connect"}
async def test_success(hass: HomeAssistant) -> None:
"""Test successful flow provides entry creation data."""
with patch("sleepyq.Sleepyq.login"):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}, data=SLEEPIQ_CONFIG
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["data"][CONF_USERNAME] == SLEEPIQ_CONFIG[CONF_USERNAME]
assert result["data"][CONF_PASSWORD] == SLEEPIQ_CONFIG[CONF_PASSWORD]

View File

@ -1,65 +1,54 @@
"""The tests for the SleepIQ component."""
from http import HTTPStatus
from unittest.mock import MagicMock, patch
"""Tests for the SleepIQ integration."""
from unittest.mock import patch
from homeassistant import setup
import homeassistant.components.sleepiq as sleepiq
from homeassistant.components.sleepiq.const import DOMAIN
from homeassistant.components.sleepiq.coordinator import UPDATE_INTERVAL
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.util.dt import utcnow
from tests.common import load_fixture
CONFIG = {"sleepiq": {"username": "foo", "password": "bar"}}
from tests.common import async_fire_time_changed
from tests.components.sleepiq.conftest import (
mock_bed_family_status,
mock_beds,
mock_sleepers,
)
def mock_responses(mock, single=False):
"""Mock responses for SleepIQ."""
base_url = "https://prod-api.sleepiq.sleepnumber.com/rest/"
if single:
suffix = "-single"
else:
suffix = ""
mock.put(base_url + "login", text=load_fixture("sleepiq-login.json"))
mock.get(base_url + "bed?_k=0987", text=load_fixture(f"sleepiq-bed{suffix}.json"))
mock.get(base_url + "sleeper?_k=0987", text=load_fixture("sleepiq-sleeper.json"))
mock.get(
base_url + "bed/familyStatus?_k=0987",
text=load_fixture(f"sleepiq-familystatus{suffix}.json"),
)
async def test_unload_entry(hass: HomeAssistant, setup_entry) -> None:
"""Test unloading the SleepIQ entry."""
entry = setup_entry["mock_entry"]
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.NOT_LOADED
assert not hass.data.get(DOMAIN)
async def test_setup(hass, requests_mock):
"""Test the setup."""
mock_responses(requests_mock)
# We're mocking the load_platform discoveries or else the platforms
# will be setup during tear down when blocking till done, but the mocks
# are no longer active.
with patch("homeassistant.helpers.discovery.load_platform", MagicMock()):
assert sleepiq.setup(hass, CONFIG)
async def test_entry_setup_login_error(hass: HomeAssistant, config_entry) -> None:
"""Test when sleepyq client is unable to login."""
with patch("sleepyq.Sleepyq.login", side_effect=ValueError):
config_entry.add_to_hass(hass)
assert not await hass.config_entries.async_setup(config_entry.entry_id)
async def test_setup_login_failed(hass, requests_mock):
"""Test the setup if a bad username or password is given."""
mock_responses(requests_mock)
requests_mock.put(
"https://prod-api.sleepiq.sleepnumber.com/rest/login",
status_code=HTTPStatus.UNAUTHORIZED,
json=load_fixture("sleepiq-login-failed.json"),
)
async def test_update_interval(hass: HomeAssistant, setup_entry) -> None:
"""Test update interval."""
with patch("sleepyq.Sleepyq.beds", return_value=mock_beds("")) as beds, patch(
"sleepyq.Sleepyq.sleepers", return_value=mock_sleepers()
) as sleepers, patch(
"sleepyq.Sleepyq.bed_family_status",
return_value=mock_bed_family_status(""),
) as bed_family_status, patch(
"sleepyq.Sleepyq.login", return_value=True
):
assert beds.call_count == 0
assert sleepers.call_count == 0
assert bed_family_status.call_count == 0
response = sleepiq.setup(hass, CONFIG)
assert not response
async_fire_time_changed(hass, utcnow() + UPDATE_INTERVAL)
await hass.async_block_till_done()
async def test_setup_component_no_login(hass):
"""Test the setup when no login is configured."""
conf = CONFIG.copy()
del conf["sleepiq"]["username"]
assert not await setup.async_setup_component(hass, sleepiq.DOMAIN, conf)
async def test_setup_component_no_password(hass):
"""Test the setup when no password is configured."""
conf = CONFIG.copy()
del conf["sleepiq"]["password"]
assert not await setup.async_setup_component(hass, sleepiq.DOMAIN, conf)
assert beds.call_count == 1
assert sleepers.call_count == 1
assert bed_family_status.call_count == 1

View File

@ -1,48 +1,35 @@
"""The tests for SleepIQ sensor platform."""
from unittest.mock import MagicMock
import homeassistant.components.sleepiq.sensor as sleepiq
from homeassistant.setup import async_setup_component
from tests.components.sleepiq.test_init import mock_responses
CONFIG = {"username": "foo", "password": "bar"}
from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_ICON
from homeassistant.helpers import entity_registry as er
async def test_setup(hass, requests_mock):
"""Test for successfully setting up the SleepIQ platform."""
mock_responses(requests_mock)
async def test_sensors(hass, setup_entry):
"""Test the SleepIQ binary sensors for a bed with two sides."""
entity_registry = er.async_get(hass)
assert await async_setup_component(hass, "sleepiq", {"sleepiq": CONFIG})
state = hass.states.get("sensor.sleepnumber_ile_test1_sleepnumber")
assert state.state == "40"
assert state.attributes.get(ATTR_ICON) == "mdi:bed"
assert (
state.attributes.get(ATTR_FRIENDLY_NAME) == "SleepNumber ILE Test1 SleepNumber"
)
device_mock = MagicMock()
sleepiq.setup_platform(hass, CONFIG, device_mock, MagicMock())
devices = device_mock.call_args[0][0]
assert len(devices) == 2
entry = entity_registry.async_get("sensor.sleepnumber_ile_test1_sleepnumber")
assert entry
assert entry.unique_id == "-31_Test1_sleep_number"
left_side = devices[1]
left_side.hass = hass
assert left_side.name == "SleepNumber ILE Test1 SleepNumber"
assert left_side.state == 40
# If account type is set, only a single bed account was created and there will
# not be a second entity
if setup_entry["account_type"]:
return
right_side = devices[0]
right_side.hass = hass
assert right_side.name == "SleepNumber ILE Test2 SleepNumber"
assert right_side.state == 80
state = hass.states.get("sensor.sleepnumber_ile_test2_sleepnumber")
assert state.state == "80"
assert state.attributes.get(ATTR_ICON) == "mdi:bed"
assert (
state.attributes.get(ATTR_FRIENDLY_NAME) == "SleepNumber ILE Test2 SleepNumber"
)
async def test_setup_single(hass, requests_mock):
"""Test for successfully setting up the SleepIQ platform."""
mock_responses(requests_mock, single=True)
assert await async_setup_component(hass, "sleepiq", {"sleepiq": CONFIG})
device_mock = MagicMock()
sleepiq.setup_platform(hass, CONFIG, device_mock, MagicMock())
devices = device_mock.call_args[0][0]
assert len(devices) == 1
right_side = devices[0]
right_side.hass = hass
assert right_side.name == "SleepNumber ILE Test1 SleepNumber"
assert right_side.state == 40
entry = entity_registry.async_get("sensor.sleepnumber_ile_test2_sleepnumber")
assert entry
assert entry.unique_id == "-31_Test2_sleep_number"

View File

@ -1,27 +0,0 @@
{
"beds" : [
{
"dualSleep" : false,
"base" : "FlexFit",
"sku" : "AILE",
"model" : "ILE",
"size" : "KING",
"isKidsBed" : false,
"sleeperRightId" : "-80",
"accountId" : "-32",
"bedId" : "-31",
"registrationDate" : "2016-07-22T14:00:58Z",
"serial" : null,
"reference" : "95000794555-1",
"macAddress" : "CD13A384BA51",
"version" : null,
"purchaseDate" : "2016-06-22T00:00:00Z",
"sleeperLeftId" : "0",
"zipcode" : "12345",
"returnRequestStatus" : 0,
"name" : "ILE",
"status" : 1,
"timezone" : "US/Eastern"
}
]
}

View File

@ -1,28 +0,0 @@
{
"beds" : [
{
"dualSleep" : true,
"base" : "FlexFit",
"sku" : "AILE",
"model" : "ILE",
"size" : "KING",
"isKidsBed" : false,
"sleeperRightId" : "-80",
"accountId" : "-32",
"bedId" : "-31",
"registrationDate" : "2016-07-22T14:00:58Z",
"serial" : null,
"reference" : "95000794555-1",
"macAddress" : "CD13A384BA51",
"version" : null,
"purchaseDate" : "2016-06-22T00:00:00Z",
"sleeperLeftId" : "-92",
"zipcode" : "12345",
"returnRequestStatus" : 0,
"name" : "ILE",
"status" : 1,
"timezone" : "US/Eastern"
}
]
}

View File

@ -1,17 +0,0 @@
{
"beds" : [
{
"bedId" : "-31",
"rightSide" : {
"alertId" : 0,
"lastLink" : "00:00:00",
"isInBed" : true,
"sleepNumber" : 40,
"alertDetailedMessage" : "No Alert",
"pressure" : -16
},
"status" : 1,
"leftSide" : null
}
]
}

View File

@ -1,24 +0,0 @@
{
"beds" : [
{
"bedId" : "-31",
"rightSide" : {
"alertId" : 0,
"lastLink" : "00:00:00",
"isInBed" : true,
"sleepNumber" : 40,
"alertDetailedMessage" : "No Alert",
"pressure" : -16
},
"status" : 1,
"leftSide" : {
"alertId" : 0,
"lastLink" : "00:00:00",
"sleepNumber" : 80,
"alertDetailedMessage" : "No Alert",
"isInBed" : false,
"pressure" : 2191
}
}
]
}

View File

@ -1 +0,0 @@
{"Error":{"Code":401,"Message":"Authentication token of type [class org.apache.shiro.authc.UsernamePasswordToken] could not be authenticated by any configured realms. Please ensure that at least one realm can authenticate these tokens."}}

View File

@ -1,7 +0,0 @@
{
"edpLoginStatus" : 200,
"userId" : "-42",
"registrationState" : 13,
"key" : "0987",
"edpLoginMessage" : "not used"
}

View File

@ -1,55 +0,0 @@
{
"sleepers" : [
{
"timezone" : "US/Eastern",
"firstName" : "Test1",
"weight" : 150,
"birthMonth" : 12,
"birthYear" : "1990",
"active" : true,
"lastLogin" : "2016-08-26 21:43:27 CDT",
"side" : 1,
"accountId" : "-32",
"height" : 60,
"bedId" : "-31",
"username" : "test1@example.com",
"sleeperId" : "-80",
"avatar" : "",
"emailValidated" : true,
"licenseVersion" : 6,
"duration" : null,
"email" : "test1@example.com",
"isAccountOwner" : true,
"sleepGoal" : 480,
"zipCode" : "12345",
"isChild" : false,
"isMale" : true
},
{
"email" : "test2@example.com",
"duration" : null,
"emailValidated" : true,
"licenseVersion" : 5,
"isChild" : false,
"isMale" : false,
"zipCode" : "12345",
"isAccountOwner" : false,
"sleepGoal" : 480,
"side" : 0,
"lastLogin" : "2016-07-17 15:37:30 CDT",
"birthMonth" : 1,
"birthYear" : "1991",
"active" : true,
"weight" : 151,
"firstName" : "Test2",
"timezone" : "US/Eastern",
"avatar" : "",
"username" : "test2@example.com",
"sleeperId" : "-92",
"bedId" : "-31",
"height" : 65,
"accountId" : "-32"
}
]
}