Refactor sleepiq as async with config flow (#64850)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
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.shelly.*
homeassistant.components.simplisafe.* homeassistant.components.simplisafe.*
homeassistant.components.slack.* homeassistant.components.slack.*
homeassistant.components.sleepiq.*
homeassistant.components.smhi.* homeassistant.components.smhi.*
homeassistant.components.ssdp.* homeassistant.components.ssdp.*
homeassistant.components.stookalert.* homeassistant.components.stookalert.*

View File

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

View File

@ -1,115 +1,73 @@
"""Support for SleepIQ from SleepNumber.""" """Support for SleepIQ from SleepNumber."""
from datetime import timedelta
import logging import logging
from sleepyq import Sleepyq from sleepyq import Sleepyq
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import discovery
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.util import Throttle
from .const import DOMAIN from .const import DOMAIN
from .coordinator import SleepIQDataUpdateCoordinator
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = vol.Schema( CONFIG_SCHEMA = vol.Schema(
{ {
vol.Required(DOMAIN): vol.Schema( DOMAIN: {
{ vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string,
vol.Required(CONF_PASSWORD): cv.string, }
}
)
}, },
extra=vol.ALLOW_EXTRA, extra=vol.ALLOW_EXTRA,
) )
def setup(hass: HomeAssistant, config: ConfigType) -> bool: PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
"""Set up the SleepIQ component.
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 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
discovery.load_platform(hass, Platform.SENSOR, DOMAIN, {}, config) """Set up sleepiq component."""
discovery.load_platform(hass, Platform.BINARY_SENSOR, DOMAIN, {}, config) if DOMAIN in config:
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN]
)
)
return True return True
class SleepIQData: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Get the latest data from SleepIQ.""" """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): coordinator = SleepIQDataUpdateCoordinator(
"""Initialize the data object.""" hass,
self._client = client client=client,
self.beds = {} 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) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
def update(self):
"""Get the latest data from SleepIQ."""
self._client.login()
beds = self._client.beds_with_sleeper_status()
self.beds = {bed.bed_id: bed for bed in beds} hass.config_entries.async_setup_platforms(entry, PLATFORMS)
return True
class SleepIQSensor(Entity): async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Implementation of a SleepIQ sensor.""" """Unload the config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
def __init__(self, sleepiq_data, bed_id, side): hass.data[DOMAIN].pop(entry.entry_id)
"""Initialize the sensor.""" return unload_ok
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)

View File

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

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.""" """Define constants for the SleepIQ component."""
DATA_SLEEPIQ = "data_sleepiq"
DOMAIN = "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" IS_IN_BED = "is_in_bed"
SLEEP_NUMBER = "sleep_number" SLEEP_NUMBER = "sleep_number"
SENSOR_TYPES = {SLEEP_NUMBER: "SleepNumber", IS_IN_BED: "Is In Bed"} 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", "domain": "sleepiq",
"name": "SleepIQ", "name": "SleepIQ",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/sleepiq", "documentation": "https://www.home-assistant.io/integrations/sleepiq",
"requirements": ["sleepyq==0.8.1"], "requirements": ["sleepyq==0.8.1"],
"codeowners": [], "codeowners": ["@mfugate1"],
"dhcp": [
{"macaddress": "64DBA0*"}
],
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["sleepyq"] "loggers": ["sleepyq"]
} }

View File

@ -1,62 +1,43 @@
"""Support for SleepIQ sensors.""" """Support for SleepIQ sensors."""
from __future__ import annotations
from homeassistant.components.sensor import SensorEntity 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.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import SleepIQSensor from .const import BED, DOMAIN, SIDES, SLEEP_NUMBER
from .const import DOMAIN, SENSOR_TYPES, SIDES, SLEEP_NUMBER from .coordinator import SleepIQDataUpdateCoordinator
from .entity import SleepIQSensor
ICON = "mdi:bed"
def setup_platform( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config: ConfigType, entry: ConfigEntry,
add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None: ) -> None:
"""Set up the SleepIQ sensors.""" """Set up the SleepIQ bed sensors."""
if discovery_info is None: coordinator: SleepIQDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
return async_add_entities(
SleepNumberSensor(coordinator, bed_id, side)
data = hass.data[DOMAIN] for side in SIDES
data.update() for bed_id in coordinator.data
if getattr(coordinator.data[bed_id][BED], side) is not None
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)
class SleepNumberSensor(SleepIQSensor, SensorEntity): class SleepNumberSensor(SleepIQSensor, SensorEntity):
"""Implementation of a SleepIQ sensor.""" """Implementation of a SleepIQ sensor."""
def __init__(self, sleepiq_data, bed_id, side): def __init__(
"""Initialize the sensor.""" self,
SleepIQSensor.__init__(self, sleepiq_data, bed_id, side) 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 @callback
self.type = SLEEP_NUMBER def _async_update_attrs(self) -> None:
self._name = SENSOR_TYPES[self.type] """Update sensor attributes."""
super()._async_update_attrs()
self.update() self._attr_native_value = self.side_data.sleep_number
@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

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", "shopping_list",
"sia", "sia",
"simplisafe", "simplisafe",
"sleepiq",
"sma", "sma",
"smappee", "smappee",
"smart_meter_texas", "smart_meter_texas",

View File

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

View File

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

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.""" """Tests for the SleepIQ integration."""
from http import HTTPStatus from unittest.mock import patch
from unittest.mock import MagicMock, patch
from homeassistant import setup from homeassistant.components.sleepiq.const import DOMAIN
import homeassistant.components.sleepiq as sleepiq 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 from tests.common import async_fire_time_changed
from tests.components.sleepiq.conftest import (
CONFIG = {"sleepiq": {"username": "foo", "password": "bar"}} mock_bed_family_status,
mock_beds,
mock_sleepers,
)
def mock_responses(mock, single=False): async def test_unload_entry(hass: HomeAssistant, setup_entry) -> None:
"""Mock responses for SleepIQ.""" """Test unloading the SleepIQ entry."""
base_url = "https://prod-api.sleepiq.sleepnumber.com/rest/" entry = setup_entry["mock_entry"]
if single: assert await hass.config_entries.async_unload(entry.entry_id)
suffix = "-single" await hass.async_block_till_done()
else:
suffix = "" assert entry.state is ConfigEntryState.NOT_LOADED
mock.put(base_url + "login", text=load_fixture("sleepiq-login.json")) assert not hass.data.get(DOMAIN)
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_setup(hass, requests_mock): async def test_entry_setup_login_error(hass: HomeAssistant, config_entry) -> None:
"""Test the setup.""" """Test when sleepyq client is unable to login."""
mock_responses(requests_mock) with patch("sleepyq.Sleepyq.login", side_effect=ValueError):
config_entry.add_to_hass(hass)
# We're mocking the load_platform discoveries or else the platforms assert not await hass.config_entries.async_setup(config_entry.entry_id)
# 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_setup_login_failed(hass, requests_mock): async def test_update_interval(hass: HomeAssistant, setup_entry) -> None:
"""Test the setup if a bad username or password is given.""" """Test update interval."""
mock_responses(requests_mock) with patch("sleepyq.Sleepyq.beds", return_value=mock_beds("")) as beds, patch(
requests_mock.put( "sleepyq.Sleepyq.sleepers", return_value=mock_sleepers()
"https://prod-api.sleepiq.sleepnumber.com/rest/login", ) as sleepers, patch(
status_code=HTTPStatus.UNAUTHORIZED, "sleepyq.Sleepyq.bed_family_status",
json=load_fixture("sleepiq-login-failed.json"), 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) async_fire_time_changed(hass, utcnow() + UPDATE_INTERVAL)
assert not response await hass.async_block_till_done()
assert beds.call_count == 1
async def test_setup_component_no_login(hass): assert sleepers.call_count == 1
"""Test the setup when no login is configured.""" assert bed_family_status.call_count == 1
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)

View File

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

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"
}
]
}