From 1697a8c774798719f9747f9f6ef4a1cd890d153e Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 14 Sep 2016 00:11:50 +0200 Subject: [PATCH] SleepIQ component with sensor and binary sensor platforms (#3390) Original from #2949 --- .../components/binary_sensor/sleepiq.py | 59 ++++++++ homeassistant/components/sensor/sleepiq.py | 58 ++++++++ homeassistant/components/sleepiq.py | 130 ++++++++++++++++++ requirements_all.txt | 3 + .../components/binary_sensor/test_sleepiq.py | 50 +++++++ tests/components/sensor/test_sleepiq.py | 50 +++++++ tests/components/test_sleepiq.py | 75 ++++++++++ tests/fixtures/sleepiq-bed.json | 28 ++++ tests/fixtures/sleepiq-familystatus.json | 24 ++++ tests/fixtures/sleepiq-login-failed.json | 1 + tests/fixtures/sleepiq-login.json | 7 + tests/fixtures/sleepiq-sleeper.json | 55 ++++++++ 12 files changed, 540 insertions(+) create mode 100644 homeassistant/components/binary_sensor/sleepiq.py create mode 100644 homeassistant/components/sensor/sleepiq.py create mode 100644 homeassistant/components/sleepiq.py create mode 100644 tests/components/binary_sensor/test_sleepiq.py create mode 100644 tests/components/sensor/test_sleepiq.py create mode 100644 tests/components/test_sleepiq.py create mode 100644 tests/fixtures/sleepiq-bed.json create mode 100644 tests/fixtures/sleepiq-familystatus.json create mode 100644 tests/fixtures/sleepiq-login-failed.json create mode 100644 tests/fixtures/sleepiq-login.json create mode 100644 tests/fixtures/sleepiq-sleeper.json diff --git a/homeassistant/components/binary_sensor/sleepiq.py b/homeassistant/components/binary_sensor/sleepiq.py new file mode 100644 index 00000000000..c842d0c9be9 --- /dev/null +++ b/homeassistant/components/binary_sensor/sleepiq.py @@ -0,0 +1,59 @@ +""" +Support for SleepIQ sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.sleepiq/ +""" +from homeassistant.components import sleepiq +from homeassistant.components.binary_sensor import BinarySensorDevice + +DEPENDENCIES = ['sleepiq'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the SleepIQ sensors.""" + if discovery_info is None: + return + + data = sleepiq.DATA + data.update() + + dev = list() + for bed_id, _ in data.beds.items(): + for side in sleepiq.SIDES: + dev.append(IsInBedBinarySensor( + data, + bed_id, + side)) + add_devices(dev) + + +# pylint: disable=too-many-instance-attributes +class IsInBedBinarySensor(sleepiq.SleepIQSensor, BinarySensorDevice): + """Implementation of a SleepIQ presence sensor.""" + + def __init__(self, sleepiq_data, bed_id, side): + """Initialize the sensor.""" + sleepiq.SleepIQSensor.__init__(self, + sleepiq_data, + bed_id, + side) + self.type = sleepiq.IS_IN_BED + self._state = None + self._name = sleepiq.SENSOR_TYPES[self.type] + self.update() + + @property + def is_on(self): + """Return the status of the sensor.""" + return self._state is True + + @property + def sensor_class(self): + """Return the class of this sensor.""" + return "occupancy" + + def update(self): + """Get the latest data from SleepIQ and updates the states.""" + sleepiq.SleepIQSensor.update(self) + self._state = self.side.is_in_bed diff --git a/homeassistant/components/sensor/sleepiq.py b/homeassistant/components/sensor/sleepiq.py new file mode 100644 index 00000000000..ed4bf26ce07 --- /dev/null +++ b/homeassistant/components/sensor/sleepiq.py @@ -0,0 +1,58 @@ +""" +Support for SleepIQ sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.sleepiq/ +""" +from homeassistant.components import sleepiq + +DEPENDENCIES = ['sleepiq'] +ICON = 'mdi:hotel' + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the SleepIQ sensors.""" + if discovery_info is None: + return + + data = sleepiq.DATA + data.update() + + dev = list() + for bed_id, _ in data.beds.items(): + for side in sleepiq.SIDES: + dev.append(SleepNumberSensor(data, bed_id, side)) + add_devices(dev) + + +# pylint: disable=too-few-public-methods, too-many-instance-attributes +class SleepNumberSensor(sleepiq.SleepIQSensor): + """Implementation of a SleepIQ sensor.""" + + def __init__(self, sleepiq_data, bed_id, side): + """Initialize the sensor.""" + sleepiq.SleepIQSensor.__init__(self, + sleepiq_data, + bed_id, + side) + + self._state = None + self.type = sleepiq.SLEEP_NUMBER + self._name = sleepiq.SENSOR_TYPES[self.type] + + self.update() + + @property + def state(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.""" + sleepiq.SleepIQSensor.update(self) + self._state = self.side.sleep_number diff --git a/homeassistant/components/sleepiq.py b/homeassistant/components/sleepiq.py new file mode 100644 index 00000000000..cabd1f0050e --- /dev/null +++ b/homeassistant/components/sleepiq.py @@ -0,0 +1,130 @@ +""" +Support for SleepIQ from SleepNumber. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sleepiq/ +""" + +import logging +from datetime import timedelta + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import discovery +from homeassistant.helpers.entity import Entity +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD +from homeassistant.util import Throttle +from requests.exceptions import HTTPError + +DOMAIN = 'sleepiq' + +REQUIREMENTS = ['sleepyq==0.6'] + +# Return cached results if last scan was less then this time ago. +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) + +IS_IN_BED = 'is_in_bed' +SLEEP_NUMBER = 'sleep_number' +SENSOR_TYPES = { + SLEEP_NUMBER: 'SleepNumber', + IS_IN_BED: 'Is In Bed', +} + +LEFT = 'left' +RIGHT = 'right' +SIDES = [LEFT, RIGHT] + +_LOGGER = logging.getLogger(__name__) + +DATA = None + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Setup SleepIQ. + + Will automatically load sensor components to support + devices discovered on the account. + """ + # pylint: disable=global-statement + global DATA + + from sleepyq import Sleepyq + username = config[DOMAIN][CONF_USERNAME] + password = config[DOMAIN][CONF_PASSWORD] + client = Sleepyq(username, password) + try: + DATA = SleepIQData(client) + DATA.update() + except HTTPError: + message = """ + SleepIQ failed to login, double check your username and password" + """ + _LOGGER.error(message) + return False + + discovery.load_platform(hass, 'sensor', DOMAIN, {}, config) + discovery.load_platform(hass, 'binary_sensor', DOMAIN, {}, config) + + return True + + +# pylint: disable=too-few-public-methods +class SleepIQData(object): + """Gets the latest data from SleepIQ.""" + + def __init__(self, client): + """Initialize the data object.""" + self._client = client + self.beds = {} + + self.update() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + 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} + + +# pylint: disable=too-few-public-methods, too-many-instance-attributes +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) + + 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 thats 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) diff --git a/requirements_all.txt b/requirements_all.txt index 61507807534..97e65bc8edd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -433,6 +433,9 @@ slacker==0.9.25 # homeassistant.components.notify.xmpp sleekxmpp==1.3.1 +# homeassistant.components.sleepiq +sleepyq==0.6 + # homeassistant.components.media_player.snapcast snapcast==1.2.2 diff --git a/tests/components/binary_sensor/test_sleepiq.py b/tests/components/binary_sensor/test_sleepiq.py new file mode 100644 index 00000000000..1c270c36e29 --- /dev/null +++ b/tests/components/binary_sensor/test_sleepiq.py @@ -0,0 +1,50 @@ +"""The tests for SleepIQ binary_sensor platform.""" +import unittest +from unittest.mock import MagicMock + +import requests_mock + +from homeassistant import core as ha +from homeassistant.components.binary_sensor import sleepiq + +from tests.components.test_sleepiq import mock_responses + + +class TestSleepIQBinarySensorSetup(unittest.TestCase): + """Tests the SleepIQ Binary Sensor platform.""" + + DEVICES = [] + + def add_devices(self, devices): + """Mock add devices.""" + for device in devices: + self.DEVICES.append(device) + + def setUp(self): + """Initialize values for this testcase class.""" + self.hass = ha.HomeAssistant() + self.username = 'foo' + self.password = 'bar' + self.config = { + 'username': self.username, + 'password': self.password, + } + + @requests_mock.Mocker() + def test_setup(self, mock): + """Test for succesfully setting up the SleepIQ platform.""" + mock_responses(mock) + + sleepiq.setup_platform(self.hass, + self.config, + self.add_devices, + MagicMock()) + self.assertEqual(2, len(self.DEVICES)) + + left_side = self.DEVICES[1] + self.assertEqual('SleepNumber ILE Test1 Is In Bed', left_side.name) + self.assertEqual('on', left_side.state) + + right_side = self.DEVICES[0] + self.assertEqual('SleepNumber ILE Test2 Is In Bed', right_side.name) + self.assertEqual('off', right_side.state) diff --git a/tests/components/sensor/test_sleepiq.py b/tests/components/sensor/test_sleepiq.py new file mode 100644 index 00000000000..5802781ae75 --- /dev/null +++ b/tests/components/sensor/test_sleepiq.py @@ -0,0 +1,50 @@ +"""The tests for SleepIQ sensor platform.""" +import unittest +from unittest.mock import MagicMock + +import requests_mock + +from homeassistant import core as ha +from homeassistant.components.sensor import sleepiq + +from tests.components.test_sleepiq import mock_responses + + +class TestSleepIQSensorSetup(unittest.TestCase): + """Tests the SleepIQ Sensor platform.""" + + DEVICES = [] + + def add_devices(self, devices): + """Mock add devices.""" + for device in devices: + self.DEVICES.append(device) + + def setUp(self): + """Initialize values for this testcase class.""" + self.hass = ha.HomeAssistant() + self.username = 'foo' + self.password = 'bar' + self.config = { + 'username': self.username, + 'password': self.password, + } + + @requests_mock.Mocker() + def test_setup(self, mock): + """Test for succesfully setting up the SleepIQ platform.""" + mock_responses(mock) + + sleepiq.setup_platform(self.hass, + self.config, + self.add_devices, + MagicMock()) + self.assertEqual(2, len(self.DEVICES)) + + left_side = self.DEVICES[1] + self.assertEqual('SleepNumber ILE Test1 SleepNumber', left_side.name) + self.assertEqual(40, left_side.state) + + right_side = self.DEVICES[0] + self.assertEqual('SleepNumber ILE Test2 SleepNumber', right_side.name) + self.assertEqual(80, right_side.state) diff --git a/tests/components/test_sleepiq.py b/tests/components/test_sleepiq.py new file mode 100644 index 00000000000..ceccefde778 --- /dev/null +++ b/tests/components/test_sleepiq.py @@ -0,0 +1,75 @@ +"""The tests for the SleepIQ component.""" +import unittest +import requests_mock + +from homeassistant import bootstrap +import homeassistant.components.sleepiq as sleepiq + +from tests.common import load_fixture, get_test_home_assistant + + +def mock_responses(mock): + base_url = 'https://api.sleepiq.sleepnumber.com/rest/' + mock.put( + base_url + 'login', + text=load_fixture('sleepiq-login.json')) + mock.get( + base_url + 'bed?_k=0987', + text=load_fixture('sleepiq-bed.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('sleepiq-familystatus.json')) + + +class TestSleepIQ(unittest.TestCase): + """Tests the SleepIQ component.""" + + def setUp(self): + """Initialize values for this testcase class.""" + self.hass = get_test_home_assistant() + self.username = 'foo' + self.password = 'bar' + self.config = { + 'sleepiq': { + 'username': self.username, + 'password': self.password, + } + } + + def tearDown(self): # pylint: disable=invalid-name + """Stop everything that was started.""" + self.hass.stop() + + @requests_mock.Mocker() + def test_setup(self, mock): + """Test the setup.""" + mock_responses(mock) + + response = sleepiq.setup(self.hass, self.config) + self.assertTrue(response) + + @requests_mock.Mocker() + def test_setup_login_failed(self, mock): + """Test the setup if a bad username or password is given.""" + mock.put('https://api.sleepiq.sleepnumber.com/rest/login', + status_code=401, + json=load_fixture('sleepiq-login-failed.json')) + + response = sleepiq.setup(self.hass, self.config) + self.assertFalse(response) + + def test_setup_component_no_login(self): + """Test the setup when no login is configured.""" + conf = self.config.copy() + del conf['sleepiq']['username'] + assert not bootstrap._setup_component(self.hass, sleepiq.DOMAIN, conf) + + def test_setup_component_no_password(self): + """Test the setup when no password is configured.""" + conf = self.config.copy() + del conf['sleepiq']['password'] + + assert not bootstrap._setup_component(self.hass, sleepiq.DOMAIN, conf) diff --git a/tests/fixtures/sleepiq-bed.json b/tests/fixtures/sleepiq-bed.json new file mode 100644 index 00000000000..d03fb6e329f --- /dev/null +++ b/tests/fixtures/sleepiq-bed.json @@ -0,0 +1,28 @@ +{ + "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" + } + ] +} + diff --git a/tests/fixtures/sleepiq-familystatus.json b/tests/fixtures/sleepiq-familystatus.json new file mode 100644 index 00000000000..0c93d74d35f --- /dev/null +++ b/tests/fixtures/sleepiq-familystatus.json @@ -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 + } + } + ] +} diff --git a/tests/fixtures/sleepiq-login-failed.json b/tests/fixtures/sleepiq-login-failed.json new file mode 100644 index 00000000000..227609154b5 --- /dev/null +++ b/tests/fixtures/sleepiq-login-failed.json @@ -0,0 +1 @@ +{"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."}} diff --git a/tests/fixtures/sleepiq-login.json b/tests/fixtures/sleepiq-login.json new file mode 100644 index 00000000000..fdd8943574f --- /dev/null +++ b/tests/fixtures/sleepiq-login.json @@ -0,0 +1,7 @@ +{ + "edpLoginStatus" : 200, + "userId" : "-42", + "registrationState" : 13, + "key" : "0987", + "edpLoginMessage" : "not used" +} diff --git a/tests/fixtures/sleepiq-sleeper.json b/tests/fixtures/sleepiq-sleeper.json new file mode 100644 index 00000000000..4089e1b1d95 --- /dev/null +++ b/tests/fixtures/sleepiq-sleeper.json @@ -0,0 +1,55 @@ +{ + "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" + } + ] +} +