diff --git a/.coveragerc b/.coveragerc index 094d2dc193a4345b4f512034a93f743e8d66b4bb..e358dbcab75c00ca2e46b51e2b2c68efb8a28ee2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -158,6 +158,9 @@ omit = homeassistant/components/zha/const.py homeassistant/components/*/zha.py + homeassistant/components/eight_sleep.py + homeassistant/components/*/eight_sleep.py + homeassistant/components/alarm_control_panel/alarmdotcom.py homeassistant/components/alarm_control_panel/concord232.py homeassistant/components/alarm_control_panel/nx584.py diff --git a/homeassistant/components/binary_sensor/eight_sleep.py b/homeassistant/components/binary_sensor/eight_sleep.py new file mode 100644 index 0000000000000000000000000000000000000000..08f33997c3f7e7bdac74bba23b7b1c0e60411dad --- /dev/null +++ b/homeassistant/components/binary_sensor/eight_sleep.py @@ -0,0 +1,69 @@ +""" +Support for Eight Sleep binary sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.eight_sleep/ +""" +import logging +import asyncio + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.eight_sleep import ( + DATA_EIGHT, EightSleepHeatEntity, CONF_BINARY_SENSORS, NAME_MAP) + +DEPENDENCIES = ['eight_sleep'] + +_LOGGER = logging.getLogger(__name__) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Setup the eight sleep binary sensor.""" + if discovery_info is None: + return + + name = 'Eight' + sensors = discovery_info[CONF_BINARY_SENSORS] + eight = hass.data[DATA_EIGHT] + + all_sensors = [] + + for sensor in sensors: + all_sensors.append(EightHeatSensor(name, eight, sensor)) + + async_add_devices(all_sensors, True) + + +class EightHeatSensor(EightSleepHeatEntity, BinarySensorDevice): + """Representation of a eight sleep heat-based sensor.""" + + def __init__(self, name, eight, sensor): + """Initialize the sensor.""" + super().__init__(eight) + + self._sensor = sensor + self._mapped_name = NAME_MAP.get(self._sensor, self._sensor) + self._name = '{} {}'.format(name, self._mapped_name) + self._state = None + + self._side = self._sensor.split('_')[0] + self._userid = self._eight.fetch_userid(self._side) + self._usrobj = self._eight.users[self._userid] + + _LOGGER.debug('Presence Sensor: %s, Side: %s, User: %s', + self._sensor, self._side, self._userid) + + @property + def name(self): + """Return the name of the sensor, if any.""" + return self._name + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self._state + + @asyncio.coroutine + def async_update(self): + """Retrieve latest state.""" + self._state = self._usrobj.bed_presence diff --git a/homeassistant/components/eight_sleep.py b/homeassistant/components/eight_sleep.py new file mode 100644 index 0000000000000000000000000000000000000000..db718aec05e98349eca36302090d82ef0b485551 --- /dev/null +++ b/homeassistant/components/eight_sleep.py @@ -0,0 +1,241 @@ +""" +Support for Eight smart mattress covers and mattresses. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/eight_sleep/ +""" +import asyncio +import logging +import os +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.config import load_yaml_config_file +from homeassistant.const import ( + CONF_USERNAME, CONF_PASSWORD, CONF_SENSORS, CONF_BINARY_SENSORS, + ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_STOP) +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_send, async_dispatcher_connect) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.util.dt import utcnow + +REQUIREMENTS = ['pyeight==0.0.4'] + +_LOGGER = logging.getLogger(__name__) + +CONF_PARTNER = 'partner' + +DATA_EIGHT = 'eight_sleep' +DEFAULT_PARTNER = False +DOMAIN = 'eight_sleep' + +HEAT_ENTITY = 'heat' +USER_ENTITY = 'user' + +HEAT_SCAN_INTERVAL = timedelta(seconds=60) +USER_SCAN_INTERVAL = timedelta(seconds=300) + +SIGNAL_UPDATE_HEAT = 'eight_heat_update' +SIGNAL_UPDATE_USER = 'eight_user_update' + +NAME_MAP = { + 'left_current_sleep': 'Left Sleep Session', + 'left_last_sleep': 'Left Previous Sleep Session', + 'left_bed_state': 'Left Bed State', + 'left_presence': 'Left Bed Presence', + 'left_bed_temp': 'Left Bed Temperature', + 'left_sleep_stage': 'Left Sleep Stage', + 'right_current_sleep': 'Right Sleep Session', + 'right_last_sleep': 'Right Previous Sleep Session', + 'right_bed_state': 'Right Bed State', + 'right_presence': 'Right Bed Presence', + 'right_bed_temp': 'Right Bed Temperature', + 'right_sleep_stage': 'Right Sleep Stage', + 'room_temp': 'Room Temperature', +} + +SENSORS = ['current_sleep', + 'last_sleep', + 'bed_state', + 'bed_temp', + 'sleep_stage'] + +SERVICE_HEAT_SET = 'heat_set' + +ATTR_TARGET_HEAT = 'target' +ATTR_HEAT_DURATION = 'duration' + +VALID_TARGET_HEAT = vol.All(vol.Coerce(int), vol.Clamp(min=0, max=100)) +VALID_DURATION = vol.All(vol.Coerce(int), vol.Clamp(min=0, max=28800)) + +SERVICE_EIGHT_SCHEMA = vol.Schema({ + ATTR_ENTITY_ID: cv.entity_ids, + ATTR_TARGET_HEAT: VALID_TARGET_HEAT, + ATTR_HEAT_DURATION: VALID_DURATION, + }) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PARTNER, default=DEFAULT_PARTNER): cv.boolean, + }), +}, extra=vol.ALLOW_EXTRA) + + +@asyncio.coroutine +def async_setup(hass, config): + """Set up the Eight Sleep component.""" + from pyeight.eight import EightSleep + + conf = config.get(DOMAIN) + user = conf.get(CONF_USERNAME) + password = conf.get(CONF_PASSWORD) + partner = conf.get(CONF_PARTNER) + + if hass.config.time_zone is None: + _LOGGER.error('Timezone is not set in Home Assistant.') + return False + + timezone = hass.config.time_zone + + eight = EightSleep(user, password, timezone, partner, None, hass.loop) + + hass.data[DATA_EIGHT] = eight + + # Authenticate, build sensors + success = yield from eight.start() + if not success: + # Authentication failed, cannot continue + return False + + @asyncio.coroutine + def async_update_heat_data(now): + """Update heat data from eight in HEAT_SCAN_INTERVAL.""" + yield from eight.update_device_data() + async_dispatcher_send(hass, SIGNAL_UPDATE_HEAT) + + async_track_point_in_utc_time( + hass, async_update_heat_data, utcnow() + HEAT_SCAN_INTERVAL) + + @asyncio.coroutine + def async_update_user_data(now): + """Update user data from eight in USER_SCAN_INTERVAL.""" + yield from eight.update_user_data() + async_dispatcher_send(hass, SIGNAL_UPDATE_USER) + + async_track_point_in_utc_time( + hass, async_update_user_data, utcnow() + USER_SCAN_INTERVAL) + + yield from async_update_heat_data(None) + yield from async_update_user_data(None) + + # Load sub components + sensors = [] + binary_sensors = [] + if eight.users: + for user in eight.users: + obj = eight.users[user] + for sensor in SENSORS: + sensors.append('{}_{}'.format(obj.side, sensor)) + binary_sensors.append('{}_presence'.format(obj.side)) + sensors.append('room_temp') + + hass.async_add_job(discovery.async_load_platform( + hass, 'sensor', DOMAIN, { + CONF_SENSORS: sensors, + }, config)) + + hass.async_add_job(discovery.async_load_platform( + hass, 'binary_sensor', DOMAIN, { + CONF_BINARY_SENSORS: binary_sensors, + }, config)) + + descriptions = yield from hass.loop.run_in_executor( + None, load_yaml_config_file, + os.path.join(os.path.dirname(__file__), 'services.yaml')) + + @asyncio.coroutine + def async_service_handler(service): + """Handle eight sleep service calls.""" + params = service.data.copy() + + sensor = params.pop(ATTR_ENTITY_ID, None) + target = params.pop(ATTR_TARGET_HEAT, None) + duration = params.pop(ATTR_HEAT_DURATION, 0) + + for sens in sensor: + side = sens.split('_')[1] + userid = eight.fetch_userid(side) + usrobj = eight.users[userid] + yield from usrobj.set_heating_level(target, duration) + + async_dispatcher_send(hass, SIGNAL_UPDATE_HEAT) + + # Register services + hass.services.async_register( + DOMAIN, SERVICE_HEAT_SET, async_service_handler, + descriptions[DOMAIN].get(SERVICE_HEAT_SET), + schema=SERVICE_EIGHT_SCHEMA) + + @asyncio.coroutine + def stop_eight(event): + """Handle stopping eight api session.""" + yield from eight.stop() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_eight) + + return True + + +class EightSleepUserEntity(Entity): + """The Eight Sleep device entity.""" + + def __init__(self, eight): + """Initialize the data oject.""" + self._eight = eight + + @asyncio.coroutine + def async_added_to_hass(self): + """Register update dispatcher.""" + @callback + def async_eight_user_update(): + """Update callback.""" + self.hass.async_add_job(self.async_update_ha_state(True)) + + async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_USER, async_eight_user_update) + + @property + def should_poll(self): + """Return True if entity has to be polled for state.""" + return False + + +class EightSleepHeatEntity(Entity): + """The Eight Sleep device entity.""" + + def __init__(self, eight): + """Initialize the data oject.""" + self._eight = eight + + @asyncio.coroutine + def async_added_to_hass(self): + """Register update dispatcher.""" + @callback + def async_eight_heat_update(): + """Update callback.""" + self.hass.async_add_job(self.async_update_ha_state(True)) + + async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_HEAT, async_eight_heat_update) + + @property + def should_poll(self): + """Return True if entity has to be polled for state.""" + return False diff --git a/homeassistant/components/sensor/eight_sleep.py b/homeassistant/components/sensor/eight_sleep.py new file mode 100644 index 0000000000000000000000000000000000000000..e7d3e2407017906c343a0415d5669713b5b7c9ed --- /dev/null +++ b/homeassistant/components/sensor/eight_sleep.py @@ -0,0 +1,273 @@ +""" +Support for Eight Sleep sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.eight_sleep/ +""" +import logging +import asyncio + +from homeassistant.components.eight_sleep import ( + DATA_EIGHT, EightSleepHeatEntity, EightSleepUserEntity, + CONF_SENSORS, NAME_MAP) + +DEPENDENCIES = ['eight_sleep'] + +ATTR_ROOM_TEMP = 'Room Temperature' +ATTR_AVG_ROOM_TEMP = 'Average Room Temperature' +ATTR_BED_TEMP = 'Bed Temperature' +ATTR_AVG_BED_TEMP = 'Average Bed Temperature' +ATTR_RESP_RATE = 'Respiratory Rate' +ATTR_AVG_RESP_RATE = 'Average Respiratory Rate' +ATTR_HEART_RATE = 'Heart Rate' +ATTR_AVG_HEART_RATE = 'Average Heart Rate' +ATTR_SLEEP_DUR = 'Time Slept' +ATTR_LIGHT_PERC = 'Light Sleep %' +ATTR_DEEP_PERC = 'Deep Sleep %' +ATTR_TNT = 'Tosses & Turns' +ATTR_SLEEP_STAGE = 'Sleep Stage' +ATTR_TARGET_HEAT = 'Target Heating Level' +ATTR_ACTIVE_HEAT = 'Heating Active' +ATTR_DURATION_HEAT = 'Heating Time Remaining' +ATTR_LAST_SEEN = 'Last In Bed' +ATTR_PROCESSING = 'Processing' +ATTR_SESSION_START = 'Session Start' + +_LOGGER = logging.getLogger(__name__) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Setup the eight sleep sensors.""" + if discovery_info is None: + return + + name = 'Eight' + sensors = discovery_info[CONF_SENSORS] + eight = hass.data[DATA_EIGHT] + + if hass.config.units.is_metric: + units = 'si' + else: + units = 'us' + + all_sensors = [] + + for sensor in sensors: + if 'bed_state' in sensor: + all_sensors.append(EightHeatSensor(name, eight, sensor)) + elif 'room_temp' in sensor: + all_sensors.append(EightRoomSensor(name, eight, sensor, units)) + else: + all_sensors.append(EightUserSensor(name, eight, sensor, units)) + + async_add_devices(all_sensors, True) + + +class EightHeatSensor(EightSleepHeatEntity): + """Representation of a eight sleep heat-based sensor.""" + + def __init__(self, name, eight, sensor): + """Initialize the sensor.""" + super().__init__(eight) + + self._sensor = sensor + self._mapped_name = NAME_MAP.get(self._sensor, self._sensor) + self._name = '{} {}'.format(name, self._mapped_name) + self._state = None + + self._side = self._sensor.split('_')[0] + self._userid = self._eight.fetch_userid(self._side) + self._usrobj = self._eight.users[self._userid] + + _LOGGER.debug('Heat Sensor: %s, Side: %s, User: %s', + self._sensor, self._side, self._userid) + + @property + def name(self): + """Return the name of the sensor, if any.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return '%' + + @asyncio.coroutine + def async_update(self): + """Retrieve latest state.""" + _LOGGER.debug('Updating Heat sensor: %s', self._sensor) + self._state = self._usrobj.heating_level + + @property + def device_state_attributes(self): + """Return device state attributes.""" + state_attr = {ATTR_TARGET_HEAT: self._usrobj.target_heating_level} + state_attr[ATTR_ACTIVE_HEAT] = self._usrobj.now_heating + state_attr[ATTR_DURATION_HEAT] = self._usrobj.heating_remaining + state_attr[ATTR_LAST_SEEN] = self._usrobj.last_seen + + return state_attr + + +class EightUserSensor(EightSleepUserEntity): + """Representation of a eight sleep user-based sensor.""" + + def __init__(self, name, eight, sensor, units): + """Initialize the sensor.""" + super().__init__(eight) + + self._sensor = sensor + self._sensor_root = self._sensor.split('_', 1)[1] + self._mapped_name = NAME_MAP.get(self._sensor, self._sensor) + self._name = '{} {}'.format(name, self._mapped_name) + self._state = None + self._attr = None + self._units = units + + self._side = self._sensor.split('_', 1)[0] + self._userid = self._eight.fetch_userid(self._side) + self._usrobj = self._eight.users[self._userid] + + _LOGGER.debug('User Sensor: %s, Side: %s, User: %s', + self._sensor, self._side, self._userid) + + @property + def name(self): + """Return the name of the sensor, if any.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + if 'current_sleep' in self._sensor or 'last_sleep' in self._sensor: + return 'Score' + elif 'bed_temp' in self._sensor: + if self._units == 'si': + return '°C' + else: + return '°F' + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + if 'bed_temp' in self._sensor: + return 'mdi:thermometer' + + @asyncio.coroutine + def async_update(self): + """Retrieve latest state.""" + _LOGGER.debug('Updating User sensor: %s', self._sensor) + if 'current' in self._sensor: + self._state = self._usrobj.current_sleep_score + self._attr = self._usrobj.current_values + elif 'last' in self._sensor: + self._state = self._usrobj.last_sleep_score + self._attr = self._usrobj.last_values + elif 'bed_temp' in self._sensor: + temp = self._usrobj.current_values['bed_temp'] + if self._units == 'si': + self._state = round(temp, 2) + else: + self._state = round((temp*1.8)+32, 2) + elif 'sleep_stage' in self._sensor: + self._state = self._usrobj.current_values['stage'] + + @property + def device_state_attributes(self): + """Return device state attributes.""" + if self._attr is None: + # Skip attributes if sensor type doesn't support + return None + + state_attr = {ATTR_SESSION_START: self._attr['date']} + state_attr[ATTR_TNT] = self._attr['tnt'] + state_attr[ATTR_PROCESSING] = self._attr['processing'] + + sleep_time = sum(self._attr['breakdown'].values()) - \ + self._attr['breakdown']['awake'] + state_attr[ATTR_SLEEP_DUR] = sleep_time + state_attr[ATTR_LIGHT_PERC] = round(( + self._attr['breakdown']['light'] / sleep_time) * 100, 2) + state_attr[ATTR_DEEP_PERC] = round(( + self._attr['breakdown']['deep'] / sleep_time) * 100, 2) + + if self._units == 'si': + room_temp = round(self._attr['room_temp'], 2) + bed_temp = round(self._attr['bed_temp'], 2) + else: + room_temp = round((self._attr['room_temp']*1.8)+32, 2) + bed_temp = round((self._attr['bed_temp']*1.8)+32, 2) + + if 'current' in self._sensor_root: + state_attr[ATTR_RESP_RATE] = round(self._attr['resp_rate'], 2) + state_attr[ATTR_HEART_RATE] = round(self._attr['heart_rate'], 2) + state_attr[ATTR_SLEEP_STAGE] = self._attr['stage'] + state_attr[ATTR_ROOM_TEMP] = room_temp + state_attr[ATTR_BED_TEMP] = bed_temp + elif 'last' in self._sensor_root: + state_attr[ATTR_AVG_RESP_RATE] = round(self._attr['resp_rate'], 2) + state_attr[ATTR_AVG_HEART_RATE] = round( + self._attr['heart_rate'], 2) + state_attr[ATTR_AVG_ROOM_TEMP] = room_temp + state_attr[ATTR_AVG_BED_TEMP] = bed_temp + + return state_attr + + +class EightRoomSensor(EightSleepUserEntity): + """Representation of a eight sleep room sensor.""" + + def __init__(self, name, eight, sensor, units): + """Initialize the sensor.""" + super().__init__(eight) + + self._sensor = sensor + self._mapped_name = NAME_MAP.get(self._sensor, self._sensor) + self._name = '{} {}'.format(name, self._mapped_name) + self._state = None + self._attr = None + self._units = units + + @property + def name(self): + """Return the name of the sensor, if any.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @asyncio.coroutine + def async_update(self): + """Retrieve latest state.""" + _LOGGER.debug('Updating Room sensor: %s', self._sensor) + temp = self._eight.room_temperature() + if self._units == 'si': + self._state = round(temp, 2) + else: + self._state = round((temp*1.8)+32, 2) + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + if self._units == 'si': + return '°C' + else: + return '°F' + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return 'mdi:thermometer' diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index 6cff6d5f4f45801cf3beaa0f5b6bfed87ede6540..0807eb617eebed71f2a2ca3b7ba29f6c3c56d4c6 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -388,3 +388,17 @@ hassio: addon: description: Name of addon. example: 'smb_config' + +eight_sleep: + heat_set: + description: Set heating level for eight sleep. + fields: + entity_id: + description: Entity id of the bed state to adjust. + example: 'sensor.eight_left_bed_state' + target: + description: Target heating level from 0-100. + example: 35 + duration: + description: Duration to heat at the target level in seconds. + example: 3600 diff --git a/requirements_all.txt b/requirements_all.txt index 03bebd8ddaf51602bab740d812776fdbb761dfe9..5ee8aa61ef52a7c95bd5e802c62b6489cc5ddd71 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -530,6 +530,9 @@ pydroid-ipcam==0.8 # homeassistant.components.sensor.ebox pyebox==0.1.0 +# homeassistant.components.eight_sleep +pyeight==0.0.4 + # homeassistant.components.notify.html5 pyelliptic==1.5.7