diff --git a/.coveragerc b/.coveragerc index 530d7832d81..48ea0375587 100644 --- a/.coveragerc +++ b/.coveragerc @@ -101,18 +101,29 @@ omit = homeassistant/components/alarm_control_panel/nx584.py homeassistant/components/alarm_control_panel/simplisafe.py homeassistant/components/binary_sensor/arest.py + homeassistant/components/binary_sensor/ffmpeg.py homeassistant/components/binary_sensor/rest.py homeassistant/components/browser.py homeassistant/components/camera/bloomsky.py homeassistant/components/camera/ffmpeg.py homeassistant/components/camera/foscam.py - homeassistant/components/camera/generic.py homeassistant/components/camera/mjpeg.py homeassistant/components/camera/rpi_camera.py + homeassistant/components/climate/eq3btsmart.py + homeassistant/components/climate/heatmiser.py + homeassistant/components/climate/homematic.py + homeassistant/components/climate/knx.py + homeassistant/components/climate/proliphix.py + homeassistant/components/climate/radiotherm.py + homeassistant/components/cover/homematic.py + homeassistant/components/cover/rpi_gpio.py + homeassistant/components/cover/scsgate.py + homeassistant/components/cover/wink.py homeassistant/components/device_tracker/actiontec.py homeassistant/components/device_tracker/aruba.py homeassistant/components/device_tracker/asuswrt.py homeassistant/components/device_tracker/bluetooth_tracker.py + homeassistant/components/device_tracker/bluetooth_le_tracker.py homeassistant/components/device_tracker/bt_home_hub_5.py homeassistant/components/device_tracker/ddwrt.py homeassistant/components/device_tracker/fritz.py @@ -173,8 +184,10 @@ omit = homeassistant/components/notify/aws_sqs.py homeassistant/components/notify/free_mobile.py homeassistant/components/notify/gntp.py + homeassistant/components/notify/group.py homeassistant/components/notify/instapush.py homeassistant/components/notify/joaoapps_join.py + homeassistant/components/notify/llamalab_automate.py homeassistant/components/notify/message_bird.py homeassistant/components/notify/nma.py homeassistant/components/notify/pushbullet.py @@ -202,13 +215,17 @@ omit = homeassistant/components/sensor/fitbit.py homeassistant/components/sensor/fixer.py homeassistant/components/sensor/forecast.py + homeassistant/components/sensor/fritzbox_callmonitor.py homeassistant/components/sensor/glances.py homeassistant/components/sensor/google_travel_time.py homeassistant/components/sensor/gpsd.py homeassistant/components/sensor/gtfs.py + homeassistant/components/sensor/hp_ilo.py homeassistant/components/sensor/imap.py homeassistant/components/sensor/lastfm.py homeassistant/components/sensor/loopenergy.py + homeassistant/components/sensor/mhz19.py + homeassistant/components/sensor/mqtt_room.py homeassistant/components/sensor/neurio_energy.py homeassistant/components/sensor/nzbget.py homeassistant/components/sensor/ohmconnect.py diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index c7fb9096a56..4b526c40b38 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -19,6 +19,7 @@ import homeassistant.config as conf_util import homeassistant.core as core import homeassistant.loader as loader import homeassistant.util.package as pkg_util +from homeassistant.util.yaml import clear_secret_cache from homeassistant.const import EVENT_COMPONENT_LOADED, PLATFORM_FORMAT from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( @@ -103,7 +104,7 @@ def _setup_component(hass: core.HomeAssistant, domain: str, config) -> bool: try: config = component.CONFIG_SCHEMA(config) except vol.MultipleInvalid as ex: - _log_exception(ex, domain, config) + log_exception(ex, domain, config) return False elif hasattr(component, 'PLATFORM_SCHEMA'): @@ -113,7 +114,7 @@ def _setup_component(hass: core.HomeAssistant, domain: str, config) -> bool: try: p_validated = component.PLATFORM_SCHEMA(p_config) except vol.MultipleInvalid as ex: - _log_exception(ex, domain, p_config) + log_exception(ex, domain, p_config) return False # Not all platform components follow same pattern for platforms @@ -134,8 +135,8 @@ def _setup_component(hass: core.HomeAssistant, domain: str, config) -> bool: try: p_validated = platform.PLATFORM_SCHEMA(p_validated) except vol.MultipleInvalid as ex: - _log_exception(ex, '{}.{}'.format(domain, p_name), - p_validated) + log_exception(ex, '{}.{}'.format(domain, p_name), + p_validated) return False platforms.append(p_validated) @@ -239,7 +240,7 @@ def from_config_dict(config: Dict[str, Any], try: conf_util.process_ha_core_config(hass, core_config) except vol.Invalid as ex: - _log_exception(ex, 'homeassistant', core_config) + log_exception(ex, 'homeassistant', core_config) return None conf_util.process_ha_config_upgrade(hass) @@ -308,6 +309,8 @@ def from_config_file(config_path: str, config_dict = conf_util.load_yaml_config_file(config_path) except HomeAssistantError: return None + finally: + clear_secret_cache() return from_config_dict(config_dict, hass, enable_log=False, skip_pip=skip_pip) @@ -371,7 +374,7 @@ def _ensure_loader_prepared(hass: core.HomeAssistant) -> None: loader.prepare(hass) -def _log_exception(ex, domain, config): +def log_exception(ex, domain, config): """Generate log exception for config validation.""" message = 'Invalid config for [{}]: '.format(domain) if 'extra keys not allowed' in ex.error_message: diff --git a/homeassistant/components/alarm_control_panel/alarmdotcom.py b/homeassistant/components/alarm_control_panel/alarmdotcom.py index 385cabb7d02..542cb5e3d02 100644 --- a/homeassistant/components/alarm_control_panel/alarmdotcom.py +++ b/homeassistant/components/alarm_control_panel/alarmdotcom.py @@ -80,7 +80,7 @@ class AlarmDotCom(alarm.AlarmControlPanel): def alarm_disarm(self, code=None): """Send disarm command.""" - if not self._validate_code(code, 'arming home'): + if not self._validate_code(code, 'disarming home'): return from pyalarmdotcom.pyalarmdotcom import Alarmdotcom # Open another session to alarm.com to fire off the command diff --git a/homeassistant/components/alarm_control_panel/demo.py b/homeassistant/components/alarm_control_panel/demo.py index 9ac98924b2b..ccbe3e72e3c 100644 --- a/homeassistant/components/alarm_control_panel/demo.py +++ b/homeassistant/components/alarm_control_panel/demo.py @@ -10,5 +10,5 @@ import homeassistant.components.alarm_control_panel.manual as manual def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Demo alarm control panel platform.""" add_devices([ - manual.ManualAlarm(hass, 'Alarm', '1234', 5, 10), + manual.ManualAlarm(hass, 'Alarm', '1234', 5, 10, False), ]) diff --git a/homeassistant/components/alarm_control_panel/manual.py b/homeassistant/components/alarm_control_panel/manual.py index 3e904601638..a95eff20e1f 100644 --- a/homeassistant/components/alarm_control_panel/manual.py +++ b/homeassistant/components/alarm_control_panel/manual.py @@ -7,28 +7,46 @@ https://home-assistant.io/components/alarm_control_panel.manual/ import datetime import logging +import voluptuous as vol + import homeassistant.components.alarm_control_panel as alarm import homeassistant.util.dt as dt_util from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, - STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED) + STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, CONF_PLATFORM, CONF_NAME, + CONF_CODE, CONF_PENDING_TIME, CONF_TRIGGER_TIME, CONF_DISARM_AFTER_TRIGGER) +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import track_point_in_time -_LOGGER = logging.getLogger(__name__) - DEFAULT_ALARM_NAME = 'HA Alarm' DEFAULT_PENDING_TIME = 60 DEFAULT_TRIGGER_TIME = 120 +DEFAULT_DISARM_AFTER_TRIGGER = False + +PLATFORM_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): 'manual', + vol.Optional(CONF_NAME, default=DEFAULT_ALARM_NAME): cv.string, + vol.Optional(CONF_CODE): cv.string, + vol.Optional(CONF_PENDING_TIME, default=DEFAULT_PENDING_TIME): + vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.Optional(CONF_TRIGGER_TIME, default=DEFAULT_TRIGGER_TIME): + vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.Optional(CONF_DISARM_AFTER_TRIGGER, + default=DEFAULT_DISARM_AFTER_TRIGGER): cv.boolean, +}) + +_LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the manual alarm platform.""" add_devices([ManualAlarm( hass, - config.get('name', DEFAULT_ALARM_NAME), - config.get('code'), - config.get('pending_time', DEFAULT_PENDING_TIME), - config.get('trigger_time', DEFAULT_TRIGGER_TIME), + config[CONF_NAME], + config.get(CONF_CODE), + config.get(CONF_PENDING_TIME, DEFAULT_PENDING_TIME), + config.get(CONF_TRIGGER_TIME, DEFAULT_TRIGGER_TIME), + config.get(CONF_DISARM_AFTER_TRIGGER, DEFAULT_DISARM_AFTER_TRIGGER) )]) @@ -40,10 +58,12 @@ class ManualAlarm(alarm.AlarmControlPanel): When armed, will be pending for 'pending_time', after that armed. When triggered, will be pending for 'trigger_time'. After that will be - triggered for 'trigger_time', after that we return to disarmed. + triggered for 'trigger_time', after that we return to the previous state + or disarm if `disarm_after_trigger` is true. """ - def __init__(self, hass, name, code, pending_time, trigger_time): + def __init__(self, hass, name, code, pending_time, + trigger_time, disarm_after_trigger): """Initalize the manual alarm panel.""" self._state = STATE_ALARM_DISARMED self._hass = hass @@ -51,6 +71,8 @@ class ManualAlarm(alarm.AlarmControlPanel): self._code = str(code) if code else None self._pending_time = datetime.timedelta(seconds=pending_time) self._trigger_time = datetime.timedelta(seconds=trigger_time) + self._disarm_after_trigger = disarm_after_trigger + self._pre_trigger_state = self._state self._state_ts = None @property @@ -77,7 +99,10 @@ class ManualAlarm(alarm.AlarmControlPanel): return STATE_ALARM_PENDING elif (self._state_ts + self._pending_time + self._trigger_time) < dt_util.utcnow(): - return STATE_ALARM_DISARMED + if self._disarm_after_trigger: + return STATE_ALARM_DISARMED + else: + return self._pre_trigger_state return self._state @@ -125,6 +150,7 @@ class ManualAlarm(alarm.AlarmControlPanel): def alarm_trigger(self, code=None): """Send alarm trigger command. No code needed.""" + self._pre_trigger_state = self._state self._state = STATE_ALARM_TRIGGERED self._state_ts = dt_util.utcnow() self.update_ha_state() diff --git a/homeassistant/components/alexa.py b/homeassistant/components/alexa.py index 3a41d51419b..969c20583ee 100644 --- a/homeassistant/components/alexa.py +++ b/homeassistant/components/alexa.py @@ -11,17 +11,17 @@ from homeassistant.const import HTTP_BAD_REQUEST from homeassistant.helpers import template, script from homeassistant.components.http import HomeAssistantView -DOMAIN = 'alexa' -DEPENDENCIES = ['http'] - _LOGGER = logging.getLogger(__name__) API_ENDPOINT = '/api/alexa' -CONF_INTENTS = 'intents' -CONF_CARD = 'card' -CONF_SPEECH = 'speech' CONF_ACTION = 'action' +CONF_CARD = 'card' +CONF_INTENTS = 'intents' +CONF_SPEECH = 'speech' + +DOMAIN = 'alexa' +DEPENDENCIES = ['http'] def setup(hass, config): diff --git a/homeassistant/components/arduino.py b/homeassistant/components/arduino.py index 0a981940842..73bd7a51dad 100644 --- a/homeassistant/components/arduino.py +++ b/homeassistant/components/arduino.py @@ -6,27 +6,34 @@ https://home-assistant.io/components/arduino/ """ import logging +import voluptuous as vol + from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) -from homeassistant.helpers import validate_config +from homeassistant.const import CONF_PORT +import homeassistant.helpers.config_validation as cv -DOMAIN = "arduino" REQUIREMENTS = ['PyMata==2.12'] -BOARD = None + _LOGGER = logging.getLogger(__name__) +BOARD = None + +DOMAIN = 'arduino' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_PORT): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + def setup(hass, config): """Setup the Arduino component.""" - if not validate_config(config, - {DOMAIN: ['port']}, - _LOGGER): - return False - import serial global BOARD try: - BOARD = ArduinoBoard(config[DOMAIN]['port']) + BOARD = ArduinoBoard(config[DOMAIN][CONF_PORT]) except (serial.serialutil.SerialException, FileNotFoundError): _LOGGER.exception("Your port is not accessible.") return False diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index 18cb8ea0cb2..2f751683265 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -6,6 +6,8 @@ https://home-assistant.io/components/binary_sensor/ """ import logging +import voluptuous as vol + from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity import Entity from homeassistant.const import (STATE_ON, STATE_OFF) @@ -33,6 +35,8 @@ SENSOR_CLASSES = [ 'vibration', # On means vibration detected, Off means no vibration ] +SENSOR_CLASSES_SCHEMA = vol.All(vol.Lower, vol.In(SENSOR_CLASSES)) + def setup(hass, config): """Track states and offer events for binary sensors.""" diff --git a/homeassistant/components/binary_sensor/ecobee.py b/homeassistant/components/binary_sensor/ecobee.py new file mode 100644 index 00000000000..09cbfd852e3 --- /dev/null +++ b/homeassistant/components/binary_sensor/ecobee.py @@ -0,0 +1,72 @@ +""" +Support for Ecobee sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.ecobee/ +""" +from homeassistant.components import ecobee +from homeassistant.components.binary_sensor import BinarySensorDevice + +DEPENDENCIES = ['ecobee'] + +ECOBEE_CONFIG_FILE = 'ecobee.conf' + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Ecobee sensors.""" + if discovery_info is None: + return + data = ecobee.NETWORK + dev = list() + for index in range(len(data.ecobee.thermostats)): + for sensor in data.ecobee.get_remote_sensors(index): + for item in sensor['capability']: + if item['type'] != 'occupancy': + continue + + dev.append(EcobeeBinarySensor(sensor['name'], index)) + + add_devices(dev) + + +class EcobeeBinarySensor(BinarySensorDevice): + """Representation of an Ecobee sensor.""" + + def __init__(self, sensor_name, sensor_index): + """Initialize the sensor.""" + self._name = sensor_name + ' Occupancy' + self.sensor_name = sensor_name + self.index = sensor_index + self._state = None + self._sensor_class = 'motion' + self.update() + + @property + def name(self): + """Return the name of the Ecobee sensor.""" + return self._name.rstrip() + + @property + def is_on(self): + """Return the status of the sensor.""" + return self._state == 'true' + + @property + def unique_id(self): + """Return the unique ID of this sensor.""" + return "binary_sensor_ecobee_{}_{}".format(self._name, self.index) + + @property + def sensor_class(self): + """Return the class of this sensor, from SENSOR_CLASSES.""" + return self._sensor_class + + def update(self): + """Get the latest state of the sensor.""" + data = ecobee.NETWORK + data.update() + for sensor in data.ecobee.get_remote_sensors(self.index): + for item in sensor['capability']: + if (item['type'] == 'occupancy' and + self.sensor_name == sensor['name']): + self._state = item['value'] diff --git a/homeassistant/components/binary_sensor/enocean.py b/homeassistant/components/binary_sensor/enocean.py index 12f073f9e85..631ed0021e1 100644 --- a/homeassistant/components/binary_sensor/enocean.py +++ b/homeassistant/components/binary_sensor/enocean.py @@ -4,27 +4,41 @@ Support for EnOcean binary sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.enocean/ """ +import logging -from homeassistant.components.binary_sensor import BinarySensorDevice +import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA, SENSOR_CLASSES_SCHEMA) from homeassistant.components import enocean -from homeassistant.const import CONF_NAME +from homeassistant.const import (CONF_NAME, CONF_ID, CONF_SENSOR_CLASS) +import homeassistant.helpers.config_validation as cv -DEPENDENCIES = ["enocean"] +_LOGGER = logging.getLogger(__name__) -CONF_ID = "id" +DEPENDENCIES = ['enocean'] +DEFAULT_NAME = 'EnOcean binary sensor' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ID): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_SENSOR_CLASS, default=None): SENSOR_CLASSES_SCHEMA, +}) def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Binary Sensor platform fo EnOcean.""" - dev_id = config.get(CONF_ID, None) - devname = config.get(CONF_NAME, "EnOcean binary sensor") - add_devices([EnOceanBinarySensor(dev_id, devname)]) + dev_id = config.get(CONF_ID) + devname = config.get(CONF_NAME) + sensor_class = config.get(CONF_SENSOR_CLASS) + + add_devices([EnOceanBinarySensor(dev_id, devname, sensor_class)]) class EnOceanBinarySensor(enocean.EnOceanDevice, BinarySensorDevice): """Representation of EnOcean binary sensors such as wall switches.""" - def __init__(self, dev_id, devname): + def __init__(self, dev_id, devname, sensor_class): """Initialize the EnOcean binary sensor.""" enocean.EnOceanDevice.__init__(self) self.stype = "listener" @@ -32,12 +46,18 @@ class EnOceanBinarySensor(enocean.EnOceanDevice, BinarySensorDevice): self.which = -1 self.onoff = -1 self.devname = devname + self._sensor_class = sensor_class @property def name(self): """The default name for the binary sensor.""" return self.devname + @property + def sensor_class(self): + """Return the class of this sensor.""" + return self._sensor_class + def value_changed(self, value, value2): """Fire an event with the data that have changed. diff --git a/homeassistant/components/binary_sensor/ffmpeg.py b/homeassistant/components/binary_sensor/ffmpeg.py new file mode 100644 index 00000000000..be4d595dcb9 --- /dev/null +++ b/homeassistant/components/binary_sensor/ffmpeg.py @@ -0,0 +1,215 @@ +""" +Provides a binary sensor which is a collection of ffmpeg tools. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.ffmpeg/ +""" +import logging +from os import path + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.binary_sensor import (BinarySensorDevice, + PLATFORM_SCHEMA, DOMAIN) +from homeassistant.config import load_yaml_config_file +from homeassistant.const import (EVENT_HOMEASSISTANT_STOP, CONF_NAME, + ATTR_ENTITY_ID) + +REQUIREMENTS = ["ha-ffmpeg==0.8"] + +SERVICE_RESTART = 'ffmpeg_restart' + +FFMPEG_SENSOR_NOISE = 'noise' +FFMPEG_SENSOR_MOTION = 'motion' + +MAP_FFMPEG_BIN = [ + FFMPEG_SENSOR_NOISE, + FFMPEG_SENSOR_MOTION +] + +CONF_TOOL = 'tool' +CONF_INPUT = 'input' +CONF_FFMPEG_BIN = 'ffmpeg_bin' +CONF_EXTRA_ARGUMENTS = 'extra_arguments' +CONF_OUTPUT = 'output' +CONF_PEAK = 'peak' +CONF_DURATION = 'duration' +CONF_RESET = 'reset' +CONF_CHANGES = 'changes' +CONF_REPEAT = 'repeat' +CONF_REPEAT_TIME = 'repeat_time' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_TOOL): vol.In(MAP_FFMPEG_BIN), + vol.Required(CONF_INPUT): cv.string, + vol.Optional(CONF_FFMPEG_BIN, default="ffmpeg"): cv.string, + vol.Optional(CONF_NAME, default="FFmpeg"): cv.string, + vol.Optional(CONF_EXTRA_ARGUMENTS): cv.string, + vol.Optional(CONF_OUTPUT): cv.string, + vol.Optional(CONF_PEAK, default=-30): vol.Coerce(int), + vol.Optional(CONF_DURATION, default=1): + vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.Optional(CONF_RESET, default=10): + vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.Optional(CONF_CHANGES, default=10): + vol.All(vol.Coerce(float), vol.Range(min=0, max=99)), + vol.Optional(CONF_REPEAT, default=0): + vol.All(vol.Coerce(int), vol.Range(min=0)), + vol.Optional(CONF_REPEAT_TIME, default=0): + vol.All(vol.Coerce(int), vol.Range(min=0)), +}) + +SERVICE_RESTART_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, +}) + + +# list of all ffmpeg sensors +DEVICES = [] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Create the binary sensor.""" + from haffmpeg import SensorNoise, SensorMotion + + if config.get(CONF_TOOL) == FFMPEG_SENSOR_NOISE: + entity = FFmpegNoise(SensorNoise, config) + else: + entity = FFmpegMotion(SensorMotion, config) + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, entity.shutdown_ffmpeg) + + # add to system + add_entities([entity]) + DEVICES.append(entity) + + # exists service? + if hass.services.has_service(DOMAIN, SERVICE_RESTART): + return True + + descriptions = load_yaml_config_file( + path.join(path.dirname(__file__), 'services.yaml')) + + # register service + def _service_handle_restart(service): + """Handle service binary_sensor.ffmpeg_restart.""" + entity_ids = service.data.get('entity_id') + + if entity_ids: + _devices = [device for device in DEVICES + if device.entity_id in entity_ids] + else: + _devices = DEVICES + + for device in _devices: + device.reset_ffmpeg() + + hass.services.register(DOMAIN, SERVICE_RESTART, + _service_handle_restart, + descriptions.get(SERVICE_RESTART), + schema=SERVICE_RESTART_SCHEMA) + return True + + +class FFmpegBinarySensor(BinarySensorDevice): + """A binary sensor which use ffmpeg for noise detection.""" + + def __init__(self, ffobj, config): + """Constructor for binary sensor noise detection.""" + self._state = False + self._config = config + self._name = config.get(CONF_NAME) + self._ffmpeg = ffobj(config.get(CONF_FFMPEG_BIN), self._callback) + + self._start_ffmpeg(config) + + def _callback(self, state): + """HA-FFmpeg callback for noise detection.""" + self._state = state + self.update_ha_state() + + def _start_ffmpeg(self, config): + """Start a FFmpeg instance.""" + raise NotImplementedError + + def shutdown_ffmpeg(self, event): + """For STOP event to shutdown ffmpeg.""" + self._ffmpeg.close() + + def reset_ffmpeg(self): + """Restart ffmpeg with new config.""" + self._ffmpeg.close() + self._start_ffmpeg(self._config) + + @property + def is_on(self): + """True if the binary sensor is on.""" + return self._state + + @property + def should_poll(self): + """Return True if entity has to be polled for state.""" + return False + + @property + def name(self): + """Return the name of the entity.""" + return self._name + + @property + def available(self): + """Return True if entity is available.""" + return self._ffmpeg.is_running + + +class FFmpegNoise(FFmpegBinarySensor): + """A binary sensor which use ffmpeg for noise detection.""" + + def _start_ffmpeg(self, config): + """Start a FFmpeg instance.""" + # init config + self._ffmpeg.set_options( + time_duration=config.get(CONF_DURATION), + time_reset=config.get(CONF_RESET), + peak=config.get(CONF_PEAK), + ) + + # run + self._ffmpeg.open_sensor( + input_source=config.get(CONF_INPUT), + output_dest=config.get(CONF_OUTPUT), + extra_cmd=config.get(CONF_EXTRA_ARGUMENTS), + ) + + @property + def sensor_class(self): + """Return the class of this sensor, from SENSOR_CLASSES.""" + return "sound" + + +class FFmpegMotion(FFmpegBinarySensor): + """A binary sensor which use ffmpeg for noise detection.""" + + def _start_ffmpeg(self, config): + """Start a FFmpeg instance.""" + # init config + self._ffmpeg.set_options( + time_reset=config.get(CONF_RESET), + time_repeat=config.get(CONF_REPEAT_TIME), + repeat=config.get(CONF_REPEAT), + changes=config.get(CONF_CHANGES), + ) + + # run + self._ffmpeg.open_sensor( + input_source=config.get(CONF_INPUT), + extra_cmd=config.get(CONF_EXTRA_ARGUMENTS), + ) + + @property + def sensor_class(self): + """Return the class of this sensor, from SENSOR_CLASSES.""" + return "motion" diff --git a/homeassistant/components/binary_sensor/homematic.py b/homeassistant/components/binary_sensor/homematic.py index 8e874079ee6..117642c65f1 100644 --- a/homeassistant/components/binary_sensor/homematic.py +++ b/homeassistant/components/binary_sensor/homematic.py @@ -20,7 +20,9 @@ SENSOR_TYPES_CLASS = { "SmokeV2": "smoke", "Motion": "motion", "MotionV2": "motion", - "RemoteMotion": None + "RemoteMotion": None, + "WeatherSensor": None, + "TiltSensor": None, } diff --git a/homeassistant/components/binary_sensor/mysensors.py b/homeassistant/components/binary_sensor/mysensors.py index d7b1a82188e..789e188537e 100644 --- a/homeassistant/components/binary_sensor/mysensors.py +++ b/homeassistant/components/binary_sensor/mysensors.py @@ -32,7 +32,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): pres.S_MOTION: [set_req.V_TRIPPED], pres.S_SMOKE: [set_req.V_TRIPPED], } - if float(gateway.version) >= 1.5: + if float(gateway.protocol_version) >= 1.5: map_sv_types.update({ pres.S_SPRINKLER: [set_req.V_TRIPPED], pres.S_WATER_LEAK: [set_req.V_TRIPPED], @@ -66,7 +66,7 @@ class MySensorsBinarySensor( pres.S_MOTION: 'motion', pres.S_SMOKE: 'smoke', } - if float(self.gateway.version) >= 1.5: + if float(self.gateway.protocol_version) >= 1.5: class_map.update({ pres.S_SPRINKLER: 'sprinkler', pres.S_WATER_LEAK: 'leak', diff --git a/homeassistant/components/binary_sensor/rest.py b/homeassistant/components/binary_sensor/rest.py index d9a6f1d8947..4a6e48ca5a3 100644 --- a/homeassistant/components/binary_sensor/rest.py +++ b/homeassistant/components/binary_sensor/rest.py @@ -6,30 +6,42 @@ https://home-assistant.io/components/binary_sensor.rest/ """ import logging -from homeassistant.components.binary_sensor import (BinarySensorDevice, - SENSOR_CLASSES) +import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, SENSOR_CLASSES_SCHEMA, PLATFORM_SCHEMA) from homeassistant.components.sensor.rest import RestData -from homeassistant.const import CONF_VALUE_TEMPLATE +from homeassistant.const import ( + CONF_PAYLOAD, CONF_NAME, CONF_VALUE_TEMPLATE, CONF_METHOD, CONF_RESOURCE, + CONF_SENSOR_CLASS) from homeassistant.helpers import template +import homeassistant.helpers.config_validation as cv + +DEFAULT_METHOD = 'GET' +DEFAULT_NAME = 'REST Binary Sensor' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_RESOURCE): cv.url, + vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): vol.In(['POST', 'GET']), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PAYLOAD): cv.string, + vol.Optional(CONF_SENSOR_CLASS): SENSOR_CLASSES_SCHEMA, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, +}) _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = 'REST Binary Sensor' -DEFAULT_METHOD = 'GET' - # pylint: disable=unused-variable def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the REST binary sensor.""" - resource = config.get('resource', None) - method = config.get('method', DEFAULT_METHOD) - payload = config.get('payload', None) + name = config.get(CONF_NAME) + resource = config.get(CONF_RESOURCE) + method = config.get(CONF_METHOD) + payload = config.get(CONF_PAYLOAD) verify_ssl = config.get('verify_ssl', True) - - sensor_class = config.get('sensor_class') - if sensor_class not in SENSOR_CLASSES: - _LOGGER.warning('Unknown sensor class: %s', sensor_class) - sensor_class = None + sensor_class = config.get(CONF_SENSOR_CLASS) + value_template = config.get(CONF_VALUE_TEMPLATE) rest = RestData(method, resource, payload, verify_ssl) rest.update() @@ -39,11 +51,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): return False add_devices([RestBinarySensor( - hass, - rest, - config.get('name', DEFAULT_NAME), - sensor_class, - config.get(CONF_VALUE_TEMPLATE))]) + hass, rest, name, sensor_class, value_template)]) # pylint: disable=too-many-arguments diff --git a/homeassistant/components/binary_sensor/services.yaml b/homeassistant/components/binary_sensor/services.yaml new file mode 100644 index 00000000000..9be9915e268 --- /dev/null +++ b/homeassistant/components/binary_sensor/services.yaml @@ -0,0 +1,9 @@ +# Describes the format for available binary_sensor services + +ffmpeg_restart: + description: Send a restart command to a ffmpeg based sensor (party mode). + + fields: + entity_id: + description: Name(s) of entites that will restart. Platform dependent. + example: 'binary_sensor.ffmpeg_noise' diff --git a/homeassistant/components/binary_sensor/template.py b/homeassistant/components/binary_sensor/template.py index ee68e817275..e87594e625c 100644 --- a/homeassistant/components/binary_sensor/template.py +++ b/homeassistant/components/binary_sensor/template.py @@ -5,55 +5,47 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.template/ """ import logging +import voluptuous as vol +import homeassistant.helpers.config_validation as cv from homeassistant.components.binary_sensor import (BinarySensorDevice, ENTITY_ID_FORMAT, - SENSOR_CLASSES) -from homeassistant.const import (ATTR_FRIENDLY_NAME, CONF_VALUE_TEMPLATE, - ATTR_ENTITY_ID, MATCH_ALL) + PLATFORM_SCHEMA, + SENSOR_CLASSES_SCHEMA) + +from homeassistant.const import (ATTR_FRIENDLY_NAME, ATTR_ENTITY_ID, MATCH_ALL, + CONF_VALUE_TEMPLATE, CONF_SENSOR_CLASS) from homeassistant.exceptions import TemplateError from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers import template from homeassistant.helpers.event import track_state_change -from homeassistant.util import slugify CONF_SENSORS = 'sensors' + +SENSOR_SCHEMA = vol.Schema({ + vol.Required(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(ATTR_FRIENDLY_NAME): cv.string, + vol.Optional(ATTR_ENTITY_ID, default=MATCH_ALL): cv.entity_ids, + vol.Optional(CONF_SENSOR_CLASS, default=None): SENSOR_CLASSES_SCHEMA +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SENSORS): vol.Schema({cv.slug: SENSOR_SCHEMA}), +}) + _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices, discovery_info=None): """Setup template binary sensors.""" sensors = [] - if config.get(CONF_SENSORS) is None: - _LOGGER.error('Missing configuration data for binary_sensor platform') - return False for device, device_config in config[CONF_SENSORS].items(): - if device != slugify(device): - _LOGGER.error('Found invalid key for binary_sensor.template: %s. ' - 'Use %s instead', device, slugify(device)) - continue - - if not isinstance(device_config, dict): - _LOGGER.error('Missing configuration data for binary_sensor %s', - device) - continue - + value_template = device_config[CONF_VALUE_TEMPLATE] + entity_ids = device_config[ATTR_ENTITY_ID] friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device) - sensor_class = device_config.get('sensor_class') - value_template = device_config.get(CONF_VALUE_TEMPLATE) - - if sensor_class not in SENSOR_CLASSES: - _LOGGER.error('Sensor class is not valid') - continue - - if value_template is None: - _LOGGER.error( - 'Missing %s for sensor %s', CONF_VALUE_TEMPLATE, device) - continue - - entity_ids = device_config.get(ATTR_ENTITY_ID, MATCH_ALL) + sensor_class = device_config.get(CONF_SENSOR_CLASS) sensors.append( BinarySensorTemplate( diff --git a/homeassistant/components/binary_sensor/wink.py b/homeassistant/components/binary_sensor/wink.py index 0ab8d812819..9ba717782e9 100644 --- a/homeassistant/components/binary_sensor/wink.py +++ b/homeassistant/components/binary_sensor/wink.py @@ -13,7 +13,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.helpers.entity import Entity from homeassistant.loader import get_component -REQUIREMENTS = ['python-wink==0.7.11', 'pubnub==3.8.2'] +REQUIREMENTS = ['python-wink==0.7.13', 'pubnub==3.8.2'] # These are the available sensors mapped to binary_sensor class SENSOR_TYPES = { diff --git a/homeassistant/components/camera/bloomsky.py b/homeassistant/components/camera/bloomsky.py index cd40b91a21c..7137c73c299 100644 --- a/homeassistant/components/camera/bloomsky.py +++ b/homeassistant/components/camera/bloomsky.py @@ -11,15 +11,15 @@ import requests from homeassistant.components.camera import Camera from homeassistant.loader import get_component -DEPENDENCIES = ["bloomsky"] +DEPENDENCIES = ['bloomsky'] # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices_callback, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Setup access to BloomSky cameras.""" bloomsky = get_component('bloomsky') for device in bloomsky.BLOOMSKY.devices.values(): - add_devices_callback([BloomSkyCamera(bloomsky.BLOOMSKY, device)]) + add_devices([BloomSkyCamera(bloomsky.BLOOMSKY, device)]) class BloomSkyCamera(Camera): @@ -28,8 +28,8 @@ class BloomSkyCamera(Camera): def __init__(self, bs, device): """Setup for access to the BloomSky camera images.""" super(BloomSkyCamera, self).__init__() - self._name = device["DeviceName"] - self._id = device["DeviceID"] + self._name = device['DeviceName'] + self._id = device['DeviceID'] self._bloomsky = bs self._url = "" self._last_url = "" @@ -42,7 +42,7 @@ class BloomSkyCamera(Camera): def camera_image(self): """Update the camera's image if it has changed.""" try: - self._url = self._bloomsky.devices[self._id]["Data"]["ImageURL"] + self._url = self._bloomsky.devices[self._id]['Data']['ImageURL'] self._bloomsky.refresh_devices() # If the URL hasn't changed then the image hasn't changed. if self._url != self._last_url: diff --git a/homeassistant/components/camera/ffmpeg.py b/homeassistant/components/camera/ffmpeg.py index 6803ebb49a3..f87b3074c1c 100644 --- a/homeassistant/components/camera/ffmpeg.py +++ b/homeassistant/components/camera/ffmpeg.py @@ -9,31 +9,33 @@ from contextlib import closing import voluptuous as vol -from homeassistant.components.camera import Camera +from homeassistant.components.camera import (Camera, PLATFORM_SCHEMA) from homeassistant.components.camera.mjpeg import extract_image_from_mjpeg import homeassistant.helpers.config_validation as cv -from homeassistant.const import CONF_NAME, CONF_PLATFORM +from homeassistant.const import CONF_NAME -REQUIREMENTS = ["ha-ffmpeg==0.4"] +REQUIREMENTS = ['ha-ffmpeg==0.8'] + +_LOGGER = logging.getLogger(__name__) CONF_INPUT = 'input' CONF_FFMPEG_BIN = 'ffmpeg_bin' CONF_EXTRA_ARGUMENTS = 'extra_arguments' -PLATFORM_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): "ffmpeg", - vol.Optional(CONF_NAME, default="FFmpeg"): cv.string, +DEFAULT_BINARY = 'ffmpeg' +DEFAULT_NAME = 'FFmpeg' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_INPUT): cv.string, - vol.Optional(CONF_FFMPEG_BIN, default="ffmpeg"): cv.string, vol.Optional(CONF_EXTRA_ARGUMENTS): cv.string, + vol.Optional(CONF_FFMPEG_BIN, default=DEFAULT_BINARY): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) -_LOGGER = logging.getLogger(__name__) - -def setup_platform(hass, config, add_devices_callback, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Setup a FFmpeg Camera.""" - add_devices_callback([FFmpegCamera(config)]) + add_devices([FFmpegCamera(config)]) class FFmpegCamera(Camera): diff --git a/homeassistant/components/camera/foscam.py b/homeassistant/components/camera/foscam.py index 95a6460b814..987b8c51af5 100644 --- a/homeassistant/components/camera/foscam.py +++ b/homeassistant/components/camera/foscam.py @@ -7,21 +7,33 @@ https://home-assistant.io/components/camera.foscam/ import logging import requests +import voluptuous as vol -from homeassistant.components.camera import DOMAIN, Camera -from homeassistant.helpers import validate_config +from homeassistant.components.camera import (Camera, PLATFORM_SCHEMA) +from homeassistant.const import ( + CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_PORT) +from homeassistant.helpers import config_validation as cv _LOGGER = logging.getLogger(__name__) +CONF_IP = 'ip' + +DEFAULT_NAME = 'Foscam Camera' +DEFAULT_PORT = 88 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_IP): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, +}) + # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices_callback, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Setup a Foscam IP Camera.""" - if not validate_config({DOMAIN: config}, - {DOMAIN: ['username', 'password', 'ip']}, _LOGGER): - return None - - add_devices_callback([FoscamCamera(config)]) + add_devices([FoscamCamera(config)]) # pylint: disable=too-many-instance-attributes @@ -32,16 +44,16 @@ class FoscamCamera(Camera): """Initialize a Foscam camera.""" super(FoscamCamera, self).__init__() - ip_address = device_info.get('ip') - port = device_info.get('port', 88) + ip_address = device_info.get(CONF_IP) + port = device_info.get(CONF_PORT) - self._base_url = 'http://' + ip_address + ':' + str(port) + '/' - self._username = device_info.get('username') - self._password = device_info.get('password') + self._base_url = 'http://{}:{}/'.format(ip_address, port) + self._username = device_info.get(CONF_USERNAME) + self._password = device_info.get(CONF_PASSWORD) self._snap_picture_url = self._base_url \ + 'cgi-bin/CGIProxy.fcgi?cmd=snapPicture2&usr=' \ + self._username + '&pwd=' + self._password - self._name = device_info.get('name', 'Foscam Camera') + self._name = device_info.get(CONF_NAME) _LOGGER.info('Using the following URL for %s: %s', self._name, self._snap_picture_url) diff --git a/homeassistant/components/camera/generic.py b/homeassistant/components/camera/generic.py index 91f44a2a230..85a662065a6 100644 --- a/homeassistant/components/camera/generic.py +++ b/homeassistant/components/camera/generic.py @@ -7,22 +7,38 @@ https://home-assistant.io/components/camera.generic/ import logging import requests -from requests.auth import HTTPBasicAuth +from requests.auth import HTTPBasicAuth, HTTPDigestAuth +import voluptuous as vol -from homeassistant.components.camera import DOMAIN, Camera -from homeassistant.helpers import validate_config +from homeassistant.const import ( + CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_AUTHENTICATION, + HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION) +from homeassistant.exceptions import TemplateError +from homeassistant.components.camera import (PLATFORM_SCHEMA, Camera) +from homeassistant.helpers import config_validation as cv, template _LOGGER = logging.getLogger(__name__) +CONF_LIMIT_REFETCH_TO_URL_CHANGE = 'limit_refetch_to_url_change' +CONF_STILL_IMAGE_URL = 'still_image_url' + +DEFAULT_NAME = 'Generic Camera' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_STILL_IMAGE_URL): vol.Any(cv.url, cv.template), + vol.Optional(CONF_AUTHENTICATION, default=HTTP_BASIC_AUTHENTICATION): + vol.In([HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]), + vol.Optional(CONF_LIMIT_REFETCH_TO_URL_CHANGE, default=False): cv.boolean, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_USERNAME): cv.string, +}) + # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices_callback, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Setup a generic IP Camera.""" - if not validate_config({DOMAIN: config}, {DOMAIN: ['still_image_url']}, - _LOGGER): - return None - - add_devices_callback([GenericCamera(config)]) + add_devices([GenericCamera(config)]) # pylint: disable=too-many-instance-attributes @@ -32,30 +48,47 @@ class GenericCamera(Camera): def __init__(self, device_info): """Initialize a generic camera.""" super().__init__() - self._name = device_info.get('name', 'Generic Camera') - self._username = device_info.get('username') - self._password = device_info.get('password') - self._still_image_url = device_info['still_image_url'] + self._name = device_info.get(CONF_NAME) + self._still_image_url = device_info[CONF_STILL_IMAGE_URL] + self._limit_refetch = device_info[CONF_LIMIT_REFETCH_TO_URL_CHANGE] + + username = device_info.get(CONF_USERNAME) + password = device_info.get(CONF_PASSWORD) + + if username and password: + if device_info[CONF_AUTHENTICATION] == HTTP_DIGEST_AUTHENTICATION: + self._auth = HTTPDigestAuth(username, password) + else: + self._auth = HTTPBasicAuth(username, password) + else: + self._auth = None + + self._last_url = None + self._last_image = None def camera_image(self): """Return a still image response from the camera.""" - if self._username and self._password: - try: - response = requests.get( - self._still_image_url, - auth=HTTPBasicAuth(self._username, self._password), - timeout=10) - except requests.exceptions.RequestException as error: - _LOGGER.error('Error getting camera image: %s', error) - return None - else: - try: - response = requests.get(self._still_image_url, timeout=10) - except requests.exceptions.RequestException as error: - _LOGGER.error('Error getting camera image: %s', error) - return None + try: + url = template.render(self.hass, self._still_image_url) + except TemplateError as err: + _LOGGER.error('Error parsing template %s: %s', + self._still_image_url, err) + return self._last_image - return response.content + if url == self._last_url and self._limit_refetch: + return self._last_image + + kwargs = {'timeout': 10, 'auth': self._auth} + + try: + response = requests.get(url, **kwargs) + except requests.exceptions.RequestException as error: + _LOGGER.error('Error getting camera image: %s', error) + return None + + self._last_url = url + self._last_image = response.content + return self._last_image @property def name(self): diff --git a/homeassistant/components/camera/local_file.py b/homeassistant/components/camera/local_file.py index 463bf3eca5a..65defb4557b 100644 --- a/homeassistant/components/camera/local_file.py +++ b/homeassistant/components/camera/local_file.py @@ -1,50 +1,55 @@ -"""Camera that loads a picture from a local file.""" +""" +Camera that loads a picture from a local file. +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.local_file/ +""" import logging import os -from homeassistant.components.camera import Camera +import voluptuous as vol + +from homeassistant.const import CONF_NAME +from homeassistant.components.camera import Camera, PLATFORM_SCHEMA +from homeassistant.helpers import config_validation as cv _LOGGER = logging.getLogger(__name__) +CONF_FILE_PATH = 'file_path' + +DEFAULT_NAME = 'Local File' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_FILE_PATH): cv.isfile, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Camera.""" - # check for missing required configuration variable - if config.get("file_path") is None: - _LOGGER.error("Missing required variable: file_path") - return False - - setup_config = ( - { - "name": config.get("name", "Local File"), - "file_path": config.get("file_path") - } - ) + file_path = config[CONF_FILE_PATH] # check filepath given is readable - if not os.access(setup_config["file_path"], os.R_OK): + if not os.access(file_path, os.R_OK): _LOGGER.error("file path is not readable") return False - add_devices([ - LocalFile(setup_config) - ]) + add_devices([LocalFile(config[CONF_NAME], file_path)]) class LocalFile(Camera): """Local camera.""" - def __init__(self, device_info): + def __init__(self, name, file_path): """Initialize Local File Camera component.""" super().__init__() - self._name = device_info["name"] - self._config = device_info + self._name = name + self._file_path = file_path def camera_image(self): """Return image response.""" - with open(self._config["file_path"], 'rb') as file: + with open(self._file_path, 'rb') as file: return file.read() @property diff --git a/homeassistant/components/camera/mjpeg.py b/homeassistant/components/camera/mjpeg.py index dce8ac30440..04f099d8b1e 100644 --- a/homeassistant/components/camera/mjpeg.py +++ b/homeassistant/components/camera/mjpeg.py @@ -8,24 +8,36 @@ import logging from contextlib import closing import requests -from requests.auth import HTTPBasicAuth +from requests.auth import HTTPBasicAuth, HTTPDigestAuth +import voluptuous as vol -from homeassistant.components.camera import DOMAIN, Camera -from homeassistant.helpers import validate_config - -CONTENT_TYPE_HEADER = 'Content-Type' +from homeassistant.const import ( + CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_AUTHENTICATION, + HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION) +from homeassistant.components.camera import (PLATFORM_SCHEMA, Camera) +from homeassistant.helpers import config_validation as cv _LOGGER = logging.getLogger(__name__) +CONF_MJPEG_URL = 'mjpeg_url' +CONTENT_TYPE_HEADER = 'Content-Type' + +DEFAULT_NAME = 'Mjpeg Camera' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_MJPEG_URL): cv.url, + vol.Optional(CONF_AUTHENTICATION, default=HTTP_BASIC_AUTHENTICATION): + vol.In([HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_USERNAME): cv.string, +}) + # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices_callback, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Setup a MJPEG IP Camera.""" - if not validate_config({DOMAIN: config}, {DOMAIN: ['mjpeg_url']}, - _LOGGER): - return None - - add_devices_callback([MjpegCamera(config)]) + add_devices([MjpegCamera(config)]) def extract_image_from_mjpeg(stream): @@ -47,17 +59,21 @@ class MjpegCamera(Camera): def __init__(self, device_info): """Initialize a MJPEG camera.""" super().__init__() - self._name = device_info.get('name', 'Mjpeg Camera') - self._username = device_info.get('username') - self._password = device_info.get('password') - self._mjpeg_url = device_info['mjpeg_url'] + self._name = device_info.get(CONF_NAME) + self._authentication = device_info.get(CONF_AUTHENTICATION) + self._username = device_info.get(CONF_USERNAME) + self._password = device_info.get(CONF_PASSWORD) + self._mjpeg_url = device_info[CONF_MJPEG_URL] def camera_stream(self): """Return a MJPEG stream image response directly from the camera.""" if self._username and self._password: + if self._authentication == HTTP_DIGEST_AUTHENTICATION: + auth = HTTPDigestAuth(self._username, self._password) + else: + auth = HTTPBasicAuth(self._username, self._password) return requests.get(self._mjpeg_url, - auth=HTTPBasicAuth(self._username, - self._password), + auth=auth, stream=True, timeout=10) else: return requests.get(self._mjpeg_url, stream=True, timeout=10) diff --git a/homeassistant/components/camera/netatmo.py b/homeassistant/components/camera/netatmo.py index 8462d4597dd..457c63d1ad7 100644 --- a/homeassistant/components/camera/netatmo.py +++ b/homeassistant/components/camera/netatmo.py @@ -6,34 +6,43 @@ https://home-assistant.io/components/camera.netatmo/ """ import logging from datetime import timedelta + import requests +import voluptuous as vol + from homeassistant.util import Throttle - -from homeassistant.components.camera import Camera +from homeassistant.components.camera import (Camera, PLATFORM_SCHEMA) from homeassistant.loader import get_component +from homeassistant.helpers import config_validation as cv -DEPENDENCIES = ["netatmo"] +DEPENDENCIES = ['netatmo'] _LOGGER = logging.getLogger(__name__) CONF_HOME = 'home' -ATTR_CAMERAS = 'cameras' +CONF_CAMERAS = 'cameras' MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_HOME): cv.string, + vol.Optional(CONF_CAMERAS, default=[]): + vol.All(cv.ensure_list, [cv.string]), +}) + # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices_callback, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Setup access to Netatmo Welcome cameras.""" netatmo = get_component('netatmo') - home = config.get(CONF_HOME, None) + home = config.get(CONF_HOME) data = WelcomeData(netatmo.NETATMO_AUTH, home) for camera_name in data.get_camera_names(): - if ATTR_CAMERAS in config: - if camera_name not in config[ATTR_CAMERAS]: + if CONF_CAMERAS in config: + if camera_name not in config[CONF_CAMERAS]: continue - add_devices_callback([WelcomeCamera(data, camera_name, home)]) + add_devices([WelcomeCamera(data, camera_name, home)]) class WelcomeCamera(Camera): diff --git a/homeassistant/components/camera/rpi_camera.py b/homeassistant/components/camera/rpi_camera.py index ee67d097286..22ab72ad8e7 100644 --- a/homeassistant/components/camera/rpi_camera.py +++ b/homeassistant/components/camera/rpi_camera.py @@ -9,41 +9,77 @@ import subprocess import logging import shutil -from homeassistant.components.camera import Camera +import voluptuous as vol + +from homeassistant.components.camera import (Camera, PLATFORM_SCHEMA) +from homeassistant.const import (CONF_NAME, CONF_FILE_PATH) +from homeassistant.helpers import config_validation as cv _LOGGER = logging.getLogger(__name__) +CONF_HORIZONTAL_FLIP = 'horizontal_flip' +CONF_IMAGE_HEIGHT = 'image_height' +CONF_IMAGE_QUALITY = 'image_quality' +CONF_IMAGE_ROTATION = 'image_rotation' +CONF_IMAGE_WIDTH = 'image_width' +CONF_TIMELAPSE = 'timelapse' +CONF_VERTICAL_FLIP = 'vertical_flip' + +DEFAULT_HORIZONTAL_FLIP = 0 +DEFAULT_IMAGE_HEIGHT = 480 +DEFAULT_IMAGE_QUALITIY = 7 +DEFAULT_IMAGE_ROTATION = 0 +DEFAULT_IMAGE_WIDTH = 640 +DEFAULT_NAME = 'Raspberry Pi Camera' +DEFAULT_TIMELAPSE = 1000 +DEFAULT_VERTICAL_FLIP = 0 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_FILE_PATH): cv.isfile, + vol.Optional(CONF_HORIZONTAL_FLIP, default=DEFAULT_HORIZONTAL_FLIP): + vol.All(vol.Coerce(int), vol.Range(min=0, max=1)), + vol.Optional(CONF_IMAGE_HEIGHT, default=DEFAULT_HORIZONTAL_FLIP): + vol.Coerce(int), + vol.Optional(CONF_IMAGE_QUALITY, default=DEFAULT_IMAGE_QUALITIY): + vol.All(vol.Coerce(int), vol.Range(min=0, max=100)), + vol.Optional(CONF_IMAGE_ROTATION, default=DEFAULT_IMAGE_ROTATION): + vol.All(vol.Coerce(int), vol.Range(min=0, max=359)), + vol.Optional(CONF_IMAGE_WIDTH, default=DEFAULT_IMAGE_WIDTH): + vol.Coerce(int), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_TIMELAPSE, default=1000): vol.Coerce(int), + vol.Optional(CONF_VERTICAL_FLIP, default=DEFAULT_VERTICAL_FLIP): + vol.All(vol.Coerce(int), vol.Range(min=0, max=1)), +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Raspberry Camera.""" if shutil.which("raspistill") is None: - _LOGGER.error("Error: raspistill not found") + _LOGGER.error("'raspistill' was not found") return False setup_config = ( { - "name": config.get("name", "Raspberry Pi Camera"), - "image_width": int(config.get("image_width", "640")), - "image_height": int(config.get("image_height", "480")), - "image_quality": int(config.get("image_quality", "7")), - "image_rotation": int(config.get("image_rotation", "0")), - "timelapse": int(config.get("timelapse", "2000")), - "horizontal_flip": int(config.get("horizontal_flip", "0")), - "vertical_flip": int(config.get("vertical_flip", "0")), - "file_path": config.get("file_path", - os.path.join(os.path.dirname(__file__), - 'image.jpg')) + CONF_NAME: config.get(CONF_NAME), + CONF_IMAGE_WIDTH: config.get(CONF_IMAGE_WIDTH), + CONF_IMAGE_HEIGHT: config.get(CONF_IMAGE_HEIGHT), + CONF_IMAGE_QUALITY: config.get(CONF_IMAGE_QUALITY), + CONF_IMAGE_ROTATION: config.get(CONF_IMAGE_ROTATION), + CONF_TIMELAPSE: config.get(CONF_TIMELAPSE), + CONF_HORIZONTAL_FLIP: config.get(CONF_HORIZONTAL_FLIP), + CONF_VERTICAL_FLIP: config.get(CONF_VERTICAL_FLIP), + CONF_FILE_PATH: config.get(CONF_FILE_PATH, + os.path.join(os.path.dirname(__file__), + 'image.jpg')) } ) - # check filepath given is writable - if not os.access(setup_config["file_path"], os.W_OK): - _LOGGER.error("Error: file path is not writable") + if not os.access(setup_config[CONF_FILE_PATH], os.W_OK): + _LOGGER.error("File path is not writable") return False - add_devices([ - RaspberryCamera(setup_config) - ]) + add_devices([RaspberryCamera(setup_config)]) class RaspberryCamera(Camera): @@ -53,26 +89,26 @@ class RaspberryCamera(Camera): """Initialize Raspberry Pi camera component.""" super().__init__() - self._name = device_info["name"] + self._name = device_info[CONF_NAME] self._config = device_info - # kill if there's raspistill instance + # Kill if there's raspistill instance subprocess.Popen(['killall', 'raspistill'], stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT) cmd_args = [ - 'raspistill', '--nopreview', '-o', str(device_info["file_path"]), - '-t', '0', '-w', str(device_info["image_width"]), - '-h', str(device_info["image_height"]), - '-tl', str(device_info["timelapse"]), - '-q', str(device_info["image_quality"]), - '-rot', str(device_info["image_rotation"]) + 'raspistill', '--nopreview', '-o', device_info[CONF_FILE_PATH], + '-t', '0', '-w', str(device_info[CONF_IMAGE_WIDTH]), + '-h', str(device_info[CONF_IMAGE_HEIGHT]), + '-tl', str(device_info[CONF_TIMELAPSE]), + '-q', str(device_info[CONF_IMAGE_QUALITY]), + '-rot', str(device_info[CONF_IMAGE_ROTATION]) ] - if device_info["horizontal_flip"]: + if device_info[CONF_HORIZONTAL_FLIP]: cmd_args.append("-hf") - if device_info["vertical_flip"]: + if device_info[CONF_VERTICAL_FLIP]: cmd_args.append("-vf") subprocess.Popen(cmd_args, @@ -81,7 +117,7 @@ class RaspberryCamera(Camera): def camera_image(self): """Return raspstill image response.""" - with open(self._config["file_path"], 'rb') as file: + with open(self._config[CONF_FILE_PATH], 'rb') as file: return file.read() @property diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py new file mode 100644 index 00000000000..6ed289b2008 --- /dev/null +++ b/homeassistant/components/climate/__init__.py @@ -0,0 +1,535 @@ +""" +Provides functionality to interact with climate devices. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/climate/ +""" +import logging +import os +from numbers import Number +import voluptuous as vol + +from homeassistant.helpers.entity_component import EntityComponent + +from homeassistant.config import load_yaml_config_file +import homeassistant.util as util +from homeassistant.util.temperature import convert as convert_temperature +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_ON, STATE_OFF, STATE_UNKNOWN, + TEMP_CELSIUS) + +DOMAIN = "climate" + +ENTITY_ID_FORMAT = DOMAIN + ".{}" +SCAN_INTERVAL = 60 + +SERVICE_SET_AWAY_MODE = "set_away_mode" +SERVICE_SET_AUX_HEAT = "set_aux_heat" +SERVICE_SET_TEMPERATURE = "set_temperature" +SERVICE_SET_FAN_MODE = "set_fan_mode" +SERVICE_SET_OPERATION_MODE = "set_operation_mode" +SERVICE_SET_SWING_MODE = "set_swing_mode" +SERVICE_SET_HUMIDITY = "set_humidity" + +STATE_HEAT = "heat" +STATE_COOL = "cool" +STATE_IDLE = "idle" +STATE_AUTO = "auto" +STATE_DRY = "dry" +STATE_FAN_ONLY = "fan_only" + +ATTR_CURRENT_TEMPERATURE = "current_temperature" +ATTR_MAX_TEMP = "max_temp" +ATTR_MIN_TEMP = "min_temp" +ATTR_AWAY_MODE = "away_mode" +ATTR_AUX_HEAT = "aux_heat" +ATTR_FAN_MODE = "fan_mode" +ATTR_FAN_LIST = "fan_list" +ATTR_CURRENT_HUMIDITY = "current_humidity" +ATTR_HUMIDITY = "humidity" +ATTR_MAX_HUMIDITY = "max_humidity" +ATTR_MIN_HUMIDITY = "min_humidity" +ATTR_OPERATION_MODE = "operation_mode" +ATTR_OPERATION_LIST = "operation_list" +ATTR_SWING_MODE = "swing_mode" +ATTR_SWING_LIST = "swing_list" + +_LOGGER = logging.getLogger(__name__) + +SET_AWAY_MODE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_AWAY_MODE): cv.boolean, +}) +SET_AUX_HEAT_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_AUX_HEAT): cv.boolean, +}) +SET_TEMPERATURE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_TEMPERATURE): vol.Coerce(float), +}) +SET_FAN_MODE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_FAN_MODE): cv.string, +}) +SET_OPERATION_MODE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_OPERATION_MODE): cv.string, +}) +SET_HUMIDITY_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_HUMIDITY): vol.Coerce(float), +}) +SET_SWING_MODE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_SWING_MODE): cv.string, +}) + + +def set_away_mode(hass, away_mode, entity_id=None): + """Turn all or specified climate devices away mode on.""" + data = { + ATTR_AWAY_MODE: away_mode + } + + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + hass.services.call(DOMAIN, SERVICE_SET_AWAY_MODE, data) + + +def set_aux_heat(hass, aux_heat, entity_id=None): + """Turn all or specified climate devices auxillary heater on.""" + data = { + ATTR_AUX_HEAT: aux_heat + } + + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + hass.services.call(DOMAIN, SERVICE_SET_AUX_HEAT, data) + + +def set_temperature(hass, temperature, entity_id=None): + """Set new target temperature.""" + data = {ATTR_TEMPERATURE: temperature} + + if entity_id is not None: + data[ATTR_ENTITY_ID] = entity_id + + hass.services.call(DOMAIN, SERVICE_SET_TEMPERATURE, data) + + +def set_humidity(hass, humidity, entity_id=None): + """Set new target humidity.""" + data = {ATTR_HUMIDITY: humidity} + + if entity_id is not None: + data[ATTR_ENTITY_ID] = entity_id + + hass.services.call(DOMAIN, SERVICE_SET_HUMIDITY, data) + + +def set_fan_mode(hass, fan, entity_id=None): + """Set all or specified climate devices fan mode on.""" + data = {ATTR_FAN_MODE: fan} + + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + hass.services.call(DOMAIN, SERVICE_SET_FAN_MODE, data) + + +def set_operation_mode(hass, operation_mode, entity_id=None): + """Set new target operation mode.""" + data = {ATTR_OPERATION_MODE: operation_mode} + + if entity_id is not None: + data[ATTR_ENTITY_ID] = entity_id + + hass.services.call(DOMAIN, SERVICE_SET_OPERATION_MODE, data) + + +def set_swing_mode(hass, swing_mode, entity_id=None): + """Set new target swing mode.""" + data = {ATTR_SWING_MODE: swing_mode} + + if entity_id is not None: + data[ATTR_ENTITY_ID] = entity_id + + hass.services.call(DOMAIN, SERVICE_SET_SWING_MODE, data) + + +# pylint: disable=too-many-branches +def setup(hass, config): + """Setup climate devices.""" + component = EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL) + component.setup(config) + + descriptions = load_yaml_config_file( + os.path.join(os.path.dirname(__file__), 'services.yaml')) + + def away_mode_set_service(service): + """Set away mode on target climate devices.""" + target_climate = component.extract_from_service(service) + + away_mode = service.data.get(ATTR_AWAY_MODE) + + if away_mode is None: + _LOGGER.error( + "Received call to %s without attribute %s", + SERVICE_SET_AWAY_MODE, ATTR_AWAY_MODE) + return + + for climate in target_climate: + if away_mode: + climate.turn_away_mode_on() + else: + climate.turn_away_mode_off() + + if climate.should_poll: + climate.update_ha_state(True) + + hass.services.register( + DOMAIN, SERVICE_SET_AWAY_MODE, away_mode_set_service, + descriptions.get(SERVICE_SET_AWAY_MODE), + schema=SET_AWAY_MODE_SCHEMA) + + def aux_heat_set_service(service): + """Set auxillary heater on target climate devices.""" + target_climate = component.extract_from_service(service) + + aux_heat = service.data.get(ATTR_AUX_HEAT) + + if aux_heat is None: + _LOGGER.error( + "Received call to %s without attribute %s", + SERVICE_SET_AUX_HEAT, ATTR_AUX_HEAT) + return + + for climate in target_climate: + if aux_heat: + climate.turn_aux_heat_on() + else: + climate.turn_aux_heat_off() + + if climate.should_poll: + climate.update_ha_state(True) + + hass.services.register( + DOMAIN, SERVICE_SET_AUX_HEAT, aux_heat_set_service, + descriptions.get(SERVICE_SET_AUX_HEAT), + schema=SET_AUX_HEAT_SCHEMA) + + def temperature_set_service(service): + """Set temperature on the target climate devices.""" + target_climate = component.extract_from_service(service) + + temperature = util.convert( + service.data.get(ATTR_TEMPERATURE), float) + + if temperature is None: + _LOGGER.error( + "Received call to %s without attribute %s", + SERVICE_SET_TEMPERATURE, ATTR_TEMPERATURE) + return + + for climate in target_climate: + climate.set_temperature(convert_temperature( + temperature, hass.config.units.temperature_unit, + climate.unit_of_measurement)) + + if climate.should_poll: + climate.update_ha_state(True) + + hass.services.register( + DOMAIN, SERVICE_SET_TEMPERATURE, temperature_set_service, + descriptions.get(SERVICE_SET_TEMPERATURE), + schema=SET_TEMPERATURE_SCHEMA) + + def humidity_set_service(service): + """Set humidity on the target climate devices.""" + target_climate = component.extract_from_service(service) + + humidity = service.data.get(ATTR_HUMIDITY) + + if humidity is None: + _LOGGER.error( + "Received call to %s without attribute %s", + SERVICE_SET_HUMIDITY, ATTR_HUMIDITY) + return + + for climate in target_climate: + climate.set_humidity(humidity) + + if climate.should_poll: + climate.update_ha_state(True) + + hass.services.register( + DOMAIN, SERVICE_SET_HUMIDITY, humidity_set_service, + descriptions.get(SERVICE_SET_HUMIDITY), + schema=SET_HUMIDITY_SCHEMA) + + def fan_mode_set_service(service): + """Set fan mode on target climate devices.""" + target_climate = component.extract_from_service(service) + + fan = service.data.get(ATTR_FAN_MODE) + + if fan is None: + _LOGGER.error( + "Received call to %s without attribute %s", + SERVICE_SET_FAN_MODE, ATTR_FAN_MODE) + return + + for climate in target_climate: + climate.set_fan_mode(fan) + + if climate.should_poll: + climate.update_ha_state(True) + + hass.services.register( + DOMAIN, SERVICE_SET_FAN_MODE, fan_mode_set_service, + descriptions.get(SERVICE_SET_FAN_MODE), + schema=SET_FAN_MODE_SCHEMA) + + def operation_set_service(service): + """Set operating mode on the target climate devices.""" + target_climate = component.extract_from_service(service) + + operation_mode = service.data.get(ATTR_OPERATION_MODE) + + if operation_mode is None: + _LOGGER.error( + "Received call to %s without attribute %s", + SERVICE_SET_OPERATION_MODE, ATTR_OPERATION_MODE) + return + + for climate in target_climate: + climate.set_operation_mode(operation_mode) + + if climate.should_poll: + climate.update_ha_state(True) + + hass.services.register( + DOMAIN, SERVICE_SET_OPERATION_MODE, operation_set_service, + descriptions.get(SERVICE_SET_OPERATION_MODE), + schema=SET_OPERATION_MODE_SCHEMA) + + def swing_set_service(service): + """Set swing mode on the target climate devices.""" + target_climate = component.extract_from_service(service) + + swing_mode = service.data.get(ATTR_SWING_MODE) + + if swing_mode is None: + _LOGGER.error( + "Received call to %s without attribute %s", + SERVICE_SET_SWING_MODE, ATTR_SWING_MODE) + return + + for climate in target_climate: + climate.set_swing_mode(swing_mode) + + if climate.should_poll: + climate.update_ha_state(True) + + hass.services.register( + DOMAIN, SERVICE_SET_SWING_MODE, swing_set_service, + descriptions.get(SERVICE_SET_SWING_MODE), + schema=SET_SWING_MODE_SCHEMA) + return True + + +class ClimateDevice(Entity): + """Representation of a climate device.""" + + # pylint: disable=too-many-public-methods,no-self-use + @property + def state(self): + """Return the current state.""" + return self.current_operation or STATE_UNKNOWN + + @property + def state_attributes(self): + """Return the optional state attributes.""" + data = { + ATTR_CURRENT_TEMPERATURE: + self._convert_for_display(self.current_temperature), + ATTR_MIN_TEMP: self._convert_for_display(self.min_temp), + ATTR_MAX_TEMP: self._convert_for_display(self.max_temp), + ATTR_TEMPERATURE: + self._convert_for_display(self.target_temperature), + } + + humidity = self.target_humidity + if humidity is not None: + data[ATTR_HUMIDITY] = humidity + data[ATTR_CURRENT_HUMIDITY] = self.current_humidity + data[ATTR_MIN_HUMIDITY] = self.min_humidity + data[ATTR_MAX_HUMIDITY] = self.max_humidity + + fan_mode = self.current_fan_mode + if fan_mode is not None: + data[ATTR_FAN_MODE] = fan_mode + data[ATTR_FAN_LIST] = self.fan_list + + operation_mode = self.current_operation + if operation_mode is not None: + data[ATTR_OPERATION_MODE] = operation_mode + data[ATTR_OPERATION_LIST] = self.operation_list + + swing_mode = self.current_swing_mode + if swing_mode is not None: + data[ATTR_SWING_MODE] = swing_mode + data[ATTR_SWING_LIST] = self.swing_list + + is_away = self.is_away_mode_on + if is_away is not None: + data[ATTR_AWAY_MODE] = STATE_ON if is_away else STATE_OFF + + is_aux_heat = self.is_aux_heat_on + if is_aux_heat is not None: + data[ATTR_AUX_HEAT] = STATE_ON if is_aux_heat else STATE_OFF + + return data + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + raise NotImplementedError + + @property + def current_humidity(self): + """Return the current humidity.""" + return None + + @property + def target_humidity(self): + """Return the humidity we try to reach.""" + return None + + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + return None + + @property + def operation_list(self): + """List of available operation modes.""" + return None + + @property + def current_temperature(self): + """Return the current temperature.""" + return None + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return None + + @property + def is_away_mode_on(self): + """Return true if away mode is on.""" + return None + + @property + def is_aux_heat_on(self): + """Return true if aux heater.""" + return None + + @property + def current_fan_mode(self): + """Return the fan setting.""" + return None + + @property + def fan_list(self): + """List of available fan modes.""" + return None + + @property + def current_swing_mode(self): + """Return the fan setting.""" + return None + + @property + def swing_list(self): + """List of available swing modes.""" + return None + + def set_temperature(self, temperature): + """Set new target temperature.""" + raise NotImplementedError() + + def set_humidity(self, humidity): + """Set new target humidity.""" + raise NotImplementedError() + + def set_fan_mode(self, fan): + """Set new target fan mode.""" + raise NotImplementedError() + + def set_operation_mode(self, operation_mode): + """Set new target operation mode.""" + raise NotImplementedError() + + def set_swing_mode(self, swing_mode): + """Set new target swing operation.""" + raise NotImplementedError() + + def turn_away_mode_on(self): + """Turn away mode on.""" + raise NotImplementedError() + + def turn_away_mode_off(self): + """Turn away mode off.""" + raise NotImplementedError() + + def turn_aux_heat_on(self): + """Turn auxillary heater on.""" + raise NotImplementedError() + + def turn_aux_heat_off(self): + """Turn auxillary heater off.""" + raise NotImplementedError() + + @property + def min_temp(self): + """Return the minimum temperature.""" + return convert_temperature(7, TEMP_CELSIUS, self.unit_of_measurement) + + @property + def max_temp(self): + """Return the maximum temperature.""" + return convert_temperature(35, TEMP_CELSIUS, self.unit_of_measurement) + + @property + def min_humidity(self): + """Return the minimum humidity.""" + return 30 + + @property + def max_humidity(self): + """Return the maximum humidity.""" + return 99 + + def _convert_for_display(self, temp): + """Convert temperature into preferred units for display purposes.""" + if temp is None or not isinstance(temp, Number): + return temp + + value = convert_temperature(temp, self.unit_of_measurement, + self.hass.config.units.temperature_unit) + + if self.hass.config.units.temperature_unit is TEMP_CELSIUS: + decimal_count = 1 + else: + # Users of fahrenheit generally expect integer units. + decimal_count = 0 + + return round(value, decimal_count) diff --git a/homeassistant/components/climate/demo.py b/homeassistant/components/climate/demo.py new file mode 100644 index 00000000000..340cc29f582 --- /dev/null +++ b/homeassistant/components/climate/demo.py @@ -0,0 +1,164 @@ +""" +Demo platform that offers a fake climate device. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/demo/ +""" +from homeassistant.components.climate import ClimateDevice +from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Demo climate devices.""" + add_devices([ + DemoClimate("HeatPump", 68, TEMP_FAHRENHEIT, None, 77, "Auto Low", + None, None, "Auto", "Heat", None), + DemoClimate("Hvac", 21, TEMP_CELSIUS, True, 22, "On High", + 67, 54, "Off", "Cool", False), + ]) + + +# pylint: disable=too-many-arguments, too-many-public-methods +class DemoClimate(ClimateDevice): + """Representation of a demo climate device.""" + + # pylint: disable=too-many-instance-attributes + def __init__(self, name, target_temperature, unit_of_measurement, + away, current_temperature, current_fan_mode, + target_humidity, current_humidity, current_swing_mode, + current_operation, aux): + """Initialize the climate device.""" + self._name = name + self._target_temperature = target_temperature + self._target_humidity = target_humidity + self._unit_of_measurement = unit_of_measurement + self._away = away + self._current_temperature = current_temperature + self._current_humidity = current_humidity + self._current_fan_mode = current_fan_mode + self._current_operation = current_operation + self._aux = aux + self._current_swing_mode = current_swing_mode + self._fan_list = ["On Low", "On High", "Auto Low", "Auto High", "Off"] + self._operation_list = ["Heat", "Cool", "Auto Changeover", "Off"] + self._swing_list = ["Auto", "1", "2", "3", "Off"] + + @property + def should_poll(self): + """Polling not needed for a demo climate device.""" + return False + + @property + def name(self): + """Return the name of the climate device.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit_of_measurement + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._current_temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temperature + + @property + def current_humidity(self): + """Return the current humidity.""" + return self._current_humidity + + @property + def target_humidity(self): + """Return the humidity we try to reach.""" + return self._target_humidity + + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + return self._current_operation + + @property + def operation_list(self): + """List of available operation modes.""" + return self._operation_list + + @property + def is_away_mode_on(self): + """Return if away mode is on.""" + return self._away + + @property + def is_aux_heat_on(self): + """Return true if away mode is on.""" + return self._aux + + @property + def current_fan_mode(self): + """Return the fan setting.""" + return self._current_fan_mode + + @property + def fan_list(self): + """List of available fan modes.""" + return self._fan_list + + def set_temperature(self, temperature): + """Set new target temperature.""" + self._target_temperature = temperature + self.update_ha_state() + + def set_humidity(self, humidity): + """Set new target temperature.""" + self._target_humidity = humidity + self.update_ha_state() + + def set_swing_mode(self, swing_mode): + """Set new target temperature.""" + self._current_swing_mode = swing_mode + self.update_ha_state() + + def set_fan_mode(self, fan): + """Set new target temperature.""" + self._current_fan_mode = fan + self.update_ha_state() + + def set_operation_mode(self, operation_mode): + """Set new target temperature.""" + self._current_operation = operation_mode + self.update_ha_state() + + @property + def current_swing_mode(self): + """Return the swing setting.""" + return self._current_swing_mode + + @property + def swing_list(self): + """List of available swing modes.""" + return self._swing_list + + def turn_away_mode_on(self): + """Turn away mode on.""" + self._away = True + self.update_ha_state() + + def turn_away_mode_off(self): + """Turn away mode off.""" + self._away = False + self.update_ha_state() + + def turn_aux_heat_on(self): + """Turn away auxillary heater on.""" + self._aux = True + self.update_ha_state() + + def turn_aux_heat_off(self): + """Turn auxillary heater off.""" + self._aux = False + self.update_ha_state() diff --git a/homeassistant/components/climate/ecobee.py b/homeassistant/components/climate/ecobee.py new file mode 100644 index 00000000000..76038085385 --- /dev/null +++ b/homeassistant/components/climate/ecobee.py @@ -0,0 +1,247 @@ +""" +Platform for Ecobee Thermostats. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.ecobee/ +""" +import logging +from os import path +import voluptuous as vol + +from homeassistant.components import ecobee +from homeassistant.components.climate import ( + DOMAIN, STATE_COOL, STATE_HEAT, STATE_IDLE, ClimateDevice) +from homeassistant.const import ( + ATTR_ENTITY_ID, STATE_OFF, STATE_ON, TEMP_FAHRENHEIT) +from homeassistant.config import load_yaml_config_file +import homeassistant.helpers.config_validation as cv + +DEPENDENCIES = ['ecobee'] +_LOGGER = logging.getLogger(__name__) +ECOBEE_CONFIG_FILE = 'ecobee.conf' +_CONFIGURING = {} + +ATTR_FAN_MIN_ON_TIME = "fan_min_on_time" +SERVICE_SET_FAN_MIN_ON_TIME = "ecobee_set_fan_min_on_time" +SET_FAN_MIN_ON_TIME_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_FAN_MIN_ON_TIME): vol.Coerce(int), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Ecobee Thermostat Platform.""" + if discovery_info is None: + return + data = ecobee.NETWORK + hold_temp = discovery_info['hold_temp'] + _LOGGER.info( + "Loading ecobee thermostat component with hold_temp set to %s", + hold_temp) + devices = [Thermostat(data, index, hold_temp) + for index in range(len(data.ecobee.thermostats))] + add_devices(devices) + + def fan_min_on_time_set_service(service): + """Set the minimum fan on time on the target thermostats.""" + entity_id = service.data.get('entity_id') + + if entity_id: + target_thermostats = [device for device in devices + if device.entity_id == entity_id] + else: + target_thermostats = devices + + fan_min_on_time = service.data[ATTR_FAN_MIN_ON_TIME] + + for thermostat in target_thermostats: + thermostat.set_fan_min_on_time(str(fan_min_on_time)) + + thermostat.update_ha_state(True) + + descriptions = load_yaml_config_file( + path.join(path.dirname(__file__), 'services.yaml')) + + hass.services.register( + DOMAIN, SERVICE_SET_FAN_MIN_ON_TIME, fan_min_on_time_set_service, + descriptions.get(SERVICE_SET_FAN_MIN_ON_TIME), + schema=SET_FAN_MIN_ON_TIME_SCHEMA) + + +# pylint: disable=too-many-public-methods, abstract-method +class Thermostat(ClimateDevice): + """A thermostat class for Ecobee.""" + + def __init__(self, data, thermostat_index, hold_temp): + """Initialize the thermostat.""" + self.data = data + self.thermostat_index = thermostat_index + self.thermostat = self.data.ecobee.get_thermostat( + self.thermostat_index) + self._name = self.thermostat['name'] + self.hold_temp = hold_temp + + def update(self): + """Get the latest state from the thermostat.""" + self.data.update() + self.thermostat = self.data.ecobee.get_thermostat( + self.thermostat_index) + + @property + def name(self): + """Return the name of the Ecobee Thermostat.""" + return self.thermostat['name'] + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return TEMP_FAHRENHEIT + + @property + def current_temperature(self): + """Return the current temperature.""" + return self.thermostat['runtime']['actualTemperature'] / 10 + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + if (self.operation_mode == 'heat' or + self.operation_mode == 'auxHeatOnly'): + return self.target_temperature_low + elif self.operation_mode == 'cool': + return self.target_temperature_high + else: + return (self.target_temperature_low + + self.target_temperature_high) / 2 + + @property + def target_temperature_low(self): + """Return the lower bound temperature we try to reach.""" + return int(self.thermostat['runtime']['desiredHeat'] / 10) + + @property + def target_temperature_high(self): + """Return the upper bound temperature we try to reach.""" + return int(self.thermostat['runtime']['desiredCool'] / 10) + + @property + def current_humidity(self): + """Return the current humidity.""" + return self.thermostat['runtime']['actualHumidity'] + + @property + def desired_fan_mode(self): + """Return the desired fan mode of operation.""" + return self.thermostat['runtime']['desiredFanMode'] + + @property + def fan(self): + """Return the current fan state.""" + if 'fan' in self.thermostat['equipmentStatus']: + return STATE_ON + else: + return STATE_OFF + + @property + def operation_mode(self): + """Return current operation ie. heat, cool, idle.""" + status = self.thermostat['equipmentStatus'] + if status == '': + return STATE_IDLE + elif 'Cool' in status: + return STATE_COOL + elif 'auxHeat' in status: + return STATE_HEAT + elif 'heatPump' in status: + return STATE_HEAT + else: + return status + + @property + def mode(self): + """Return current mode ie. home, away, sleep.""" + return self.thermostat['program']['currentClimateRef'] + + @property + def current_operation(self): + """Return current hvac mode ie. auto, auxHeatOnly, cool, heat, off.""" + return self.thermostat['settings']['hvacMode'] + + @property + def fan_min_on_time(self): + """Return current fan minimum on time.""" + return self.thermostat['settings']['fanMinOnTime'] + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + # Move these to Thermostat Device and make them global + return { + "humidity": self.current_humidity, + "fan": self.fan, + "mode": self.mode, + "operation_mode": self.current_operation, + "fan_min_on_time": self.fan_min_on_time + } + + @property + def is_away_mode_on(self): + """Return true if away mode is on.""" + mode = self.mode + events = self.thermostat['events'] + for event in events: + if event['running']: + mode = event['holdClimateRef'] + break + return 'away' in mode + + def turn_away_mode_on(self): + """Turn away on.""" + if self.hold_temp: + self.data.ecobee.set_climate_hold(self.thermostat_index, + "away", "indefinite") + else: + self.data.ecobee.set_climate_hold(self.thermostat_index, "away") + + def turn_away_mode_off(self): + """Turn away off.""" + self.data.ecobee.resume_program(self.thermostat_index) + + def set_temperature(self, temperature): + """Set new target temperature.""" + temperature = int(temperature) + low_temp = temperature - 1 + high_temp = temperature + 1 + if self.hold_temp: + self.data.ecobee.set_hold_temp(self.thermostat_index, low_temp, + high_temp, "indefinite") + else: + self.data.ecobee.set_hold_temp(self.thermostat_index, low_temp, + high_temp) + + def set_operation_mode(self, operation_mode): + """Set HVAC mode (auto, auxHeatOnly, cool, heat, off).""" + self.data.ecobee.set_hvac_mode(self.thermostat_index, operation_mode) + + def set_fan_min_on_time(self, fan_min_on_time): + """Set the minimum fan on time.""" + self.data.ecobee.set_fan_min_on_time(self.thermostat_index, + fan_min_on_time) + + # Home and Sleep mode aren't used in UI yet: + + # def turn_home_mode_on(self): + # """ Turns home mode on. """ + # self.data.ecobee.set_climate_hold(self.thermostat_index, "home") + + # def turn_home_mode_off(self): + # """ Turns home mode off. """ + # self.data.ecobee.resume_program(self.thermostat_index) + + # def turn_sleep_mode_on(self): + # """ Turns sleep mode on. """ + # self.data.ecobee.set_climate_hold(self.thermostat_index, "sleep") + + # def turn_sleep_mode_off(self): + # """ Turns sleep mode off. """ + # self.data.ecobee.resume_program(self.thermostat_index) diff --git a/homeassistant/components/climate/eq3btsmart.py b/homeassistant/components/climate/eq3btsmart.py new file mode 100644 index 00000000000..01114972811 --- /dev/null +++ b/homeassistant/components/climate/eq3btsmart.py @@ -0,0 +1,90 @@ +""" +Support for eq3 Bluetooth Smart thermostats. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.eq3btsmart/ +""" +import logging + +from homeassistant.components.climate import ClimateDevice +from homeassistant.const import TEMP_CELSIUS +from homeassistant.util.temperature import convert + +REQUIREMENTS = ['bluepy_devices==0.2.0'] + +CONF_MAC = 'mac' +CONF_DEVICES = 'devices' +CONF_ID = 'id' + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the eq3 BLE thermostats.""" + devices = [] + + for name, device_cfg in config[CONF_DEVICES].items(): + mac = device_cfg[CONF_MAC] + devices.append(EQ3BTSmartThermostat(mac, name)) + + add_devices(devices) + return True + + +# pylint: disable=too-many-instance-attributes, import-error, abstract-method +class EQ3BTSmartThermostat(ClimateDevice): + """Representation of a EQ3 Bluetooth Smart thermostat.""" + + def __init__(self, _mac, _name): + """Initialize the thermostat.""" + from bluepy_devices.devices import eq3btsmart + + self._name = _name + + self._thermostat = eq3btsmart.EQ3BTSmartThermostat(_mac) + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit of measurement that is used.""" + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Can not report temperature, so return target_temperature.""" + return self.target_temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._thermostat.target_temperature + + def set_temperature(self, temperature): + """Set new target temperature.""" + self._thermostat.target_temperature = temperature + + @property + def device_state_attributes(self): + """Return the device specific state attributes.""" + return {"mode": self._thermostat.mode, + "mode_readable": self._thermostat.mode_readable} + + @property + def min_temp(self): + """Return the minimum temperature.""" + return convert(self._thermostat.min_temp, TEMP_CELSIUS, + self.unit_of_measurement) + + @property + def max_temp(self): + """Return the maximum temperature.""" + return convert(self._thermostat.max_temp, TEMP_CELSIUS, + self.unit_of_measurement) + + def update(self): + """Update the data from the thermostat.""" + self._thermostat.update() diff --git a/homeassistant/components/climate/generic_thermostat.py b/homeassistant/components/climate/generic_thermostat.py new file mode 100644 index 00000000000..11e6707ad47 --- /dev/null +++ b/homeassistant/components/climate/generic_thermostat.py @@ -0,0 +1,216 @@ +""" +Adds support for generic thermostat units. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.generic_thermostat/ +""" +import logging +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components import switch +from homeassistant.components.climate import ( + STATE_HEAT, STATE_COOL, STATE_IDLE, ClimateDevice) +from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, STATE_ON, STATE_OFF +from homeassistant.helpers import condition +from homeassistant.helpers.event import track_state_change + +DEPENDENCIES = ['switch', 'sensor'] + +TOL_TEMP = 0.3 + +CONF_NAME = 'name' +DEFAULT_NAME = 'Generic Thermostat' +CONF_HEATER = 'heater' +CONF_SENSOR = 'target_sensor' +CONF_MIN_TEMP = 'min_temp' +CONF_MAX_TEMP = 'max_temp' +CONF_TARGET_TEMP = 'target_temp' +CONF_AC_MODE = 'ac_mode' +CONF_MIN_DUR = 'min_cycle_duration' + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = vol.Schema({ + vol.Required("platform"): "generic_thermostat", + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_HEATER): cv.entity_id, + vol.Required(CONF_SENSOR): cv.entity_id, + vol.Optional(CONF_MIN_TEMP): vol.Coerce(float), + vol.Optional(CONF_MAX_TEMP): vol.Coerce(float), + vol.Optional(CONF_TARGET_TEMP): vol.Coerce(float), + vol.Optional(CONF_AC_MODE): vol.Coerce(bool), + vol.Optional(CONF_MIN_DUR): vol.All(cv.time_period, cv.positive_timedelta), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the generic thermostat.""" + name = config.get(CONF_NAME) + heater_entity_id = config.get(CONF_HEATER) + sensor_entity_id = config.get(CONF_SENSOR) + min_temp = config.get(CONF_MIN_TEMP) + max_temp = config.get(CONF_MAX_TEMP) + target_temp = config.get(CONF_TARGET_TEMP) + ac_mode = config.get(CONF_AC_MODE) + min_cycle_duration = config.get(CONF_MIN_DUR) + + add_devices([GenericThermostat(hass, name, heater_entity_id, + sensor_entity_id, min_temp, + max_temp, target_temp, ac_mode, + min_cycle_duration)]) + + +# pylint: disable=too-many-instance-attributes, abstract-method +class GenericThermostat(ClimateDevice): + """Representation of a GenericThermostat device.""" + + # pylint: disable=too-many-arguments + def __init__(self, hass, name, heater_entity_id, sensor_entity_id, + min_temp, max_temp, target_temp, ac_mode, min_cycle_duration): + """Initialize the thermostat.""" + self.hass = hass + self._name = name + self.heater_entity_id = heater_entity_id + self.ac_mode = ac_mode + self.min_cycle_duration = min_cycle_duration + + self._active = False + self._cur_temp = None + self._min_temp = min_temp + self._max_temp = max_temp + self._target_temp = target_temp + self._unit = hass.config.units.temperature_unit + + track_state_change(hass, sensor_entity_id, self._sensor_changed) + + sensor_state = hass.states.get(sensor_entity_id) + if sensor_state: + self._update_temp(sensor_state) + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def name(self): + """Return the name of the thermostat.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit + + @property + def current_temperature(self): + """Return the sensor temperature.""" + return self._cur_temp + + @property + def operation(self): + """Return current operation ie. heat, cool, idle.""" + if self.ac_mode: + cooling = self._active and self._is_device_active + return STATE_COOL if cooling else STATE_IDLE + else: + heating = self._active and self._is_device_active + return STATE_HEAT if heating else STATE_IDLE + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temp + + def set_temperature(self, temperature): + """Set new target temperature.""" + self._target_temp = temperature + self._control_heating() + self.update_ha_state() + + @property + def min_temp(self): + """Return the minimum temperature.""" + # pylint: disable=no-member + if self._min_temp: + return self._min_temp + else: + # get default temp from super class + return ClimateDevice.min_temp.fget(self) + + @property + def max_temp(self): + """Return the maximum temperature.""" + # pylint: disable=no-member + if self._min_temp: + return self._max_temp + else: + # Get default temp from super class + return ClimateDevice.max_temp.fget(self) + + def _sensor_changed(self, entity_id, old_state, new_state): + """Called when temperature changes.""" + if new_state is None: + return + + self._update_temp(new_state) + self._control_heating() + self.update_ha_state() + + def _update_temp(self, state): + """Update thermostat with latest state from sensor.""" + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + + try: + self._cur_temp = self.hass.config.units.temperature( + float(state.state), unit) + except ValueError as ex: + _LOGGER.error('Unable to update from sensor: %s', ex) + + def _control_heating(self): + """Check if we need to turn heating on or off.""" + if not self._active and None not in (self._cur_temp, + self._target_temp): + self._active = True + _LOGGER.info('Obtained current and target temperature. ' + 'Generic thermostat active.') + + if not self._active: + return + + if self.min_cycle_duration: + if self._is_device_active: + current_state = STATE_ON + else: + current_state = STATE_OFF + long_enough = condition.state(self.hass, self.heater_entity_id, + current_state, + self.min_cycle_duration) + if not long_enough: + return + + if self.ac_mode: + too_hot = self._cur_temp - self._target_temp > TOL_TEMP + is_cooling = self._is_device_active + if too_hot and not is_cooling: + _LOGGER.info('Turning on AC %s', self.heater_entity_id) + switch.turn_on(self.hass, self.heater_entity_id) + elif not too_hot and is_cooling: + _LOGGER.info('Turning off AC %s', self.heater_entity_id) + switch.turn_off(self.hass, self.heater_entity_id) + else: + too_cold = self._target_temp - self._cur_temp > TOL_TEMP + is_heating = self._is_device_active + + if too_cold and not is_heating: + _LOGGER.info('Turning on heater %s', self.heater_entity_id) + switch.turn_on(self.hass, self.heater_entity_id) + elif not too_cold and is_heating: + _LOGGER.info('Turning off heater %s', self.heater_entity_id) + switch.turn_off(self.hass, self.heater_entity_id) + + @property + def _is_device_active(self): + """If the toggleable device is currently active.""" + return switch.is_on(self.hass, self.heater_entity_id) diff --git a/homeassistant/components/climate/heatmiser.py b/homeassistant/components/climate/heatmiser.py new file mode 100644 index 00000000000..c7dd5534f57 --- /dev/null +++ b/homeassistant/components/climate/heatmiser.py @@ -0,0 +1,114 @@ +""" +Support for the PRT Heatmiser themostats using the V3 protocol. + +See https://github.com/andylockran/heatmiserV3 for more info on the +heatmiserV3 module dependency. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.heatmiser/ +""" +import logging + +from homeassistant.components.climate import ClimateDevice +from homeassistant.const import TEMP_CELSIUS + +CONF_IPADDRESS = 'ipaddress' +CONF_PORT = 'port' +CONF_TSTATS = 'tstats' + +REQUIREMENTS = ["heatmiserV3==0.9.1"] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the heatmiser thermostat.""" + from heatmiserV3 import heatmiser, connection + + ipaddress = str(config[CONF_IPADDRESS]) + port = str(config[CONF_PORT]) + + if ipaddress is None or port is None: + _LOGGER.error("Missing required configuration items %s or %s", + CONF_IPADDRESS, CONF_PORT) + return False + + serport = connection.connection(ipaddress, port) + serport.open() + + tstats = [] + if CONF_TSTATS in config: + tstats = config[CONF_TSTATS] + + if tstats is None: + _LOGGER.error("No thermostats configured.") + return False + + for tstat in tstats: + add_devices([ + HeatmiserV3Thermostat( + heatmiser, + tstat.get("id"), + tstat.get("name"), + serport) + ]) + return + + +class HeatmiserV3Thermostat(ClimateDevice): + """Representation of a HeatmiserV3 thermostat.""" + + # pylint: disable=too-many-instance-attributes, abstract-method + def __init__(self, heatmiser, device, name, serport): + """Initialize the thermostat.""" + self.heatmiser = heatmiser + self.device = device + self.serport = serport + self._current_temperature = None + self._name = name + self._id = device + self.dcb = None + self.update() + self._target_temperature = int(self.dcb.get("roomset")) + + @property + def name(self): + """Return the name of the thermostat, if any.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit of measurement which this thermostat uses.""" + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + if self.dcb is not None: + low = self.dcb.get("floortemplow ") + high = self.dcb.get("floortemphigh") + temp = (high*256 + low)/10.0 + self._current_temperature = temp + else: + self._current_temperature = None + return self._current_temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temperature + + def set_temperature(self, temperature): + """Set new target temperature.""" + temperature = int(temperature) + self.heatmiser.hmSendAddress( + self._id, + 18, + temperature, + 1, + self.serport) + self._target_temperature = int(temperature) + + def update(self): + """Get the latest data.""" + self.dcb = self.heatmiser.hmReadAddress(self._id, 'prt', self.serport) diff --git a/homeassistant/components/climate/homematic.py b/homeassistant/components/climate/homematic.py new file mode 100644 index 00000000000..be81bb9326e --- /dev/null +++ b/homeassistant/components/climate/homematic.py @@ -0,0 +1,143 @@ +""" +Support for Homematic thermostats. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.homematic/ +""" +import logging +import homeassistant.components.homematic as homematic +from homeassistant.components.climate import ClimateDevice, STATE_AUTO +from homeassistant.util.temperature import convert +from homeassistant.const import TEMP_CELSIUS, STATE_UNKNOWN + +DEPENDENCIES = ['homematic'] + +STATE_MANUAL = "manual" +STATE_BOOST = "boost" + +HM_STATE_MAP = { + "AUTO_MODE": STATE_AUTO, + "MANU_MODE": STATE_MANUAL, + "BOOST_MODE": STATE_BOOST, +} + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_callback_devices, discovery_info=None): + """Setup the Homematic thermostat platform.""" + if discovery_info is None: + return + + return homematic.setup_hmdevice_discovery_helper(HMThermostat, + discovery_info, + add_callback_devices) + + +# pylint: disable=abstract-method +class HMThermostat(homematic.HMDevice, ClimateDevice): + """Representation of a Homematic thermostat.""" + + @property + def unit_of_measurement(self): + """Return the unit of measurement that is used.""" + return TEMP_CELSIUS + + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + if not self.available: + return None + + # read state and search + for mode, state in HM_STATE_MAP.items(): + code = getattr(self._hmdevice, mode, 0) + if self._data.get('CONTROL_MODE') == code: + return state + + @property + def operation_list(self): + """List of available operation modes.""" + if not self.available: + return None + op_list = [] + + # generate list + for mode in self._hmdevice.ACTIONNODE: + if mode in HM_STATE_MAP: + op_list.append(HM_STATE_MAP.get(mode)) + + return op_list + + @property + def current_humidity(self): + """Return the current humidity.""" + if not self.available: + return None + return self._data.get('ACTUAL_HUMIDITY', None) + + @property + def current_temperature(self): + """Return the current temperature.""" + if not self.available: + return None + return self._data.get('ACTUAL_TEMPERATURE', None) + + @property + def target_temperature(self): + """Return the target temperature.""" + if not self.available: + return None + return self._data.get('SET_TEMPERATURE', None) + + def set_temperature(self, temperature): + """Set new target temperature.""" + if not self.available: + return None + self._hmdevice.set_temperature(temperature) + + def set_operation_mode(self, operation_mode): + """Set new target operation mode.""" + for mode, state in HM_STATE_MAP.items(): + if state == operation_mode: + code = getattr(self._hmdevice, mode, 0) + self._hmdevice.STATE = code + + @property + def min_temp(self): + """Return the minimum temperature - 4.5 means off.""" + return convert(4.5, TEMP_CELSIUS, self.unit_of_measurement) + + @property + def max_temp(self): + """Return the maximum temperature - 30.5 means on.""" + return convert(30.5, TEMP_CELSIUS, self.unit_of_measurement) + + def _check_hm_to_ha_object(self): + """Check if possible to use the Homematic object as this HA type.""" + from pyhomematic.devicetypes.thermostats import HMThermostat\ + as pyHMThermostat + + # Check compatibility from HMDevice + if not super()._check_hm_to_ha_object(): + return False + + # Check if the Homematic device correct for this HA device + if isinstance(self._hmdevice, pyHMThermostat): + return True + + _LOGGER.critical("This %s can't be use as thermostat", self._name) + return False + + def _init_data_struct(self): + """Generate a data dict (self._data) from the Homematic metadata.""" + super()._init_data_struct() + + # Add state to data dict + self._data.update({"CONTROL_MODE": STATE_UNKNOWN, + "SET_TEMPERATURE": STATE_UNKNOWN, + "ACTUAL_TEMPERATURE": STATE_UNKNOWN}) + + # support humidity + if 'ACTUAL_HUMIDITY' in self._hmdevice.SENSORNODE: + self._data.update({'ACTUAL_HUMIDITY': STATE_UNKNOWN}) diff --git a/homeassistant/components/climate/honeywell.py b/homeassistant/components/climate/honeywell.py new file mode 100644 index 00000000000..1efce2b95de --- /dev/null +++ b/homeassistant/components/climate/honeywell.py @@ -0,0 +1,266 @@ +""" +Support for Honeywell Round Connected and Honeywell Evohome thermostats. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.honeywell/ +""" +import logging +import socket + +from homeassistant.components.climate import ClimateDevice +from homeassistant.const import ( + CONF_PASSWORD, CONF_USERNAME, TEMP_CELSIUS, TEMP_FAHRENHEIT) + +REQUIREMENTS = ['evohomeclient==0.2.5', + 'somecomfort==0.2.1'] + +_LOGGER = logging.getLogger(__name__) + +CONF_AWAY_TEMP = "away_temperature" +DEFAULT_AWAY_TEMP = 16 + + +def _setup_round(username, password, config, add_devices): + """Setup rounding function.""" + from evohomeclient import EvohomeClient + + try: + away_temp = float(config.get(CONF_AWAY_TEMP, DEFAULT_AWAY_TEMP)) + except ValueError: + _LOGGER.error("value entered for item %s should convert to a number", + CONF_AWAY_TEMP) + return False + + evo_api = EvohomeClient(username, password) + + try: + zones = evo_api.temperatures(force_refresh=True) + for i, zone in enumerate(zones): + add_devices([RoundThermostat(evo_api, + zone['id'], + i == 0, + away_temp)]) + except socket.error: + _LOGGER.error( + "Connection error logging into the honeywell evohome web service" + ) + return False + return True + + +# config will be used later +def _setup_us(username, password, config, add_devices): + """Setup user.""" + import somecomfort + + try: + client = somecomfort.SomeComfort(username, password) + except somecomfort.AuthError: + _LOGGER.error('Failed to login to honeywell account %s', username) + return False + except somecomfort.SomeComfortError as ex: + _LOGGER.error('Failed to initialize honeywell client: %s', str(ex)) + return False + + dev_id = config.get('thermostat') + loc_id = config.get('location') + + add_devices([HoneywellUSThermostat(client, device) + for location in client.locations_by_id.values() + for device in location.devices_by_id.values() + if ((not loc_id or location.locationid == loc_id) and + (not dev_id or device.deviceid == dev_id))]) + return True + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the honeywel thermostat.""" + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + region = config.get('region', 'eu').lower() + + if username is None or password is None: + _LOGGER.error("Missing required configuration items %s or %s", + CONF_USERNAME, CONF_PASSWORD) + return False + if region not in ('us', 'eu'): + _LOGGER.error('Region `%s` is invalid (use either us or eu)', region) + return False + + if region == 'us': + return _setup_us(username, password, config, add_devices) + else: + return _setup_round(username, password, config, add_devices) + + +class RoundThermostat(ClimateDevice): + """Representation of a Honeywell Round Connected thermostat.""" + + # pylint: disable=too-many-instance-attributes, abstract-method + def __init__(self, device, zone_id, master, away_temp): + """Initialize the thermostat.""" + self.device = device + self._current_temperature = None + self._target_temperature = None + self._name = "round connected" + self._id = zone_id + self._master = master + self._is_dhw = False + self._away_temp = away_temp + self._away = False + self.update() + + @property + def name(self): + """Return the name of the honeywell, if any.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._current_temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + if self._is_dhw: + return None + return self._target_temperature + + def set_temperature(self, temperature): + """Set new target temperature.""" + self.device.set_temperature(self._name, temperature) + + @property + def current_operation(self: ClimateDevice) -> str: + """Get the current operation of the system.""" + return getattr(self.device, 'system_mode', None) + + @property + def is_away_mode_on(self): + """Return true if away mode is on.""" + return self._away + + def set_operation_mode(self: ClimateDevice, operation_mode: str) -> None: + """Set the HVAC mode for the thermostat.""" + if hasattr(self.device, 'system_mode'): + self.device.system_mode = operation_mode + + def turn_away_mode_on(self): + """Turn away on. + + Evohome does have a proprietary away mode, but it doesn't really work + the way it should. For example: If you set a temperature manually + it doesn't get overwritten when away mode is switched on. + """ + self._away = True + self.device.set_temperature(self._name, self._away_temp) + + def turn_away_mode_off(self): + """Turn away off.""" + self._away = False + self.device.cancel_temp_override(self._name) + + def update(self): + """Get the latest date.""" + try: + # Only refresh if this is the "master" device, + # others will pick up the cache + for val in self.device.temperatures(force_refresh=self._master): + if val['id'] == self._id: + data = val + + except StopIteration: + _LOGGER.error("Did not receive any temperature data from the " + "evohomeclient API.") + return + + self._current_temperature = data['temp'] + self._target_temperature = data['setpoint'] + if data['thermostat'] == "DOMESTIC_HOT_WATER": + self._name = "Hot Water" + self._is_dhw = True + else: + self._name = data['name'] + self._is_dhw = False + + +# pylint: disable=abstract-method +class HoneywellUSThermostat(ClimateDevice): + """Representation of a Honeywell US Thermostat.""" + + def __init__(self, client, device): + """Initialize the thermostat.""" + self._client = client + self._device = device + + @property + def is_fan_on(self): + """Return true if fan is on.""" + return self._device.fan_running + + @property + def name(self): + """Return the name of the honeywell, if any.""" + return self._device.name + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return (TEMP_CELSIUS if self._device.temperature_unit == 'C' + else TEMP_FAHRENHEIT) + + @property + def current_temperature(self): + """Return the current temperature.""" + self._device.refresh() + return self._device.current_temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + if self._device.system_mode == 'cool': + return self._device.setpoint_cool + else: + return self._device.setpoint_heat + + @property + def current_operation(self: ClimateDevice) -> str: + """Return current operation ie. heat, cool, idle.""" + return getattr(self._device, 'system_mode', None) + + def set_temperature(self, temperature): + """Set target temperature.""" + import somecomfort + try: + if self._device.system_mode == 'cool': + self._device.setpoint_cool = temperature + else: + self._device.setpoint_heat = temperature + except somecomfort.SomeComfortError: + _LOGGER.error('Temperature %.1f out of range', temperature) + + @property + def device_state_attributes(self): + """Return the device specific state attributes.""" + return {'fan': (self.is_fan_on and 'running' or 'idle'), + 'fanmode': self._device.fan_mode, + 'system_mode': self._device.system_mode} + + def turn_away_mode_on(self): + """Turn away on.""" + pass + + def turn_away_mode_off(self): + """Turn away off.""" + pass + + def set_operation_mode(self: ClimateDevice, operation_mode: str) -> None: + """Set the system mode (Cool, Heat, etc).""" + if hasattr(self._device, 'system_mode'): + self._device.system_mode = operation_mode diff --git a/homeassistant/components/climate/knx.py b/homeassistant/components/climate/knx.py new file mode 100644 index 00000000000..10f02d80cc7 --- /dev/null +++ b/homeassistant/components/climate/knx.py @@ -0,0 +1,83 @@ +""" +Support for KNX thermostats. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/knx/ +""" +import logging + +from homeassistant.components.climate import ClimateDevice +from homeassistant.const import TEMP_CELSIUS + +from homeassistant.components.knx import ( + KNXConfig, KNXMultiAddressDevice) + +DEPENDENCIES = ["knx"] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Create and add an entity based on the configuration.""" + add_entities([ + KNXThermostat(hass, KNXConfig(config)) + ]) + + +class KNXThermostat(KNXMultiAddressDevice, ClimateDevice): + """Representation of a KNX thermostat. + + A KNX thermostat will has the following parameters: + - temperature (current temperature) + - setpoint (target temperature in HASS terms) + - operation mode selection (comfort/night/frost protection) + + This version supports only polling. Messages from the KNX bus do not + automatically update the state of the thermostat (to be implemented + in future releases) + """ + + def __init__(self, hass, config): + """Initialize the thermostat based on the given configuration.""" + KNXMultiAddressDevice.__init__(self, hass, config, + ["temperature", "setpoint"], + ["mode"]) + + self._unit_of_measurement = TEMP_CELSIUS # KNX always used celsius + self._away = False # not yet supported + self._is_fan_on = False # not yet supported + + @property + def should_poll(self): + """Polling is needed for the KNX thermostat.""" + return True + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit_of_measurement + + @property + def current_temperature(self): + """Return the current temperature.""" + from knxip.conversion import knx2_to_float + + return knx2_to_float(self.value("temperature")) + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + from knxip.conversion import knx2_to_float + + return knx2_to_float(self.value("setpoint")) + + def set_temperature(self, temperature): + """Set new target temperature.""" + from knxip.conversion import float_to_knx2 + + self.set_value("setpoint", float_to_knx2(temperature)) + _LOGGER.debug("Set target temperature to %s", temperature) + + def set_operation_mode(self, operation_mode): + """Set operation mode.""" + raise NotImplementedError() diff --git a/homeassistant/components/climate/nest.py b/homeassistant/components/climate/nest.py new file mode 100644 index 00000000000..39746bff601 --- /dev/null +++ b/homeassistant/components/climate/nest.py @@ -0,0 +1,189 @@ +""" +Support for Nest thermostats. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.nest/ +""" +import voluptuous as vol + +import homeassistant.components.nest as nest +from homeassistant.components.climate import ( + STATE_COOL, STATE_HEAT, STATE_IDLE, ClimateDevice) +from homeassistant.const import TEMP_CELSIUS, CONF_PLATFORM, CONF_SCAN_INTERVAL + +DEPENDENCIES = ['nest'] + +PLATFORM_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): nest.DOMAIN, + vol.Optional(CONF_SCAN_INTERVAL): + vol.All(vol.Coerce(int), vol.Range(min=1)), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Nest thermostat.""" + add_devices([NestThermostat(structure, device) + for structure, device in nest.devices()]) + + +# pylint: disable=abstract-method +class NestThermostat(ClimateDevice): + """Representation of a Nest thermostat.""" + + def __init__(self, structure, device): + """Initialize the thermostat.""" + self.structure = structure + self.device = device + + @property + def name(self): + """Return the name of the nest, if any.""" + location = self.device.where + name = self.device.name + if location is None: + return name + else: + if name == '': + return location.capitalize() + else: + return location.capitalize() + '(' + name + ')' + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def device_state_attributes(self): + """Return the device specific state attributes.""" + # Move these to Thermostat Device and make them global + return { + "humidity": self.device.humidity, + "target_humidity": self.device.target_humidity, + "mode": self.device.mode + } + + @property + def current_temperature(self): + """Return the current temperature.""" + return self.device.temperature + + @property + def operation(self): + """Return current operation ie. heat, cool, idle.""" + if self.device.hvac_ac_state is True: + return STATE_COOL + elif self.device.hvac_heater_state is True: + return STATE_HEAT + else: + return STATE_IDLE + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + if self.device.mode == 'range': + low, high = self.target_temperature_low, \ + self.target_temperature_high + if self.operation == STATE_COOL: + temp = high + elif self.operation == STATE_HEAT: + temp = low + else: + # If the outside temp is lower than the current temp, consider + # the 'low' temp to the target, otherwise use the high temp + if (self.device.structure.weather.current.temperature < + self.current_temperature): + temp = low + else: + temp = high + else: + if self.is_away_mode_on: + # away_temperature is a low, high tuple. Only one should be set + # if not in range mode, the other will be None + temp = self.device.away_temperature[0] or \ + self.device.away_temperature[1] + else: + temp = self.device.target + + return temp + + @property + def target_temperature_low(self): + """Return the lower bound temperature we try to reach.""" + if self.is_away_mode_on and self.device.away_temperature[0]: + # away_temperature is always a low, high tuple + return self.device.away_temperature[0] + if self.device.mode == 'range': + return self.device.target[0] + return self.target_temperature + + @property + def target_temperature_high(self): + """Return the upper bound temperature we try to reach.""" + if self.is_away_mode_on and self.device.away_temperature[1]: + # away_temperature is always a low, high tuple + return self.device.away_temperature[1] + if self.device.mode == 'range': + return self.device.target[1] + return self.target_temperature + + @property + def is_away_mode_on(self): + """Return if away mode is on.""" + return self.structure.away + + def set_temperature(self, temperature): + """Set new target temperature.""" + if self.device.mode == 'range': + if self.target_temperature == self.target_temperature_low: + temperature = (temperature, self.target_temperature_high) + elif self.target_temperature == self.target_temperature_high: + temperature = (self.target_temperature_low, temperature) + self.device.target = temperature + + def set_operation_mode(self, operation_mode): + """Set operation mode.""" + self.device.mode = operation_mode + + def turn_away_mode_on(self): + """Turn away on.""" + self.structure.away = True + + def turn_away_mode_off(self): + """Turn away off.""" + self.structure.away = False + + @property + def is_fan_on(self): + """Return whether the fan is on.""" + return self.device.fan + + def turn_fan_on(self): + """Turn fan on.""" + self.device.fan = True + + def turn_fan_off(self): + """Turn fan off.""" + self.device.fan = False + + @property + def min_temp(self): + """Identify min_temp in Nest API or defaults if not available.""" + temp = self.device.away_temperature.low + if temp is None: + return super().min_temp + else: + return temp + + @property + def max_temp(self): + """Identify max_temp in Nest API or defaults if not available.""" + temp = self.device.away_temperature.high + if temp is None: + return super().max_temp + else: + return temp + + def update(self): + """Python-nest has its own mechanism for staying up to date.""" + pass diff --git a/homeassistant/components/climate/proliphix.py b/homeassistant/components/climate/proliphix.py new file mode 100644 index 00000000000..c6e8ed69617 --- /dev/null +++ b/homeassistant/components/climate/proliphix.py @@ -0,0 +1,90 @@ +""" +Support for Proliphix NT10e Thermostats. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.proliphix/ +""" +from homeassistant.components.climate import ( + STATE_COOL, STATE_HEAT, STATE_IDLE, ClimateDevice) +from homeassistant.const import ( + CONF_HOST, CONF_PASSWORD, CONF_USERNAME, TEMP_FAHRENHEIT) + +REQUIREMENTS = ['proliphix==0.3.1'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Proliphix thermostats.""" + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + host = config.get(CONF_HOST) + + import proliphix + + pdp = proliphix.PDP(host, username, password) + + add_devices([ + ProliphixThermostat(pdp) + ]) + + +# pylint: disable=abstract-method +class ProliphixThermostat(ClimateDevice): + """Representation a Proliphix thermostat.""" + + def __init__(self, pdp): + """Initialize the thermostat.""" + self._pdp = pdp + # initial data + self._pdp.update() + self._name = self._pdp.name + + @property + def should_poll(self): + """Polling needed for thermostat.""" + return True + + def update(self): + """Update the data from the thermostat.""" + self._pdp.update() + + @property + def name(self): + """Return the name of the thermostat.""" + return self._name + + @property + def device_state_attributes(self): + """Return the device specific state attributes.""" + return { + "fan": self._pdp.fan_state + } + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return TEMP_FAHRENHEIT + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._pdp.cur_temp + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._pdp.setback + + @property + def current_operation(self): + """Return the current state of the thermostat.""" + state = self._pdp.hvac_state + if state in (1, 2): + return STATE_IDLE + elif state == 3: + return STATE_HEAT + elif state == 6: + return STATE_COOL + + def set_temperature(self, temperature): + """Set new target temperature.""" + self._pdp.setback = temperature diff --git a/homeassistant/components/climate/radiotherm.py b/homeassistant/components/climate/radiotherm.py new file mode 100644 index 00000000000..deee3d53f3f --- /dev/null +++ b/homeassistant/components/climate/radiotherm.py @@ -0,0 +1,136 @@ +""" +Support for Radio Thermostat wifi-enabled home thermostats. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.radiotherm/ +""" +import datetime +import logging +from urllib.error import URLError + +from homeassistant.components.climate import ( + STATE_AUTO, STATE_COOL, STATE_HEAT, STATE_IDLE, STATE_OFF, + ClimateDevice) +from homeassistant.const import CONF_HOST, TEMP_FAHRENHEIT + +REQUIREMENTS = ['radiotherm==1.2'] +HOLD_TEMP = 'hold_temp' +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Radio Thermostat.""" + import radiotherm + + hosts = [] + if CONF_HOST in config: + hosts = config[CONF_HOST] + else: + hosts.append(radiotherm.discover.discover_address()) + + if hosts is None: + _LOGGER.error("No radiotherm thermostats detected.") + return False + + hold_temp = config.get(HOLD_TEMP, False) + tstats = [] + + for host in hosts: + try: + tstat = radiotherm.get_thermostat(host) + tstats.append(RadioThermostat(tstat, hold_temp)) + except (URLError, OSError): + _LOGGER.exception("Unable to connect to Radio Thermostat: %s", + host) + + add_devices(tstats) + + +# pylint: disable=abstract-method +class RadioThermostat(ClimateDevice): + """Representation of a Radio Thermostat.""" + + def __init__(self, device, hold_temp): + """Initialize the thermostat.""" + self.device = device + self.set_time() + self._target_temperature = None + self._current_temperature = None + self._current_operation = STATE_IDLE + self._name = None + self.hold_temp = hold_temp + self.update() + + @property + def name(self): + """Return the name of the Radio Thermostat.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return TEMP_FAHRENHEIT + + @property + def device_state_attributes(self): + """Return the device specific state attributes.""" + return { + "fan": self.device.fmode['human'], + "mode": self.device.tmode['human'] + } + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._current_temperature + + @property + def current_operation(self): + """Return the current operation. head, cool idle.""" + return self._current_operation + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temperature + + def update(self): + """Update the data from the thermostat.""" + self._current_temperature = self.device.temp['raw'] + self._name = self.device.name['raw'] + if self.device.tmode['human'] == 'Cool': + self._target_temperature = self.device.t_cool['raw'] + self._current_operation = STATE_COOL + elif self.device.tmode['human'] == 'Heat': + self._target_temperature = self.device.t_heat['raw'] + self._current_operation = STATE_HEAT + else: + self._current_operation = STATE_IDLE + + def set_temperature(self, temperature): + """Set new target temperature.""" + if self._current_operation == STATE_COOL: + self.device.t_cool = temperature + elif self._current_operation == STATE_HEAT: + self.device.t_heat = temperature + if self.hold_temp: + self.device.hold = 1 + else: + self.device.hold = 0 + + def set_time(self): + """Set device time.""" + now = datetime.datetime.now() + self.device.time = {'day': now.weekday(), + 'hour': now.hour, 'minute': now.minute} + + def set_operation_mode(self, operation_mode): + """Set operation mode (auto, cool, heat, off).""" + if operation_mode == STATE_OFF: + self.device.tmode = 0 + elif operation_mode == STATE_AUTO: + self.device.tmode = 3 + elif operation_mode == STATE_COOL: + self.device.t_cool = self._target_temperature + elif operation_mode == STATE_HEAT: + self.device.t_heat = self._target_temperature diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml new file mode 100644 index 00000000000..3a037d2a48b --- /dev/null +++ b/homeassistant/components/climate/services.yaml @@ -0,0 +1,84 @@ +set_aux_heat: + description: Turn auxillary heater on/off for climate device + + fields: + entity_id: + description: Name(s) of entities to change + example: 'climate.kitchen' + + aux_heat: + description: New value of axillary heater + example: true + +set_away_mode: + description: Turn away mode on/off for climate device + + fields: + entity_id: + description: Name(s) of entities to change + example: 'climate.kitchen' + + away_mode: + description: New value of away mode + example: true + +set_temperature: + description: Set target temperature of climate device + + fields: + entity_id: + description: Name(s) of entities to change + example: 'climate.kitchen' + + temperature: + description: New target temperature for hvac + example: 25 + +set_humidity: + description: Set target humidity of climate device + + fields: + entity_id: + description: Name(s) of entities to change + example: 'climate.kitchen' + + humidity: + description: New target humidity for climate device + example: 60 + +set_fan_mode: + description: Set fan operation for climate device + + fields: + entity_id: + description: Name(s) of entities to change + example: 'climate.nest' + + fan: + description: New value of fan mode + example: On Low + +set_operation_mode: + description: Set operation mode for climate device + + fields: + entity_id: + description: Name(s) of entities to change + example: 'climet.nest' + + operation_mode: + description: New value of operation mode + example: Heat + + +set_swing_mode: + description: Set swing operation for climate device + + fields: + entity_id: + description: Name(s) of entities to change + example: '.nest' + + swing_mode: + description: New value of swing mode + example: 1 diff --git a/homeassistant/components/climate/zwave.py b/homeassistant/components/climate/zwave.py new file mode 100755 index 00000000000..24ef45eb952 --- /dev/null +++ b/homeassistant/components/climate/zwave.py @@ -0,0 +1,253 @@ +""" +Support for ZWave climate devices. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.zwave/ +""" +# Because we do not compile openzwave on CI +# pylint: disable=import-error +import logging +from homeassistant.components.climate import DOMAIN +from homeassistant.components.climate import ClimateDevice +from homeassistant.components.zwave import ( + ATTR_NODE_ID, ATTR_VALUE_ID, ZWaveDeviceEntity) +from homeassistant.components import zwave +from homeassistant.const import (TEMP_FAHRENHEIT, TEMP_CELSIUS) + +_LOGGER = logging.getLogger(__name__) + +CONF_NAME = 'name' +DEFAULT_NAME = 'ZWave Climate' + +REMOTEC = 0x5254 +REMOTEC_ZXT_120 = 0x8377 +REMOTEC_ZXT_120_THERMOSTAT = (REMOTEC, REMOTEC_ZXT_120) + +COMMAND_CLASS_SENSOR_MULTILEVEL = 0x31 +COMMAND_CLASS_THERMOSTAT_MODE = 0x40 +COMMAND_CLASS_THERMOSTAT_SETPOINT = 0x43 +COMMAND_CLASS_THERMOSTAT_FAN_MODE = 0x44 +COMMAND_CLASS_CONFIGURATION = 0x70 + +WORKAROUND_ZXT_120 = 'zxt_120' + +DEVICE_MAPPINGS = { + REMOTEC_ZXT_120_THERMOSTAT: WORKAROUND_ZXT_120 +} + +ZXT_120_SET_TEMP = { + 'Heat': 1, + 'Cool': 2, + 'Dry Air': 8, + 'Auto Changeover': 10 +} + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the ZWave Climate devices.""" + if discovery_info is None or zwave.NETWORK is None: + _LOGGER.debug("No discovery_info=%s or no NETWORK=%s", + discovery_info, zwave.NETWORK) + return + + node = zwave.NETWORK.nodes[discovery_info[ATTR_NODE_ID]] + value = node.values[discovery_info[ATTR_VALUE_ID]] + value.set_change_verified(False) + add_devices([ZWaveClimate(value)]) + _LOGGER.debug("discovery_info=%s and zwave.NETWORK=%s", + discovery_info, zwave.NETWORK) + + +# pylint: disable=too-many-arguments, abstract-method +class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice): + """Represents a ZWave Climate device.""" + + # pylint: disable=too-many-public-methods, too-many-instance-attributes + def __init__(self, value): + """Initialize the zwave climate device.""" + from openzwave.network import ZWaveNetwork + from pydispatch import dispatcher + ZWaveDeviceEntity.__init__(self, value, DOMAIN) + self._node = value.node + self._target_temperature = None + self._current_temperature = None + self._current_operation = None + self._operation_list = None + self._current_fan_mode = None + self._fan_list = None + self._current_swing_mode = None + self._swing_list = None + self._unit = None + self._index = None + self._zxt_120 = None + self.update_properties() + # register listener + dispatcher.connect( + self.value_changed, ZWaveNetwork.SIGNAL_VALUE_CHANGED) + # Make sure that we have values for the key before converting to int + if (value.node.manufacturer_id.strip() and + value.node.product_id.strip()): + specific_sensor_key = (int(value.node.manufacturer_id, 16), + int(value.node.product_id, 16)) + + if specific_sensor_key in DEVICE_MAPPINGS: + if DEVICE_MAPPINGS[specific_sensor_key] == WORKAROUND_ZXT_120: + _LOGGER.debug("Remotec ZXT-120 Zwave Thermostat" + " workaround") + self._zxt_120 = 1 + + def value_changed(self, value): + """Called when a value has changed on the network.""" + if self._value.value_id == value.value_id or \ + self._value.node == value.node: + self.update_properties() + self.update_ha_state() + _LOGGER.debug("Value changed on network %s", value) + + def update_properties(self): + """Callback on data change for the registered node/value pair.""" + # Set point + temps = [] + for value in self._node.get_values( + class_id=COMMAND_CLASS_THERMOSTAT_SETPOINT).values(): + self._unit = value.units + temps.append(int(value.data)) + if value.index == self._index: + self._target_temperature = int(value.data) + self._target_temperature_high = max(temps) + self._target_temperature_low = min(temps) + # Operation Mode + for value in self._node.get_values( + class_id=COMMAND_CLASS_THERMOSTAT_MODE).values(): + self._current_operation = value.data + self._operation_list = list(value.data_items) + _LOGGER.debug("self._operation_list=%s", self._operation_list) + _LOGGER.debug("self._current_operation=%s", + self._current_operation) + # Current Temp + for value in self._node.get_values( + class_id=COMMAND_CLASS_SENSOR_MULTILEVEL).values(): + if value.label == 'Temperature': + self._current_temperature = int(value.data) + # Fan Mode + for value in self._node.get_values( + class_id=COMMAND_CLASS_THERMOSTAT_FAN_MODE).values(): + self._current_fan_mode = value.data + self._fan_list = list(value.data_items) + _LOGGER.debug("self._fan_list=%s", self._fan_list) + _LOGGER.debug("self._current_fan_mode=%s", + self._current_fan_mode) + # Swing mode + if self._zxt_120 == 1: + for value in self._node.get_values( + class_id=COMMAND_CLASS_CONFIGURATION).values(): + if value.command_class == 112 and value.index == 33: + self._current_swing_mode = value.data + self._swing_list = list(value.data_items) + _LOGGER.debug("self._swing_list=%s", self._swing_list) + _LOGGER.debug("self._current_swing_mode=%s", + self._current_swing_mode) + + @property + def should_poll(self): + """No polling on ZWave.""" + return False + + @property + def current_fan_mode(self): + """Return the fan speed set.""" + return self._current_fan_mode + + @property + def fan_list(self): + """List of available fan modes.""" + return self._fan_list + + @property + def current_swing_mode(self): + """Return the swing mode set.""" + return self._current_swing_mode + + @property + def swing_list(self): + """List of available swing modes.""" + return self._swing_list + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + unit = self._unit + if unit == 'C': + return TEMP_CELSIUS + elif unit == 'F': + return TEMP_FAHRENHEIT + else: + _LOGGER.exception("unit_of_measurement=%s is not valid", + unit) + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._current_temperature + + @property + def current_operation(self): + """Return the current operation mode.""" + return self._current_operation + + @property + def operation_list(self): + """List of available operation modes.""" + return self._operation_list + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temperature + + def set_temperature(self, temperature): + """Set new target temperature.""" + for value in self._node.get_values( + class_id=COMMAND_CLASS_THERMOSTAT_SETPOINT).values(): + if value.command_class != 67 and value.index != self._index: + continue + if self._zxt_120: + # ZXT-120 does not support get setpoint + self._target_temperature = temperature + if ZXT_120_SET_TEMP.get(self._current_operation) \ + != value.index: + continue + _LOGGER.debug("ZXT_120_SET_TEMP=%s and" + " self._current_operation=%s", + ZXT_120_SET_TEMP.get(self._current_operation), + self._current_operation) + # ZXT-120 responds only to whole int + value.data = int(round(temperature, 0)) + else: + value.data = int(temperature) + break + + def set_fan_mode(self, fan): + """Set new target fan mode.""" + for value in self._node.get_values( + class_id=COMMAND_CLASS_THERMOSTAT_FAN_MODE).values(): + if value.command_class == 68 and value.index == 0: + value.data = bytes(fan, 'utf-8') + break + + def set_operation_mode(self, operation_mode): + """Set new target operation mode.""" + for value in self._node.get_values( + class_id=COMMAND_CLASS_THERMOSTAT_MODE).values(): + if value.command_class == 64 and value.index == 0: + value.data = bytes(operation_mode, 'utf-8') + break + + def set_swing_mode(self, swing_mode): + """Set new target swing mode.""" + if self._zxt_120 == 1: + for value in self._node.get_values( + class_id=COMMAND_CLASS_CONFIGURATION).values(): + if value.command_class == 112 and value.index == 33: + value.data = bytes(swing_mode, 'utf-8') + break diff --git a/homeassistant/components/configurator.py b/homeassistant/components/configurator.py index b7c102a584c..9f5cb397587 100644 --- a/homeassistant/components/configurator.py +++ b/homeassistant/components/configurator.py @@ -11,26 +11,26 @@ import logging from homeassistant.const import EVENT_TIME_CHANGED, ATTR_FRIENDLY_NAME from homeassistant.helpers.entity import generate_entity_id -DOMAIN = "configurator" -ENTITY_ID_FORMAT = DOMAIN + ".{}" - -SERVICE_CONFIGURE = "configure" - -STATE_CONFIGURE = "configure" -STATE_CONFIGURED = "configured" - -ATTR_LINK_NAME = "link_name" -ATTR_LINK_URL = "link_url" -ATTR_CONFIGURE_ID = "configure_id" -ATTR_DESCRIPTION = "description" -ATTR_DESCRIPTION_IMAGE = "description_image" -ATTR_SUBMIT_CAPTION = "submit_caption" -ATTR_FIELDS = "fields" -ATTR_ERRORS = "errors" - -_REQUESTS = {} _INSTANCES = {} _LOGGER = logging.getLogger(__name__) +_REQUESTS = {} + +ATTR_CONFIGURE_ID = 'configure_id' +ATTR_DESCRIPTION = 'description' +ATTR_DESCRIPTION_IMAGE = 'description_image' +ATTR_ERRORS = 'errors' +ATTR_FIELDS = 'fields' +ATTR_LINK_NAME = 'link_name' +ATTR_LINK_URL = 'link_url' +ATTR_SUBMIT_CAPTION = 'submit_caption' + +DOMAIN = 'configurator' + +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +SERVICE_CONFIGURE = 'configure' +STATE_CONFIGURE = 'configure' +STATE_CONFIGURED = 'configured' # pylint: disable=too-many-arguments diff --git a/homeassistant/components/conversation.py b/homeassistant/components/conversation.py index 6b580fa21fb..89cbd73b6ab 100644 --- a/homeassistant/components/conversation.py +++ b/homeassistant/components/conversation.py @@ -15,20 +15,20 @@ from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON) import homeassistant.helpers.config_validation as cv -DOMAIN = "conversation" +REQUIREMENTS = ['fuzzywuzzy==0.11.1'] -SERVICE_PROCESS = "process" +ATTR_TEXT = 'text' -ATTR_TEXT = "text" +DOMAIN = 'conversation' + +REGEX_TURN_COMMAND = re.compile(r'turn (?P(?: |\w)+) (?P\w+)') + +SERVICE_PROCESS = 'process' SERVICE_PROCESS_SCHEMA = vol.Schema({ vol.Required(ATTR_TEXT): vol.All(cv.string, vol.Lower), }) -REGEX_TURN_COMMAND = re.compile(r'turn (?P(?: |\w)+) (?P\w+)') - -REQUIREMENTS = ['fuzzywuzzy==0.11.1'] - def setup(hass, config): """Register the process service.""" diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py new file mode 100644 index 00000000000..3f08c7ff229 --- /dev/null +++ b/homeassistant/components/cover/__init__.py @@ -0,0 +1,236 @@ +""" +Support for Cover devices. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover/ +""" +import os +import logging + +import voluptuous as vol + +from homeassistant.config import load_yaml_config_file +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa +import homeassistant.helpers.config_validation as cv +from homeassistant.components import group +from homeassistant.const import ( + SERVICE_OPEN_COVER, SERVICE_CLOSE_COVER, SERVICE_SET_COVER_POSITION, + SERVICE_STOP_COVER, SERVICE_OPEN_COVER_TILT, SERVICE_CLOSE_COVER_TILT, + SERVICE_STOP_COVER_TILT, SERVICE_SET_COVER_TILT_POSITION, STATE_OPEN, + STATE_CLOSED, STATE_UNKNOWN, ATTR_ENTITY_ID) + + +DOMAIN = 'cover' +SCAN_INTERVAL = 15 + +GROUP_NAME_ALL_COVERS = 'all_covers' +ENTITY_ID_ALL_COVERS = group.ENTITY_ID_FORMAT.format( + GROUP_NAME_ALL_COVERS) + +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +_LOGGER = logging.getLogger(__name__) + +ATTR_CURRENT_POSITION = 'current_position' +ATTR_CURRENT_TILT_POSITION = 'current_tilt_position' +ATTR_POSITION = 'position' +ATTR_TILT_POSITION = 'tilt_position' + +COVER_SERVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, +}) + +COVER_SET_COVER_POSITION_SCHEMA = COVER_SERVICE_SCHEMA.extend({ + vol.Required(ATTR_POSITION): + vol.All(vol.Coerce(int), vol.Range(min=0, max=100)), +}) + +COVER_SET_COVER_TILT_POSITION_SCHEMA = COVER_SERVICE_SCHEMA.extend({ + vol.Required(ATTR_TILT_POSITION): + vol.All(vol.Coerce(int), vol.Range(min=0, max=100)), +}) + +SERVICE_TO_METHOD = { + SERVICE_OPEN_COVER: {'method': 'open_cover'}, + SERVICE_CLOSE_COVER: {'method': 'close_cover'}, + SERVICE_SET_COVER_POSITION: { + 'method': 'set_cover_position', + 'schema': COVER_SET_COVER_POSITION_SCHEMA}, + SERVICE_STOP_COVER: {'method': 'stop_cover'}, + SERVICE_OPEN_COVER_TILT: {'method': 'open_cover_tilt'}, + SERVICE_CLOSE_COVER_TILT: {'method': 'close_cover_tilt'}, + SERVICE_SET_COVER_TILT_POSITION: { + 'method': 'set_cover_tilt_position', + 'schema': COVER_SET_COVER_TILT_POSITION_SCHEMA}, +} + + +def is_closed(hass, entity_id=None): + """Return if the cover is closed based on the statemachine.""" + entity_id = entity_id or ENTITY_ID_ALL_COVERS + return hass.states.is_state(entity_id, STATE_CLOSED) + + +def open_cover(hass, entity_id=None): + """Open all or specified cover.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else None + hass.services.call(DOMAIN, SERVICE_OPEN_COVER, data) + + +def close_cover(hass, entity_id=None): + """Close all or specified cover.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else None + hass.services.call(DOMAIN, SERVICE_CLOSE_COVER, data) + + +def set_cover_position(hass, position, entity_id=None): + """Move to specific position all or specified cover.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + data[ATTR_POSITION] = position + hass.services.call(DOMAIN, SERVICE_SET_COVER_POSITION, data) + + +def stop_cover(hass, entity_id=None): + """Stop all or specified cover.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else None + hass.services.call(DOMAIN, SERVICE_STOP_COVER, data) + + +def open_cover_tilt(hass, entity_id=None): + """Open all or specified cover tilt.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else None + hass.services.call(DOMAIN, SERVICE_OPEN_COVER_TILT, data) + + +def close_cover_tilt(hass, entity_id=None): + """Close all or specified cover tilt.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else None + hass.services.call(DOMAIN, SERVICE_CLOSE_COVER_TILT, data) + + +def set_cover_tilt_position(hass, tilt_position, entity_id=None): + """Move to specific tilt position all or specified cover.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + data[ATTR_TILT_POSITION] = tilt_position + hass.services.call(DOMAIN, SERVICE_SET_COVER_TILT_POSITION, data) + + +def stop_cover_tilt(hass, entity_id=None): + """Stop all or specified cover tilt.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else None + hass.services.call(DOMAIN, SERVICE_STOP_COVER_TILT, data) + + +def setup(hass, config): + """Track states and offer events for covers.""" + component = EntityComponent( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_COVERS) + component.setup(config) + + def handle_cover_service(service): + """Handle calls to the cover services.""" + method = SERVICE_TO_METHOD.get(service.service) + params = service.data.copy() + params.pop(ATTR_ENTITY_ID, None) + + if method: + for cover in component.extract_from_service(service): + getattr(cover, method['method'])(**params) + + if cover.should_poll: + cover.update_ha_state(True) + + descriptions = load_yaml_config_file( + os.path.join(os.path.dirname(__file__), 'services.yaml')) + + for service_name in SERVICE_TO_METHOD: + schema = SERVICE_TO_METHOD[service_name].get( + 'schema', COVER_SERVICE_SCHEMA) + hass.services.register(DOMAIN, service_name, handle_cover_service, + descriptions.get(service_name), schema=schema) + return True + + +class CoverDevice(Entity): + """Representation a cover.""" + + # pylint: disable=no-self-use + @property + def current_cover_position(self): + """Return current position of cover. + + None is unknown, 0 is closed, 100 is fully open. + """ + pass + + @property + def current_cover_tilt_position(self): + """Return current position of cover tilt. + + None is unknown, 0 is closed, 100 is fully open. + """ + pass + + @property + def state(self): + """Return the state of the cover.""" + closed = self.is_closed + + if closed is None: + return STATE_UNKNOWN + + return STATE_CLOSED if closed else STATE_OPEN + + @property + def state_attributes(self): + """Return the state attributes.""" + data = {} + + current = self.current_cover_position + if current is not None: + data[ATTR_CURRENT_POSITION] = self.current_cover_position + + current_tilt = self.current_cover_tilt_position + if current_tilt is not None: + data[ATTR_CURRENT_TILT_POSITION] = self.current_cover_tilt_position + + return data + + @property + def is_closed(self): + """Return if the cover is closed or not.""" + raise NotImplementedError() + + def open_cover(self, **kwargs): + """Open the cover.""" + raise NotImplementedError() + + def close_cover(self, **kwargs): + """Close cover.""" + raise NotImplementedError() + + def set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + pass + + def stop_cover(self, **kwargs): + """Stop the cover.""" + pass + + def open_cover_tilt(self, **kwargs): + """Open the cover tilt.""" + pass + + def close_cover_tilt(self, **kwargs): + """Close the cover tilt.""" + pass + + def set_cover_tilt_position(self, **kwargs): + """Move the cover tilt to a specific position.""" + pass + + def stop_cover_tilt(self, **kwargs): + """Stop the cover.""" + pass diff --git a/homeassistant/components/cover/command_line.py b/homeassistant/components/cover/command_line.py new file mode 100644 index 00000000000..c2c8050f09f --- /dev/null +++ b/homeassistant/components/cover/command_line.py @@ -0,0 +1,128 @@ +""" +Support for command line covers. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.command_line/ +""" +import logging +import subprocess + +from homeassistant.components.cover import CoverDevice +from homeassistant.const import CONF_VALUE_TEMPLATE +from homeassistant.helpers import template + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """Setup cover controlled by shell commands.""" + covers = config.get('covers', {}) + devices = [] + + for dev_name, properties in covers.items(): + devices.append( + CommandCover( + hass, + properties.get('name', dev_name), + properties.get('opencmd', 'true'), + properties.get('closecmd', 'true'), + properties.get('stopcmd', 'true'), + properties.get('statecmd', False), + properties.get(CONF_VALUE_TEMPLATE, '{{ value }}'))) + add_devices_callback(devices) + + +# pylint: disable=too-many-arguments, too-many-instance-attributes +class CommandCover(CoverDevice): + """Representation a command line cover.""" + + # pylint: disable=too-many-arguments + def __init__(self, hass, name, command_open, command_close, command_stop, + command_state, value_template): + """Initialize the cover.""" + self._hass = hass + self._name = name + self._state = None + self._command_open = command_open + self._command_close = command_close + self._command_stop = command_stop + self._command_state = command_state + self._value_template = value_template + + @staticmethod + def _move_cover(command): + """Execute the actual commands.""" + _LOGGER.info('Running command: %s', command) + + success = (subprocess.call(command, shell=True) == 0) + + if not success: + _LOGGER.error('Command failed: %s', command) + + return success + + @staticmethod + def _query_state_value(command): + """Execute state command for return value.""" + _LOGGER.info('Running state command: %s', command) + + try: + return_value = subprocess.check_output(command, shell=True) + return return_value.strip().decode('utf-8') + except subprocess.CalledProcessError: + _LOGGER.error('Command failed: %s', command) + + @property + def should_poll(self): + """Only poll if we have state command.""" + return self._command_state is not None + + @property + def name(self): + """Return the name of the cover.""" + return self._name + + @property + def is_closed(self): + """Return if the cover is closed.""" + if self.current_cover_position is not None: + if self.current_cover_position > 0: + return False + else: + return True + + @property + def current_cover_position(self): + """Return current position of cover. + + None is unknown, 0 is closed, 100 is fully open. + """ + return self._state + + def _query_state(self): + """Query for the state.""" + if not self._command_state: + _LOGGER.error('No state command specified') + return + return self._query_state_value(self._command_state) + + def update(self): + """Update device state.""" + if self._command_state: + payload = str(self._query_state()) + if self._value_template: + payload = template.render_with_possible_json_value( + self._hass, self._value_template, payload) + self._state = int(payload) + + def open_cover(self, **kwargs): + """Open the cover.""" + self._move_cover(self._command_open) + + def close_cover(self, **kwargs): + """Close the cover.""" + self._move_cover(self._command_close) + + def stop_cover(self, **kwargs): + """Stop the cover.""" + self._move_cover(self._command_stop) diff --git a/homeassistant/components/cover/demo.py b/homeassistant/components/cover/demo.py new file mode 100644 index 00000000000..1f1c666f339 --- /dev/null +++ b/homeassistant/components/cover/demo.py @@ -0,0 +1,173 @@ +""" +Demo platform for the cover component. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/demo/ +""" +from homeassistant.components.cover import CoverDevice +from homeassistant.const import EVENT_TIME_CHANGED +from homeassistant.helpers.event import track_utc_time_change + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Demo covers.""" + add_devices([ + DemoCover(hass, 'Kitchen Window'), + DemoCover(hass, 'Hall Window', 10), + DemoCover(hass, 'Living Room Window', 70, 50), + ]) + + +class DemoCover(CoverDevice): + """Representation of a demo cover.""" + + # pylint: disable=no-self-use, too-many-instance-attributes + def __init__(self, hass, name, position=None, tilt_position=None): + """Initialize the cover.""" + self.hass = hass + self._name = name + self._position = position + self._set_position = None + self._set_tilt_position = None + self._tilt_position = tilt_position + self._closing = True + self._closing_tilt = True + self._listener_cover = None + self._listener_cover_tilt = None + + @property + def name(self): + """Return the name of the cover.""" + return self._name + + @property + def should_poll(self): + """No polling needed for a demo cover.""" + return False + + @property + def current_cover_position(self): + """Return the current position of the cover.""" + return self._position + + @property + def current_cover_tilt_position(self): + """Return the current tilt position of the cover.""" + return self._tilt_position + + @property + def is_closed(self): + """Return if the cover is closed.""" + if self._position is not None: + if self.current_cover_position > 0: + return False + else: + return True + else: + return None + + def close_cover(self, **kwargs): + """Close the cover.""" + if self._position in (0, None): + return + + self._listen_cover() + self._closing = True + + def close_cover_tilt(self, **kwargs): + """Close the cover tilt.""" + if self._tilt_position in (0, None): + return + + self._listen_cover_tilt() + self._closing_tilt = True + + def open_cover(self, **kwargs): + """Open the cover.""" + if self._position in (100, None): + return + + self._listen_cover() + self._closing = False + + def open_cover_tilt(self, **kwargs): + """Open the cover tilt.""" + if self._tilt_position in (100, None): + return + + self._listen_cover_tilt() + self._closing_tilt = False + + def set_cover_position(self, position, **kwargs): + """Move the cover to a specific position.""" + self._set_position = round(position, -1) + if self._position == position: + return + + self._listen_cover() + self._closing = position < self._position + + def set_cover_tilt_position(self, tilt_position, **kwargs): + """Move the cover til to a specific position.""" + self._set_tilt_position = round(tilt_position, -1) + if self._tilt_position == tilt_position: + return + + self._listen_cover_tilt() + self._closing_tilt = tilt_position < self._tilt_position + + def stop_cover(self, **kwargs): + """Stop the cover.""" + if self._position is None: + return + if self._listener_cover is not None: + self.hass.bus.remove_listener(EVENT_TIME_CHANGED, + self._listener_cover) + self._listener_cover = None + self._set_position = None + + def stop_cover_tilt(self, **kwargs): + """Stop the cover tilt.""" + if self._tilt_position is None: + return + + if self._listener_cover_tilt is not None: + self.hass.bus.remove_listener(EVENT_TIME_CHANGED, + self._listener_cover_tilt) + self._listener_cover_tilt = None + self._set_tilt_position = None + + def _listen_cover(self): + """Listen for changes in cover.""" + if self._listener_cover is None: + self._listener_cover = track_utc_time_change( + self.hass, self._time_changed_cover) + + def _time_changed_cover(self, now): + """Track time changes.""" + if self._closing: + self._position -= 10 + else: + self._position += 10 + + if self._position in (100, 0, self._set_position): + self.stop_cover() + self.update_ha_state() + + def _listen_cover_tilt(self): + """Listen for changes in cover tilt.""" + if self._listener_cover_tilt is None: + self._listener_cover_tilt = track_utc_time_change( + self.hass, self._time_changed_cover_tilt) + + def _time_changed_cover_tilt(self, now): + """Track time changes.""" + if self._closing_tilt: + self._tilt_position -= 10 + else: + self._tilt_position += 10 + + if self._tilt_position in (100, 0, self._set_tilt_position): + self.stop_cover_tilt() + + self.update_ha_state() diff --git a/homeassistant/components/cover/homematic.py b/homeassistant/components/cover/homematic.py new file mode 100644 index 00000000000..cab6b51e645 --- /dev/null +++ b/homeassistant/components/cover/homematic.py @@ -0,0 +1,101 @@ +""" +The homematic cover platform. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.homematic/ + +Important: For this platform to work the homematic component has to be +properly configured. +""" + +import logging +from homeassistant.const import STATE_UNKNOWN +from homeassistant.components.cover import CoverDevice,\ + ATTR_CURRENT_POSITION +import homeassistant.components.homematic as homematic + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['homematic'] + + +def setup_platform(hass, config, add_callback_devices, discovery_info=None): + """Setup the platform.""" + if discovery_info is None: + return + + return homematic.setup_hmdevice_discovery_helper(HMCover, + discovery_info, + add_callback_devices) + + +# pylint: disable=abstract-method +class HMCover(homematic.HMDevice, CoverDevice): + """Represents a Homematic Cover in Home Assistant.""" + + @property + def current_cover_position(self): + """ + Return current position of cover. + + None is unknown, 0 is closed, 100 is fully open. + """ + if self.available: + return int((1 - self._hm_get_state()) * 100) + return None + + def set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + if self.available: + if ATTR_CURRENT_POSITION in kwargs: + position = float(kwargs[ATTR_CURRENT_POSITION]) + position = min(100, max(0, position)) + level = (100 - position) / 100.0 + self._hmdevice.set_level(level, self._channel) + + @property + def is_closed(self): + """Return if the cover is closed.""" + if self.current_cover_position is not None: + if self.current_cover_position > 0: + return False + else: + return True + + def open_cover(self, **kwargs): + """Open the cover.""" + if self.available: + self._hmdevice.move_up(self._channel) + + def close_cover(self, **kwargs): + """Close the cover.""" + if self.available: + self._hmdevice.move_down(self._channel) + + def stop_cover(self, **kwargs): + """Stop the device if in motion.""" + if self.available: + self._hmdevice.stop(self._channel) + + def _check_hm_to_ha_object(self): + """Check if possible to use the HM Object as this HA type.""" + from pyhomematic.devicetypes.actors import Blind + + # Check compatibility from HMDevice + if not super()._check_hm_to_ha_object(): + return False + + # Check if the homematic device is correct for this HA device + if isinstance(self._hmdevice, Blind): + return True + + _LOGGER.critical("This %s can't be use as cover!", self._name) + return False + + def _init_data_struct(self): + """Generate a data dict (self._data) from hm metadata.""" + super()._init_data_struct() + + # Add state to data dict + self._state = "LEVEL" + self._data.update({self._state: STATE_UNKNOWN}) diff --git a/homeassistant/components/cover/mqtt.py b/homeassistant/components/cover/mqtt.py new file mode 100644 index 00000000000..dd6b10e244d --- /dev/null +++ b/homeassistant/components/cover/mqtt.py @@ -0,0 +1,167 @@ +""" +Support for MQTT cover devices. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.mqtt/ +""" +import logging + +import voluptuous as vol + +import homeassistant.components.mqtt as mqtt +from homeassistant.components.cover import CoverDevice +from homeassistant.const import ( + CONF_NAME, CONF_VALUE_TEMPLATE, CONF_OPTIMISTIC, STATE_OPEN, + STATE_CLOSED) +from homeassistant.components.mqtt import ( + CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN) +from homeassistant.helpers import template +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['mqtt'] + +CONF_PAYLOAD_OPEN = 'payload_open' +CONF_PAYLOAD_CLOSE = 'payload_close' +CONF_PAYLOAD_STOP = 'payload_stop' +CONF_STATE_OPEN = 'state_open' +CONF_STATE_CLOSED = 'state_closed' + +DEFAULT_NAME = "MQTT Cover" +DEFAULT_PAYLOAD_OPEN = "OPEN" +DEFAULT_PAYLOAD_CLOSE = "CLOSE" +DEFAULT_PAYLOAD_STOP = "STOP" +DEFAULT_OPTIMISTIC = False +DEFAULT_RETAIN = False + +PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PAYLOAD_OPEN, default=DEFAULT_PAYLOAD_OPEN): cv.string, + vol.Optional(CONF_PAYLOAD_CLOSE, default=DEFAULT_PAYLOAD_CLOSE): cv.string, + vol.Optional(CONF_PAYLOAD_STOP, default=DEFAULT_PAYLOAD_STOP): cv.string, + vol.Optional(CONF_STATE_OPEN, default=STATE_OPEN): cv.string, + vol.Optional(CONF_STATE_CLOSED, default=STATE_CLOSED): cv.string, + vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, + vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, + +}) + + +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """Add MQTT Cover.""" + add_devices_callback([MqttCover( + hass, + config[CONF_NAME], + config.get(CONF_STATE_TOPIC), + config[CONF_COMMAND_TOPIC], + config[CONF_QOS], + config[CONF_RETAIN], + config[CONF_STATE_OPEN], + config[CONF_STATE_CLOSED], + config[CONF_PAYLOAD_OPEN], + config[CONF_PAYLOAD_CLOSE], + config[CONF_PAYLOAD_STOP], + config[CONF_OPTIMISTIC], + config.get(CONF_VALUE_TEMPLATE) + )]) + + +# pylint: disable=too-many-arguments, too-many-instance-attributes +class MqttCover(CoverDevice): + """Representation of a cover that can be controlled using MQTT.""" + + def __init__(self, hass, name, state_topic, command_topic, qos, + retain, state_open, state_closed, payload_open, payload_close, + payload_stop, optimistic, value_template): + """Initialize the cover.""" + self._position = None + self._state = None + self._hass = hass + self._name = name + self._state_topic = state_topic + self._command_topic = command_topic + self._qos = qos + self._payload_open = payload_open + self._payload_close = payload_close + self._payload_stop = payload_stop + self._state_open = state_open + self._state_closed = state_closed + self._retain = retain + self._optimistic = optimistic or state_topic is None + + def message_received(topic, payload, qos): + """A new MQTT message has been received.""" + if value_template is not None: + payload = template.render_with_possible_json_value( + hass, value_template, payload) + if payload == self._state_open: + self._state = False + self.update_ha_state() + elif payload == self._state_closed: + self._state = True + self.update_ha_state() + elif payload.isnumeric() and 0 <= int(payload) <= 100: + self._state = int(payload) + self._position = int(payload) + self.update_ha_state() + else: + _LOGGER.warning( + "Payload is not True or False or" + " integer(0-100) %s", payload) + if self._state_topic is None: + # Force into optimistic mode. + self._optimistic = True + else: + mqtt.subscribe(hass, self._state_topic, message_received, + self._qos) + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def name(self): + """Return the name of the cover.""" + return self._name + + @property + def is_closed(self): + """Return if the cover is closed.""" + if self.current_cover_position is not None: + if self.current_cover_position > 0: + return False + else: + return True + + @property + def current_cover_position(self): + """Return current position of cover. + + None is unknown, 0 is closed, 100 is fully open. + """ + return self._position + + def open_cover(self, **kwargs): + """Move the cover up.""" + mqtt.publish(self.hass, self._command_topic, self._payload_open, + self._qos, self._retain) + if self._optimistic: + # Optimistically assume that cover has changed state. + self._state = 100 + self.update_ha_state() + + def close_cover(self, **kwargs): + """Move the cover down.""" + mqtt.publish(self.hass, self._command_topic, self._payload_close, + self._qos, self._retain) + if self._optimistic: + # Optimistically assume that cover has changed state. + self._state = 0 + self.update_ha_state() + + def stop_cover(self, **kwargs): + """Stop the device.""" + mqtt.publish(self.hass, self._command_topic, self._payload_stop, + self._qos, self._retain) diff --git a/homeassistant/components/cover/rfxtrx.py b/homeassistant/components/cover/rfxtrx.py new file mode 100644 index 00000000000..d7ca03f5762 --- /dev/null +++ b/homeassistant/components/cover/rfxtrx.py @@ -0,0 +1,67 @@ +""" +Support for RFXtrx cover components. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/cover.rfxtrx/ +""" + +import homeassistant.components.rfxtrx as rfxtrx +from homeassistant.components.cover import CoverDevice + +DEPENDENCIES = ['rfxtrx'] + +PLATFORM_SCHEMA = rfxtrx.DEFAULT_SCHEMA + + +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """Setup the RFXtrx cover.""" + import RFXtrx as rfxtrxmod + + # Add cover from config file + covers = rfxtrx.get_devices_from_config(config, + RfxtrxCover) + add_devices_callback(covers) + + def cover_update(event): + """Callback for cover updates from the RFXtrx gateway.""" + if not isinstance(event.device, rfxtrxmod.LightingDevice) or \ + event.device.known_to_be_dimmable or \ + not event.device.known_to_be_rollershutter: + return + + new_device = rfxtrx.get_new_device(event, config, RfxtrxCover) + if new_device: + add_devices_callback([new_device]) + + rfxtrx.apply_received_command(event) + + # Subscribe to main rfxtrx events + if cover_update not in rfxtrx.RECEIVED_EVT_SUBSCRIBERS: + rfxtrx.RECEIVED_EVT_SUBSCRIBERS.append(cover_update) + + +# pylint: disable=abstract-method +class RfxtrxCover(rfxtrx.RfxtrxDevice, CoverDevice): + """Representation of an rfxtrx cover.""" + + @property + def should_poll(self): + """No polling available in rfxtrx cover.""" + return False + + @property + def is_closed(self): + """Return if the cover is closed.""" + return None + + def open_cover(self, **kwargs): + """Move the cover up.""" + self._send_command("roll_up") + + def close_cover(self, **kwargs): + """Move the cover down.""" + self._send_command("roll_down") + + def stop_cover(self, **kwargs): + """Stop the cover.""" + self._send_command("stop_roll") diff --git a/homeassistant/components/cover/rpi_gpio.py b/homeassistant/components/cover/rpi_gpio.py new file mode 100644 index 00000000000..6cef5dc08e7 --- /dev/null +++ b/homeassistant/components/cover/rpi_gpio.py @@ -0,0 +1,112 @@ +""" +Support for building a Raspberry Pi cover in HA. + +Instructions for building the controller can be found here +https://github.com/andrewshilliday/garage-door-controller + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.rpi_gpio/ +""" + +import logging +from time import sleep +import voluptuous as vol + +from homeassistant.components.cover import CoverDevice +import homeassistant.components.rpi_gpio as rpi_gpio +import homeassistant.helpers.config_validation as cv + +RELAY_TIME = 'relay_time' +STATE_PULL_MODE = 'state_pull_mode' +DEFAULT_PULL_MODE = 'UP' +DEFAULT_RELAY_TIME = .2 +DEPENDENCIES = ['rpi_gpio'] + +_LOGGER = logging.getLogger(__name__) + +_COVERS_SCHEMA = vol.All( + cv.ensure_list, + [ + vol.Schema({ + 'name': str, + 'relay_pin': int, + 'state_pin': int, + }) + ] +) +PLATFORM_SCHEMA = vol.Schema({ + 'platform': str, + vol.Required('covers'): _COVERS_SCHEMA, + vol.Optional(STATE_PULL_MODE, default=DEFAULT_PULL_MODE): cv.string, + vol.Optional(RELAY_TIME, default=DEFAULT_RELAY_TIME): vol.Coerce(int), +}) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the cover platform.""" + relay_time = config.get(RELAY_TIME) + state_pull_mode = config.get(STATE_PULL_MODE) + covers = [] + covers_conf = config.get('covers') + + for cover in covers_conf: + covers.append(RPiGPIOCover(cover['name'], cover['relay_pin'], + cover['state_pin'], + state_pull_mode, + relay_time)) + add_devices(covers) + + +# pylint: disable=abstract-method +class RPiGPIOCover(CoverDevice): + """Representation of a Raspberry cover.""" + + # pylint: disable=too-many-arguments + def __init__(self, name, relay_pin, state_pin, + state_pull_mode, relay_time): + """Initialize the cover.""" + self._name = name + self._state = False + self._relay_pin = relay_pin + self._state_pin = state_pin + self._state_pull_mode = state_pull_mode + self._relay_time = relay_time + rpi_gpio.setup_output(self._relay_pin) + rpi_gpio.setup_input(self._state_pin, self._state_pull_mode) + rpi_gpio.write_output(self._relay_pin, True) + + @property + def unique_id(self): + """Return the ID of this cover.""" + return "{}.{}".format(self.__class__, self._name) + + @property + def name(self): + """Return the name of the cover if any.""" + return self._name + + def update(self): + """Update the state of the cover.""" + self._state = rpi_gpio.read_input(self._state_pin) + + @property + def is_closed(self): + """Return true if cover is closed.""" + return self._state + + def _trigger(self): + """Trigger the cover.""" + rpi_gpio.write_output(self._relay_pin, False) + sleep(self._relay_time) + rpi_gpio.write_output(self._relay_pin, True) + + def close_cover(self): + """Close the cover.""" + if not self.is_closed: + self._trigger() + + def open_cover(self): + """Open the cover.""" + if self.is_closed: + self._trigger() diff --git a/homeassistant/components/cover/scsgate.py b/homeassistant/components/cover/scsgate.py new file mode 100644 index 00000000000..18692534e90 --- /dev/null +++ b/homeassistant/components/cover/scsgate.py @@ -0,0 +1,96 @@ +""" +Allow to configure a SCSGate cover. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.scsgate/ +""" +import logging + +import homeassistant.components.scsgate as scsgate +from homeassistant.components.cover import CoverDevice +from homeassistant.const import CONF_NAME + +DEPENDENCIES = ['scsgate'] +SCS_ID = 'scs_id' + + +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """Setup the SCSGate cover.""" + devices = config.get('devices') + covers = [] + logger = logging.getLogger(__name__) + + if devices: + for _, entity_info in devices.items(): + if entity_info[SCS_ID] in scsgate.SCSGATE.devices: + continue + + logger.info("Adding %s scsgate.cover", entity_info[CONF_NAME]) + + name = entity_info[CONF_NAME] + scs_id = entity_info[SCS_ID] + cover = SCSGateCover( + name=name, + scs_id=scs_id, + logger=logger) + scsgate.SCSGATE.add_device(cover) + covers.append(cover) + + add_devices_callback(covers) + + +# pylint: disable=too-many-arguments, too-many-instance-attributes +class SCSGateCover(CoverDevice): + """Representation of SCSGate cover.""" + + def __init__(self, scs_id, name, logger): + """Initialize the cover.""" + self._scs_id = scs_id + self._name = name + self._logger = logger + + @property + def scs_id(self): + """Return the SCSGate ID.""" + return self._scs_id + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def name(self): + """Return the name of the cover.""" + return self._name + + @property + def is_closed(self): + """Return if the cover is closed.""" + return None + + def open_cover(self, **kwargs): + """Move the cover.""" + from scsgate.tasks import RaiseRollerShutterTask + + scsgate.SCSGATE.append_task( + RaiseRollerShutterTask(target=self._scs_id)) + + def close_cover(self, **kwargs): + """Move the cover down.""" + from scsgate.tasks import LowerRollerShutterTask + + scsgate.SCSGATE.append_task( + LowerRollerShutterTask(target=self._scs_id)) + + def stop_cover(self, **kwargs): + """Stop the cover.""" + from scsgate.tasks import HaltRollerShutterTask + + scsgate.SCSGATE.append_task(HaltRollerShutterTask(target=self._scs_id)) + + def process_event(self, message): + """Handle a SCSGate message related with this cover.""" + self._logger.debug( + "Rollershutter %s, got message %s", + self._scs_id, message.toggled) diff --git a/homeassistant/components/cover/services.yaml b/homeassistant/components/cover/services.yaml new file mode 100644 index 00000000000..02765ca9ab8 --- /dev/null +++ b/homeassistant/components/cover/services.yaml @@ -0,0 +1,71 @@ +open_cover: + description: Open all or specified cover + + fields: + entity_id: + description: Name(s) of cover(s) to open + example: 'cover.living_room' + +close_cover: + description: Close all or specified cover + + fields: + entity_id: + description: Name(s) of cover(s) to close + example: 'cover.living_room' + +set_cover_position: + description: Move to specific position all or specified cover + + fields: + entity_id: + description: Name(s) of cover(s) to set cover position + example: 'cover.living_room' + + position: + description: Position of the cover (0 to 100) + example: 30 + +stop_cover: + description: Stop all or specified cover + + fields: + entity_id: + description: Name(s) of cover(s) to stop + example: 'cover.living_room' + +open_cover_tilt: + description: Open all or specified cover tilt + + fields: + entity_id: + description: Name(s) of cover(s) tilt to open + example: 'cover.living_room' + +close_cover_tilt: + description: Close all or specified cover tilt + + fields: + entity_id: + description: Name(s) of cover(s) to close tilt + example: 'cover.living_room' + +set_cover_tilt_position: + description: Move to specific position all or specified cover tilt + + fields: + entity_id: + description: Name(s) of cover(s) to set cover tilt position + example: 'cover.living_room' + + position: + description: Position of the cover (0 to 100) + example: 30 + +stop_cover_tilt: + description: Stop all or specified cover + + fields: + entity_id: + description: Name(s) of cover(s) to stop + example: 'cover.living_room' diff --git a/homeassistant/components/cover/wink.py b/homeassistant/components/cover/wink.py new file mode 100644 index 00000000000..9b76e234303 --- /dev/null +++ b/homeassistant/components/cover/wink.py @@ -0,0 +1,64 @@ +""" +Support for Wink Covers. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.wink/ +""" +import logging + +from homeassistant.components.cover import CoverDevice +from homeassistant.components.wink import WinkDevice +from homeassistant.const import CONF_ACCESS_TOKEN + +REQUIREMENTS = ['python-wink==0.7.13', 'pubnub==3.8.2'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Wink cover platform.""" + import pywink + + if discovery_info is None: + token = config.get(CONF_ACCESS_TOKEN) + + if token is None: + logging.getLogger(__name__).error( + "Missing wink access_token. " + "Get one at https://winkbearertoken.appspot.com/") + return + + pywink.set_bearer_token(token) + + add_devices(WinkCoverDevice(shade) for shade, door in + pywink.get_shades()) + + +class WinkCoverDevice(WinkDevice, CoverDevice): + """Representation of a Wink covers.""" + + def __init__(self, wink): + """Initialize the cover.""" + WinkDevice.__init__(self, wink) + + @property + def should_poll(self): + """Wink Shades don't track their position.""" + return False + + def close_cover(self): + """Close the shade.""" + self.wink.set_state(0) + + def open_cover(self): + """Open the shade.""" + self.wink.set_state(1) + + @property + def is_closed(self): + """Return if the cover is closed.""" + state = self.wink.state() + if state == 0: + return True + elif state == 1: + return False + else: + return None diff --git a/homeassistant/components/cover/zwave.py b/homeassistant/components/cover/zwave.py new file mode 100644 index 00000000000..83d55001fe2 --- /dev/null +++ b/homeassistant/components/cover/zwave.py @@ -0,0 +1,184 @@ +""" +Support for Zwave cover components. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/cover.zwave/ +""" +# Because we do not compile openzwave on CI +# pylint: disable=import-error +import logging +from homeassistant.components.cover import DOMAIN +from homeassistant.components.zwave import ZWaveDeviceEntity +from homeassistant.components import zwave +from homeassistant.components.cover import CoverDevice + +COMMAND_CLASS_SWITCH_MULTILEVEL = 0x26 # 38 +COMMAND_CLASS_SWITCH_BINARY = 0x25 # 37 + +SOMFY = 0x47 +SOMFY_ZRTSI = 0x5a52 +SOMFY_ZRTSI_CONTROLLER = (SOMFY, SOMFY_ZRTSI) +WORKAROUND = 'workaround' + +DEVICE_MAPPINGS = { + SOMFY_ZRTSI_CONTROLLER: WORKAROUND +} + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Find and return Z-Wave covers.""" + if discovery_info is None or zwave.NETWORK is None: + return + + node = zwave.NETWORK.nodes[discovery_info[zwave.ATTR_NODE_ID]] + value = node.values[discovery_info[zwave.ATTR_VALUE_ID]] + + if (value.command_class == zwave.COMMAND_CLASS_SWITCH_MULTILEVEL and + value.index == 0): + value.set_change_verified(False) + add_devices([ZwaveRollershutter(value)]) + elif (value.command_class == zwave.COMMAND_CLASS_SWITCH_BINARY or + value.command_class == zwave.COMMAND_CLASS_BARRIER_OPERATOR): + if value.type != zwave.TYPE_BOOL and \ + value.genre != zwave.GENRE_USER: + return + value.set_change_verified(False) + add_devices([ZwaveGarageDoor(value)]) + else: + return + + +class ZwaveRollershutter(zwave.ZWaveDeviceEntity, CoverDevice): + """Representation of an Zwave roller shutter.""" + + def __init__(self, value): + """Initialize the zwave rollershutter.""" + import libopenzwave + from openzwave.network import ZWaveNetwork + from pydispatch import dispatcher + ZWaveDeviceEntity.__init__(self, value, DOMAIN) + self._lozwmgr = libopenzwave.PyManager() + self._lozwmgr.create() + self._node = value.node + self._current_position = None + self._workaround = None + dispatcher.connect( + self.value_changed, ZWaveNetwork.SIGNAL_VALUE_CHANGED) + if (value.node.manufacturer_id.strip() and + value.node.product_id.strip()): + specific_sensor_key = (int(value.node.manufacturer_id, 16), + int(value.node.product_type, 16)) + + if specific_sensor_key in DEVICE_MAPPINGS: + if DEVICE_MAPPINGS[specific_sensor_key] == WORKAROUND: + _LOGGER.debug("Controller without positioning feedback") + self._workaround = 1 + + def value_changed(self, value): + """Called when a value has changed on the network.""" + if self._value.value_id == value.value_id or \ + self._value.node == value.node: + self.update_properties() + self.update_ha_state() + _LOGGER.debug("Value changed on network %s", value) + + def update_properties(self): + """Callback on data change for the registered node/value pair.""" + # Position value + for value in self._node.get_values( + class_id=COMMAND_CLASS_SWITCH_MULTILEVEL).values(): + if value.command_class == zwave.COMMAND_CLASS_SWITCH_MULTILEVEL \ + and value.label == 'Level': + self._current_position = value.data + + @property + def is_closed(self): + """Return if the cover is closed.""" + if self.current_cover_position > 0: + return False + else: + return True + + @property + def current_cover_position(self): + """Return the current position of Zwave roller shutter.""" + if not self._workaround: + if self._current_position is not None: + if self._current_position <= 5: + return 0 + elif self._current_position >= 95: + return 100 + else: + return self._current_position + + def open_cover(self, **kwargs): + """Move the roller shutter up.""" + for value in self._node.get_values( + class_id=COMMAND_CLASS_SWITCH_MULTILEVEL).values(): + if value.command_class == zwave.COMMAND_CLASS_SWITCH_MULTILEVEL \ + and value.label == 'Open' or \ + value.command_class == zwave.COMMAND_CLASS_SWITCH_MULTILEVEL \ + and value.label == 'Down': + self._lozwmgr.pressButton(value.value_id) + break + + def close_cover(self, **kwargs): + """Move the roller shutter down.""" + for value in self._node.get_values( + class_id=COMMAND_CLASS_SWITCH_MULTILEVEL).values(): + if value.command_class == zwave.COMMAND_CLASS_SWITCH_MULTILEVEL \ + and value.label == 'Up' or \ + value.command_class == zwave.COMMAND_CLASS_SWITCH_MULTILEVEL \ + and value.label == 'Close': + self._lozwmgr.pressButton(value.value_id) + break + + def set_cover_position(self, position, **kwargs): + """Move the roller shutter to a specific position.""" + self._node.set_dimmer(self._value.value_id, 100 - position) + + def stop_cover(self, **kwargs): + """Stop the roller shutter.""" + for value in self._node.get_values( + class_id=COMMAND_CLASS_SWITCH_MULTILEVEL).values(): + if value.command_class == zwave.COMMAND_CLASS_SWITCH_MULTILEVEL \ + and value.label == 'Open' or \ + value.command_class == zwave.COMMAND_CLASS_SWITCH_MULTILEVEL \ + and value.label == 'Down': + self._lozwmgr.releaseButton(value.value_id) + break + + +class ZwaveGarageDoor(zwave.ZWaveDeviceEntity, CoverDevice): + """Representation of an Zwave garage door device.""" + + def __init__(self, value): + """Initialize the zwave garage door.""" + from openzwave.network import ZWaveNetwork + from pydispatch import dispatcher + ZWaveDeviceEntity.__init__(self, value, DOMAIN) + self._state = value.data + dispatcher.connect( + self.value_changed, ZWaveNetwork.SIGNAL_VALUE_CHANGED) + + def value_changed(self, value): + """Called when a value has changed on the network.""" + if self._value.value_id == value.value_id: + self._state = value.data + self.update_ha_state() + _LOGGER.debug("Value changed on network %s", value) + + @property + def is_closed(self): + """Return the current position of Zwave garage door.""" + return not self._state + + def close_cover(self): + """Close the garage door.""" + self._value.data = False + + def open_cover(self): + """Open the garage door.""" + self._value.data = True diff --git a/homeassistant/components/demo.py b/homeassistant/components/demo.py index f083a96f5b2..5695bc5005a 100644 --- a/homeassistant/components/demo.py +++ b/homeassistant/components/demo.py @@ -11,17 +11,16 @@ import homeassistant.core as ha import homeassistant.loader as loader from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM -DOMAIN = "demo" - DEPENDENCIES = ['conversation', 'introduction', 'zone'] +DOMAIN = 'demo' COMPONENTS_WITH_DEMO_PLATFORM = [ 'alarm_control_panel', 'binary_sensor', 'camera', + 'climate', 'device_tracker', 'garage_door', - 'hvac', 'light', 'lock', 'media_player', @@ -29,7 +28,6 @@ COMPONENTS_WITH_DEMO_PLATFORM = [ 'rollershutter', 'sensor', 'switch', - 'thermostat', ] diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index d518587d298..8c52f2147b3 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -258,7 +258,7 @@ class Device(Entity): _state = STATE_NOT_HOME def __init__(self, hass, consider_home, home_range, track, dev_id, mac, - name=None, picture=None, away_hide=False): + name=None, picture=None, gravatar=None, away_hide=False): """Initialize a device.""" self.hass = hass self.entity_id = ENTITY_ID_FORMAT.format(dev_id) @@ -280,7 +280,11 @@ class Device(Entity): self.config_name = name # Configured picture - self.config_picture = picture + if gravatar is not None: + self.config_picture = get_gravatar_for_email(gravatar) + else: + self.config_picture = picture + self.away_hide = away_hide @property @@ -382,6 +386,7 @@ def load_config(path, hass, consider_home, home_range): Device(hass, consider_home, home_range, device.get('track', False), str(dev_id).lower(), str(device.get('mac')).upper(), device.get('name'), device.get('picture'), + device.get('gravatar'), device.get(CONF_AWAY_HIDE, DEFAULT_AWAY_HIDE)) for dev_id, device in load_yaml_config_file(path).items()] except HomeAssistantError: @@ -425,3 +430,10 @@ def update_config(path, dev_id, device): (CONF_AWAY_HIDE, 'yes' if device.away_hide else 'no')): out.write(' {}: {}\n'.format(key, '' if value is None else value)) + + +def get_gravatar_for_email(email): + """Return an 80px Gravatar for the given email address.""" + import hashlib + url = "https://www.gravatar.com/avatar/{}.jpg?s=80&d=wavatar" + return url.format(hashlib.md5(email.encode('utf-8').lower()).hexdigest()) diff --git a/homeassistant/components/device_tracker/actiontec.py b/homeassistant/components/device_tracker/actiontec.py index 5de139b9e1f..a4804848f4a 100644 --- a/homeassistant/components/device_tracker/actiontec.py +++ b/homeassistant/components/device_tracker/actiontec.py @@ -1,126 +1,129 @@ -""" -Support for Actiontec MI424WR (Verizon FIOS) routers. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.actiontec/ -""" -import logging -import re -import telnetlib -import threading -from collections import namedtuple -from datetime import timedelta - -import homeassistant.util.dt as dt_util -from homeassistant.components.device_tracker import DOMAIN -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.helpers import validate_config -from homeassistant.util import Throttle - -# Return cached results if last scan was less then this time ago. -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) - -_LOGGER = logging.getLogger(__name__) - -_LEASES_REGEX = re.compile( - r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})' + - r'\smac:\s(?P([0-9a-f]{2}[:-]){5}([0-9a-f]{2}))' + - r'\svalid\sfor:\s(?P(-?\d+))' + - r'\ssec') - - -# pylint: disable=unused-argument -def get_scanner(hass, config): - """Validate the configuration and return an Actiontec scanner.""" - if not validate_config(config, - {DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]}, - _LOGGER): - return None - scanner = ActiontecDeviceScanner(config[DOMAIN]) - return scanner if scanner.success_init else None - -Device = namedtuple("Device", ["mac", "ip", "last_update"]) - - -class ActiontecDeviceScanner(object): - """This class queries a an actiontec router for connected devices.""" - - def __init__(self, config): - """Initialize the scanner.""" - self.host = config[CONF_HOST] - self.username = config[CONF_USERNAME] - self.password = config[CONF_PASSWORD] - self.lock = threading.Lock() - self.last_results = [] - data = self.get_actiontec_data() - self.success_init = data is not None - _LOGGER.info("actiontec scanner initialized") - - def scan_devices(self): - """Scan for new devices and return a list with found device IDs.""" - self._update_info() - return [client.mac for client in self.last_results] - - def get_device_name(self, device): - """Return the name of the given device or None if we don't know.""" - if not self.last_results: - return None - for client in self.last_results: - if client.mac == device: - return client.ip - return None - - @Throttle(MIN_TIME_BETWEEN_SCANS) - def _update_info(self): - """Ensure the information from the router is up to date. - - Return boolean if scanning successful. - """ - _LOGGER.info("Scanning") - if not self.success_init: - return False - - with self.lock: - now = dt_util.now() - actiontec_data = self.get_actiontec_data() - if not actiontec_data: - return False - self.last_results = [Device(data['mac'], name, now) - for name, data in actiontec_data.items() - if data['timevalid'] > -60] - _LOGGER.info("actiontec scan successful") - return True - - def get_actiontec_data(self): - """Retrieve data from Actiontec MI424WR and return parsed result.""" - try: - telnet = telnetlib.Telnet(self.host) - telnet.read_until(b'Username: ') - telnet.write((self.username + '\n').encode('ascii')) - telnet.read_until(b'Password: ') - telnet.write((self.password + '\n').encode('ascii')) - prompt = telnet.read_until( - b'Wireless Broadband Router> ').split(b'\n')[-1] - telnet.write('firewall mac_cache_dump\n'.encode('ascii')) - telnet.write('\n'.encode('ascii')) - telnet.read_until(prompt) - leases_result = telnet.read_until(prompt).split(b'\n')[1:-1] - telnet.write('exit\n'.encode('ascii')) - except EOFError: - _LOGGER.exception("Unexpected response from router") - return - except ConnectionRefusedError: - _LOGGER.exception("Connection refused by router," + - " is telnet enabled?") - return None - - devices = {} - for lease in leases_result: - match = _LEASES_REGEX.search(lease.decode('utf-8')) - if match is not None: - devices[match.group('ip')] = { - 'ip': match.group('ip'), - 'mac': match.group('mac').upper(), - 'timevalid': int(match.group('timevalid')) - } - return devices +""" +Support for Actiontec MI424WR (Verizon FIOS) routers. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.actiontec/ +""" +import logging +import re +import telnetlib +import threading +from collections import namedtuple +from datetime import timedelta +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util +from homeassistant.components.device_tracker import (DOMAIN, PLATFORM_SCHEMA) +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.util import Throttle + +# Return cached results if last scan was less then this time ago. +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) + +_LOGGER = logging.getLogger(__name__) + +_LEASES_REGEX = re.compile( + r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})' + + r'\smac:\s(?P([0-9a-f]{2}[:-]){5}([0-9a-f]{2}))' + + r'\svalid\sfor:\s(?P(-?\d+))' + + r'\ssec') + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string +}) + + +# pylint: disable=unused-argument +def get_scanner(hass, config): + """Validate the configuration and return an Actiontec scanner.""" + scanner = ActiontecDeviceScanner(config[DOMAIN]) + return scanner if scanner.success_init else None + +Device = namedtuple("Device", ["mac", "ip", "last_update"]) + + +class ActiontecDeviceScanner(object): + """This class queries a an actiontec router for connected devices.""" + + def __init__(self, config): + """Initialize the scanner.""" + self.host = config[CONF_HOST] + self.username = config[CONF_USERNAME] + self.password = config[CONF_PASSWORD] + self.lock = threading.Lock() + self.last_results = [] + data = self.get_actiontec_data() + self.success_init = data is not None + _LOGGER.info("actiontec scanner initialized") + + def scan_devices(self): + """Scan for new devices and return a list with found device IDs.""" + self._update_info() + return [client.mac for client in self.last_results] + + def get_device_name(self, device): + """Return the name of the given device or None if we don't know.""" + if not self.last_results: + return None + for client in self.last_results: + if client.mac == device: + return client.ip + return None + + @Throttle(MIN_TIME_BETWEEN_SCANS) + def _update_info(self): + """Ensure the information from the router is up to date. + + Return boolean if scanning successful. + """ + _LOGGER.info("Scanning") + if not self.success_init: + return False + + with self.lock: + now = dt_util.now() + actiontec_data = self.get_actiontec_data() + if not actiontec_data: + return False + self.last_results = [Device(data['mac'], name, now) + for name, data in actiontec_data.items() + if data['timevalid'] > -60] + _LOGGER.info("actiontec scan successful") + return True + + def get_actiontec_data(self): + """Retrieve data from Actiontec MI424WR and return parsed result.""" + try: + telnet = telnetlib.Telnet(self.host) + telnet.read_until(b'Username: ') + telnet.write((self.username + '\n').encode('ascii')) + telnet.read_until(b'Password: ') + telnet.write((self.password + '\n').encode('ascii')) + prompt = telnet.read_until( + b'Wireless Broadband Router> ').split(b'\n')[-1] + telnet.write('firewall mac_cache_dump\n'.encode('ascii')) + telnet.write('\n'.encode('ascii')) + telnet.read_until(prompt) + leases_result = telnet.read_until(prompt).split(b'\n')[1:-1] + telnet.write('exit\n'.encode('ascii')) + except EOFError: + _LOGGER.exception("Unexpected response from router") + return + except ConnectionRefusedError: + _LOGGER.exception("Connection refused by router," + + " is telnet enabled?") + return None + + devices = {} + for lease in leases_result: + match = _LEASES_REGEX.search(lease.decode('utf-8')) + if match is not None: + devices[match.group('ip')] = { + 'ip': match.group('ip'), + 'mac': match.group('mac').upper(), + 'timevalid': int(match.group('timevalid')) + } + return devices diff --git a/homeassistant/components/device_tracker/asuswrt.py b/homeassistant/components/device_tracker/asuswrt.py index ec1d073c436..37e8cb0b2e7 100644 --- a/homeassistant/components/device_tracker/asuswrt.py +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -12,14 +12,36 @@ import threading from collections import namedtuple from datetime import timedelta -from homeassistant.components.device_tracker import DOMAIN +import voluptuous as vol + +from homeassistant.components.device_tracker import DOMAIN, PLATFORM_SCHEMA from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.helpers import validate_config from homeassistant.util import Throttle +import homeassistant.helpers.config_validation as cv # Return cached results if last scan was less then this time ago. MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) +CONF_PROTOCOL = 'protocol' +CONF_MODE = 'mode' +CONF_SSH_KEY = 'ssh_key' +CONF_PUB_KEY = 'pub_key' + +PLATFORM_SCHEMA = vol.All( + cv.has_at_least_one_key(CONF_PASSWORD, CONF_PUB_KEY, CONF_SSH_KEY), + PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PROTOCOL, default='ssh'): + vol.Schema(['ssh', 'telnet']), + vol.Optional(CONF_MODE, default='router'): + vol.Schema(['router', 'ap']), + vol.Optional(CONF_SSH_KEY): cv.isfile, + vol.Optional(CONF_PUB_KEY): cv.isfile + })) + + _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['pexpect==4.0.1'] @@ -57,16 +79,6 @@ _IP_NEIGH_REGEX = re.compile( # pylint: disable=unused-argument def get_scanner(hass, config): """Validate the configuration and return an ASUS-WRT scanner.""" - if not validate_config(config, - {DOMAIN: [CONF_HOST, CONF_USERNAME]}, - _LOGGER): - return None - elif CONF_PASSWORD not in config[DOMAIN] and \ - 'ssh_key' not in config[DOMAIN] and \ - 'pub_key' not in config[DOMAIN]: - _LOGGER.error('Either a private key or password must be provided') - return None - scanner = AsusWrtDeviceScanner(config[DOMAIN]) return scanner if scanner.success_init else None @@ -83,11 +95,11 @@ class AsusWrtDeviceScanner(object): def __init__(self, config): """Initialize the scanner.""" self.host = config[CONF_HOST] - self.username = str(config[CONF_USERNAME]) - self.password = str(config.get(CONF_PASSWORD, '')) - self.ssh_key = str(config.get('ssh_key', config.get('pub_key', ''))) - self.protocol = config.get('protocol') - self.mode = config.get('mode') + self.username = config[CONF_USERNAME] + self.password = config.get(CONF_PASSWORD, '') + self.ssh_key = config.get('ssh_key', config.get('pub_key', '')) + self.protocol = config[CONF_PROTOCOL] + self.mode = config[CONF_MODE] self.lock = threading.Lock() diff --git a/homeassistant/components/device_tracker/bluetooth_le_tracker.py b/homeassistant/components/device_tracker/bluetooth_le_tracker.py new file mode 100644 index 00000000000..7784a2326d8 --- /dev/null +++ b/homeassistant/components/device_tracker/bluetooth_le_tracker.py @@ -0,0 +1,108 @@ +"""Tracking for bluetooth devices.""" +import logging +from datetime import timedelta + +from homeassistant.helpers.event import track_point_in_utc_time +from homeassistant.components.device_tracker import ( + YAML_DEVICES, + CONF_TRACK_NEW, + CONF_SCAN_INTERVAL, + DEFAULT_SCAN_INTERVAL, + load_config, +) +import homeassistant.util as util +import homeassistant.util.dt as dt_util + +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ['gattlib==0.20150805'] + +BLE_PREFIX = 'BLE_' +MIN_SEEN_NEW = 5 + + +def setup_scanner(hass, config, see): + """Setup the Bluetooth LE Scanner.""" + # pylint: disable=import-error + from gattlib import DiscoveryService + + new_devices = {} + + def see_device(address, name, new_device=False): + """Mark a device as seen.""" + if new_device: + if address in new_devices: + _LOGGER.debug("Seen %s %s times", address, + new_devices[address]) + new_devices[address] += 1 + if new_devices[address] >= MIN_SEEN_NEW: + _LOGGER.debug("Adding %s to tracked devices", address) + devs_to_track.append(address) + else: + return + else: + _LOGGER.debug("Seen %s for the first time", address) + new_devices[address] = 1 + return + + see(mac=BLE_PREFIX + address, host_name=name.strip("\x00")) + + def discover_ble_devices(): + """Discover Bluetooth LE devices.""" + _LOGGER.debug("Discovering Bluetooth LE devices") + service = DiscoveryService() + devices = service.discover(10) + _LOGGER.debug("Bluetooth LE devices discovered = %s", devices) + + return devices + + yaml_path = hass.config.path(YAML_DEVICES) + devs_to_track = [] + devs_donot_track = [] + + # Load all known devices. + # We just need the devices so set consider_home and home range + # to 0 + for device in load_config(yaml_path, hass, 0, 0): + # check if device is a valid bluetooth device + if device.mac and device.mac[:3].upper() == BLE_PREFIX: + if device.track: + devs_to_track.append(device.mac[3:]) + else: + devs_donot_track.append(device.mac[3:]) + + # if track new devices is true discover new devices + # on every scan. + track_new = util.convert(config.get(CONF_TRACK_NEW), bool, + len(devs_to_track) == 0) + if not devs_to_track and not track_new: + _LOGGER.warning("No Bluetooth LE devices to track!") + return False + + interval = util.convert(config.get(CONF_SCAN_INTERVAL), int, + DEFAULT_SCAN_INTERVAL) + + def update_ble(now): + """Lookup Bluetooth LE devices and update status.""" + devs = discover_ble_devices() + for mac in devs_to_track: + _LOGGER.debug("Checking " + mac) + result = mac in devs + if not result: + # Could not lookup device name + continue + see_device(mac, devs[mac]) + + if track_new: + for address in devs: + if address not in devs_to_track and \ + address not in devs_donot_track: + _LOGGER.info("Discovered Bluetooth LE device %s", address) + see_device(address, devs[address], new_device=True) + + track_point_in_utc_time(hass, update_ble, + now + timedelta(seconds=interval)) + + update_ble(dt_util.utcnow()) + + return True diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index 00ba8c68556..cdb1f90ba8a 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -29,6 +29,9 @@ LOCK = threading.Lock() CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy' +VALIDATE_LOCATION = 'location' +VALIDATE_TRANSITION = 'transition' + def setup_scanner(hass, config, see): """Setup an OwnTracks tracker.""" @@ -47,16 +50,18 @@ def setup_scanner(hass, config, see): 'because of missing or malformatted data: %s', data_type, data) return None + if data_type == VALIDATE_TRANSITION: + return data if max_gps_accuracy is not None and \ convert(data.get('acc'), float, 0.0) > max_gps_accuracy: - _LOGGER.debug('Skipping %s update because expected GPS ' - 'accuracy %s is not met: %s', - data_type, max_gps_accuracy, data) + _LOGGER.warning('Ignoring %s update because expected GPS ' + 'accuracy %s is not met: %s', + data_type, max_gps_accuracy, payload) return None if convert(data.get('acc'), float, 1.0) == 0.0: - _LOGGER.debug('Skipping %s update because GPS accuracy' - 'is zero', - data_type) + _LOGGER.warning('Ignoring %s update because GPS accuracy' + 'is zero: %s', + data_type, payload) return None return data @@ -65,7 +70,7 @@ def setup_scanner(hass, config, see): """MQTT message received.""" # Docs on available data: # http://owntracks.org/booklet/tech/json/#_typelocation - data = validate_payload(payload, 'location') + data = validate_payload(payload, VALIDATE_LOCATION) if not data: return @@ -86,7 +91,7 @@ def setup_scanner(hass, config, see): """MQTT event (geofences) received.""" # Docs on available data: # http://owntracks.org/booklet/tech/json/#_typetransition - data = validate_payload(payload, 'transition') + data = validate_payload(payload, VALIDATE_TRANSITION) if not data: return @@ -143,14 +148,24 @@ def setup_scanner(hass, config, see): else: _LOGGER.info("Exit to GPS") # Check for GPS accuracy - if not ('acc' in data and - max_gps_accuracy is not None and - data['acc'] > max_gps_accuracy): - + valid_gps = True + if 'acc' in data: + if data['acc'] == 0.0: + valid_gps = False + _LOGGER.warning( + 'Ignoring GPS in region exit because accuracy' + 'is zero: %s', + payload) + if (max_gps_accuracy is not None and + data['acc'] > max_gps_accuracy): + valid_gps = False + _LOGGER.warning( + 'Ignoring GPS in region exit because expected ' + 'GPS accuracy %s is not met: %s', + max_gps_accuracy, payload) + if valid_gps: see(**kwargs) see_beacons(dev_id, kwargs) - else: - _LOGGER.info("Inaccurate GPS reported") beacons = MOBILE_BEACONS_ACTIVE[dev_id] if location in beacons: diff --git a/homeassistant/components/device_tracker/tplink.py b/homeassistant/components/device_tracker/tplink.py index 37d4d2b4471..17beab02532 100755 --- a/homeassistant/components/device_tracker/tplink.py +++ b/homeassistant/components/device_tracker/tplink.py @@ -313,19 +313,23 @@ class Tplink4DeviceScanner(TplinkDeviceScanner): _LOGGER.info("Loading wireless clients...") - url = 'http://{}/{}/userRpm/WlanStationRpm.htm' \ - .format(self.host, self.token) - referer = 'http://{}'.format(self.host) - cookie = 'Authorization=Basic {}'.format(self.credentials) + mac_results = [] - page = requests.get(url, headers={ - 'cookie': cookie, - 'referer': referer - }) - result = self.parse_macs.findall(page.text) + # Check both the 2.4GHz and 5GHz client list URLs + for clients_url in ('WlanStationRpm.htm', 'WlanStationRpm_5g.htm'): + url = 'http://{}/{}/userRpm/{}' \ + .format(self.host, self.token, clients_url) + referer = 'http://{}'.format(self.host) + cookie = 'Authorization=Basic {}'.format(self.credentials) - if not result: + page = requests.get(url, headers={ + 'cookie': cookie, + 'referer': referer + }) + mac_results.extend(self.parse_macs.findall(page.text)) + + if not mac_results: return False - self.last_results = [mac.replace("-", ":") for mac in result] + self.last_results = [mac.replace("-", ":") for mac in mac_results] return True diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 6db4af66207..0ac40c00f90 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -12,13 +12,13 @@ import threading from homeassistant.const import EVENT_HOMEASSISTANT_START from homeassistant.helpers.discovery import load_platform, discover -DOMAIN = "discovery" REQUIREMENTS = ['netdisco==0.7.1'] -SCAN_INTERVAL = 300 # seconds +DOMAIN = 'discovery' -SERVICE_WEMO = 'belkin_wemo' +SCAN_INTERVAL = 300 # seconds SERVICE_NETGEAR = 'netgear_router' +SERVICE_WEMO = 'belkin_wemo' SERVICE_HANDLERS = { SERVICE_NETGEAR: ('device_tracker', None), diff --git a/homeassistant/components/downloader.py b/homeassistant/components/downloader.py index c639619d7a7..b752743d2d4 100644 --- a/homeassistant/components/downloader.py +++ b/homeassistant/components/downloader.py @@ -16,21 +16,20 @@ from homeassistant.helpers import validate_config import homeassistant.helpers.config_validation as cv from homeassistant.util import sanitize_filename -DOMAIN = "downloader" - -SERVICE_DOWNLOAD_FILE = "download_file" - -ATTR_URL = "url" -ATTR_SUBDIR = "subdir" - -SERVICE_DOWNLOAD_FILE_SCHEMA = vol.Schema({ - # pylint: disable=no-value-for-parameter - vol.Required(ATTR_URL): vol.Url(), - vol.Optional(ATTR_SUBDIR): cv.string, -}) +ATTR_SUBDIR = 'subdir' +ATTR_URL = 'url' CONF_DOWNLOAD_DIR = 'download_dir' +DOMAIN = 'downloader' + +SERVICE_DOWNLOAD_FILE = 'download_file' + +SERVICE_DOWNLOAD_FILE_SCHEMA = vol.Schema({ + vol.Required(ATTR_URL): cv.url, + vol.Optional(ATTR_SUBDIR): cv.string, +}) + # pylint: disable=too-many-branches def setup(hass, config): diff --git a/homeassistant/components/dweet.py b/homeassistant/components/dweet.py index 49c1e74f232..d56d9d2ef93 100644 --- a/homeassistant/components/dweet.py +++ b/homeassistant/components/dweet.py @@ -6,30 +6,28 @@ https://home-assistant.io/components/dweet/ """ import logging from datetime import timedelta + import voluptuous as vol -from homeassistant.const import EVENT_STATE_CHANGED, STATE_UNKNOWN +from homeassistant.const import ( + CONF_NAME, CONF_WHITELIST, EVENT_STATE_CHANGED, STATE_UNKNOWN) import homeassistant.helpers.config_validation as cv from homeassistant.helpers import state as state_helper from homeassistant.util import Throttle +REQUIREMENTS = ['dweepy==0.2.0'] _LOGGER = logging.getLogger(__name__) -DOMAIN = "dweet" -DEPENDENCIES = [] - -REQUIREMENTS = ['dweepy==0.2.0'] - -CONF_NAME = 'name' -CONF_WHITELIST = 'whitelist' +DOMAIN = 'dweet' MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_WHITELIST): cv.string, + vol.Required(CONF_WHITELIST, default=[]): + vol.All(cv.ensure_list, [cv.entity_id]), }), }, extra=vol.ALLOW_EXTRA) @@ -38,8 +36,8 @@ CONFIG_SCHEMA = vol.Schema({ def setup(hass, config): """Setup the Dweet.io component.""" conf = config[DOMAIN] - name = conf[CONF_NAME] - whitelist = conf.get(CONF_WHITELIST, []) + name = conf.get(CONF_NAME) + whitelist = conf.get(CONF_WHITELIST) json_body = {} def dweet_event_listener(event): diff --git a/homeassistant/components/ecobee.py b/homeassistant/components/ecobee.py index 48d689364ee..702c7fd6304 100644 --- a/homeassistant/components/ecobee.py +++ b/homeassistant/components/ecobee.py @@ -7,7 +7,9 @@ https://home-assistant.io/components/ecobee/ import logging import os from datetime import timedelta +import voluptuous as vol +import homeassistant.helpers.config_validation as cv from homeassistant.helpers import discovery from homeassistant.const import CONF_API_KEY from homeassistant.loader import get_component @@ -15,12 +17,19 @@ from homeassistant.util import Throttle DOMAIN = "ecobee" NETWORK = None -HOLD_TEMP = 'hold_temp' +CONF_HOLD_TEMP = 'hold_temp' REQUIREMENTS = [ 'https://github.com/nkgilley/python-ecobee-api/archive/' '4856a704670c53afe1882178a89c209b5f98533d.zip#python-ecobee==0.0.6'] +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_API_KEY): cv.string, + vol.Optional(CONF_HOLD_TEMP, default=False): cv.boolean + }) +}, extra=vol.ALLOW_EXTRA) + _LOGGER = logging.getLogger(__name__) ECOBEE_CONFIG_FILE = 'ecobee.conf' @@ -67,11 +76,12 @@ def setup_ecobee(hass, network, config): configurator = get_component('configurator') configurator.request_done(_CONFIGURING.pop('ecobee')) - hold_temp = config[DOMAIN].get(HOLD_TEMP, False) + hold_temp = config[DOMAIN].get(CONF_HOLD_TEMP) - discovery.load_platform(hass, 'thermostat', DOMAIN, + discovery.load_platform(hass, 'climate', DOMAIN, {'hold_temp': hold_temp}, config) discovery.load_platform(hass, 'sensor', DOMAIN, {}, config) + discovery.load_platform(hass, 'binary_sensor', DOMAIN, {}, config) # pylint: disable=too-few-public-methods diff --git a/homeassistant/components/emulated_hue.py b/homeassistant/components/emulated_hue.py new file mode 100755 index 00000000000..f7a353d5c7f --- /dev/null +++ b/homeassistant/components/emulated_hue.py @@ -0,0 +1,542 @@ +""" +Support for local control of entities by emulating the Phillips Hue bridge. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/emulated_hue/ +""" +import threading +import socket +import logging +import json +import os +import select + +import voluptuous as vol + +from homeassistant import util, core +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, SERVICE_TURN_OFF, SERVICE_TURN_ON, + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, + STATE_ON +) +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, ATTR_SUPPORTED_FEATURES, SUPPORT_BRIGHTNESS +) +from homeassistant.components.http import ( + HomeAssistantView, HomeAssistantWSGI +) +import homeassistant.helpers.config_validation as cv + +DOMAIN = 'emulated_hue' + +_LOGGER = logging.getLogger(__name__) + +CONF_HOST_IP = 'host_ip' +CONF_LISTEN_PORT = 'listen_port' +CONF_OFF_MAPS_TO_ON_DOMAINS = 'off_maps_to_on_domains' +CONF_EXPOSE_BY_DEFAULT = 'expose_by_default' +CONF_EXPOSED_DOMAINS = 'exposed_domains' + +ATTR_EMULATED_HUE = 'emulated_hue' +ATTR_EMULATED_HUE_NAME = 'emulated_hue_name' + +DEFAULT_LISTEN_PORT = 8300 +DEFAULT_OFF_MAPS_TO_ON_DOMAINS = ['script', 'scene'] +DEFAULT_EXPOSE_BY_DEFAULT = True +DEFAULT_EXPOSED_DOMAINS = [ + 'switch', 'light', 'group', 'input_boolean', 'media_player' +] + +HUE_API_STATE_ON = 'on' +HUE_API_STATE_BRI = 'bri' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_HOST_IP): cv.string, + vol.Optional(CONF_LISTEN_PORT, default=DEFAULT_LISTEN_PORT): + vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)), + vol.Optional(CONF_OFF_MAPS_TO_ON_DOMAINS): cv.ensure_list, + vol.Optional(CONF_EXPOSE_BY_DEFAULT): cv.boolean, + vol.Optional(CONF_EXPOSED_DOMAINS): cv.ensure_list + }) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, yaml_config): + """Activate the emulated_hue component.""" + config = Config(yaml_config) + + server = HomeAssistantWSGI( + hass, + development=False, + server_host=config.host_ip_addr, + server_port=config.listen_port, + api_password=None, + ssl_certificate=None, + ssl_key=None, + cors_origins=[] + ) + + server.register_view(DescriptionXmlView(hass, config)) + server.register_view(HueUsernameView(hass)) + server.register_view(HueLightsView(hass, config)) + + upnp_listener = UPNPResponderThread( + config.host_ip_addr, config.listen_port) + + def start_emulated_hue_bridge(event): + """Start the emulated hue bridge.""" + server.start() + upnp_listener.start() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_emulated_hue_bridge) + + def stop_emulated_hue_bridge(event): + """Stop the emulated hue bridge.""" + upnp_listener.stop() + server.stop() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_emulated_hue_bridge) + + return True + + +# pylint: disable=too-few-public-methods +class Config(object): + """Holds configuration variables for the emulated hue bridge.""" + + def __init__(self, yaml_config): + """Initialize the instance.""" + conf = yaml_config.get(DOMAIN, {}) + + # Get the IP address that will be passed to the Echo during discovery + self.host_ip_addr = conf.get(CONF_HOST_IP) + if self.host_ip_addr is None: + self.host_ip_addr = util.get_local_ip() + _LOGGER.warning( + "Listen IP address not specified, auto-detected address is %s", + self.host_ip_addr) + + # Get the port that the Hue bridge will listen on + self.listen_port = conf.get(CONF_LISTEN_PORT) + if not isinstance(self.listen_port, int): + self.listen_port = DEFAULT_LISTEN_PORT + _LOGGER.warning( + "Listen port not specified, defaulting to %s", + self.listen_port) + + # Get domains that cause both "on" and "off" commands to map to "on" + # This is primarily useful for things like scenes or scripts, which + # don't really have a concept of being off + self.off_maps_to_on_domains = conf.get(CONF_OFF_MAPS_TO_ON_DOMAINS) + if not isinstance(self.off_maps_to_on_domains, list): + self.off_maps_to_on_domains = DEFAULT_OFF_MAPS_TO_ON_DOMAINS + + # Get whether or not entities should be exposed by default, or if only + # explicitly marked ones will be exposed + self.expose_by_default = conf.get( + CONF_EXPOSE_BY_DEFAULT, DEFAULT_EXPOSE_BY_DEFAULT) + + # Get domains that are exposed by default when expose_by_default is + # True + self.exposed_domains = conf.get( + CONF_EXPOSED_DOMAINS, DEFAULT_EXPOSED_DOMAINS) + + +class DescriptionXmlView(HomeAssistantView): + """Handles requests for the description.xml file.""" + + url = '/description.xml' + name = 'description:xml' + requires_auth = False + + def __init__(self, hass, config): + """Initialize the instance of the view.""" + super().__init__(hass) + self.config = config + + def get(self, request): + """Handle a GET request.""" + xml_template = """ + + +1 +0 + +http://{0}:{1}/ + +urn:schemas-upnp-org:device:Basic:1 +HASS Bridge ({0}) +Royal Philips Electronics +http://www.philips.com +Philips hue Personal Wireless Lighting +Philips hue bridge 2015 +BSB002 +http://www.meethue.com +1234 +uuid:2f402f80-da50-11e1-9b23-001788255acc + + +""" + + resp_text = xml_template.format( + self.config.host_ip_addr, self.config.listen_port) + + return self.Response(resp_text, mimetype='text/xml') + + +class HueUsernameView(HomeAssistantView): + """Handle requests to create a username for the emulated hue bridge.""" + + url = '/api' + name = 'hue:api' + requires_auth = False + + def __init__(self, hass): + """Initialize the instance of the view.""" + super().__init__(hass) + + def post(self, request): + """Handle a POST request.""" + data = request.json + + if 'devicetype' not in data: + return self.Response("devicetype not specified", status=400) + + json_response = [{'success': {'username': '12345678901234567890'}}] + + return self.json(json_response) + + +class HueLightsView(HomeAssistantView): + """Handle requests for getting and setting info about entities.""" + + url = '/api//lights' + name = 'api:username:lights' + extra_urls = ['/api//lights/', + '/api//lights//state'] + requires_auth = False + + def __init__(self, hass, config): + """Initialize the instance of the view.""" + super().__init__(hass) + self.config = config + self.cached_states = {} + + def get(self, request, username, entity_id=None): + """Handle a GET request.""" + if entity_id is None: + return self.get_lights_list() + + if not request.base_url.endswith('state'): + return self.get_light_state(entity_id) + + return self.Response("Method not allowed", status=405) + + def put(self, request, username, entity_id=None): + """Handle a PUT request.""" + if not request.base_url.endswith('state'): + return self.Response("Method not allowed", status=405) + + content_type = request.environ.get('CONTENT_TYPE', '') + if content_type == 'application/x-www-form-urlencoded': + # Alexa sends JSON data with a form data content type, for + # whatever reason, and Werkzeug parses form data automatically, + # so we need to do some gymnastics to get the data we need + json_data = None + + for key in request.form: + try: + json_data = json.loads(key) + break + except ValueError: + # Try the next key? + pass + + if json_data is None: + return self.Response("Bad request", status=400) + else: + json_data = request.json + + return self.put_light_state(json_data, entity_id) + + def get_lights_list(self): + """Process a request to get the list of available lights.""" + json_response = {} + + for entity in self.hass.states.all(): + if self.is_entity_exposed(entity): + json_response[entity.entity_id] = entity_to_json(entity) + + return self.json(json_response) + + def get_light_state(self, entity_id): + """Process a request to get the state of an individual light.""" + entity = self.hass.states.get(entity_id) + if entity is None or not self.is_entity_exposed(entity): + return self.Response("Entity not found", status=404) + + cached_state = self.cached_states.get(entity_id, None) + + if cached_state is None: + final_state = entity.state == STATE_ON + final_brightness = entity.attributes.get( + ATTR_BRIGHTNESS, 255 if final_state else 0) + else: + final_state, final_brightness = cached_state + + json_response = entity_to_json(entity, final_state, final_brightness) + + return self.json(json_response) + + def put_light_state(self, request_json, entity_id): + """Process a request to set the state of an individual light.""" + config = self.config + + # Retrieve the entity from the state machine + entity = self.hass.states.get(entity_id) + if entity is None: + return self.Response("Entity not found", status=404) + + if not self.is_entity_exposed(entity): + return self.Response("Entity not found", status=404) + + # Parse the request into requested "on" status and brightness + parsed = parse_hue_api_put_light_body(request_json, entity) + + if parsed is None: + return self.Response("Bad request", status=400) + + result, brightness = parsed + + # Convert the resulting "on" status into the service we need to call + service = SERVICE_TURN_ON if result else SERVICE_TURN_OFF + + # Construct what we need to send to the service + data = {ATTR_ENTITY_ID: entity_id} + + if brightness is not None: + data[ATTR_BRIGHTNESS] = brightness + + if entity.domain.lower() in config.off_maps_to_on_domains: + # Map the off command to on + service = SERVICE_TURN_ON + + # Caching is required because things like scripts and scenes won't + # report as "off" to Alexa if an "off" command is received, because + # they'll map to "on". Thus, instead of reporting its actual + # status, we report what Alexa will want to see, which is the same + # as the actual requested command. + self.cached_states[entity_id] = (result, brightness) + + # Perform the requested action + self.hass.services.call(core.DOMAIN, service, data, blocking=True) + + json_response = \ + [create_hue_success_response(entity_id, HUE_API_STATE_ON, result)] + + if brightness is not None: + json_response.append(create_hue_success_response( + entity_id, HUE_API_STATE_BRI, brightness)) + + return self.json(json_response) + + def is_entity_exposed(self, entity): + """Determine if an entity should be exposed on the emulated bridge.""" + config = self.config + + if entity.attributes.get('view') is not None: + # Ignore entities that are views + return False + + domain = entity.domain.lower() + explicit_expose = entity.attributes.get(ATTR_EMULATED_HUE, None) + + domain_exposed_by_default = \ + config.expose_by_default and domain in config.exposed_domains + + # Expose an entity if the entity's domain is exposed by default and + # the configuration doesn't explicitly exclude it from being + # exposed, or if the entity is explicitly exposed + is_default_exposed = \ + domain_exposed_by_default and explicit_expose is not False + + return is_default_exposed or explicit_expose + + +def parse_hue_api_put_light_body(request_json, entity): + """Parse the body of a request to change the state of a light.""" + if HUE_API_STATE_ON in request_json: + if not isinstance(request_json[HUE_API_STATE_ON], bool): + return None + + if request_json['on']: + # Echo requested device be turned on + brightness = None + report_brightness = False + result = True + else: + # Echo requested device be turned off + brightness = None + report_brightness = False + result = False + + if HUE_API_STATE_BRI in request_json: + # Make sure the entity actually supports brightness + entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + + if (entity_features & SUPPORT_BRIGHTNESS) == SUPPORT_BRIGHTNESS: + try: + # Clamp brightness from 0 to 255 + brightness = \ + max(0, min(int(request_json[HUE_API_STATE_BRI]), 255)) + except ValueError: + return None + + report_brightness = True + result = (brightness > 0) + + return (result, brightness) if report_brightness else (result, None) + + +def entity_to_json(entity, is_on=None, brightness=None): + """Convert an entity to its Hue bridge JSON representation.""" + if is_on is None: + is_on = entity.state == STATE_ON + + if brightness is None: + brightness = 255 if is_on else 0 + + name = entity.attributes.get( + ATTR_EMULATED_HUE_NAME, entity.attributes[ATTR_FRIENDLY_NAME]) + + return { + 'state': + { + HUE_API_STATE_ON: is_on, + HUE_API_STATE_BRI: brightness, + 'reachable': True + }, + 'type': 'Dimmable light', + 'name': name, + 'modelid': 'HASS123', + 'uniqueid': entity.entity_id, + 'swversion': '123' + } + + +def create_hue_success_response(entity_id, attr, value): + """Create a success response for an attribute set on a light.""" + success_key = '/lights/{}/state/{}'.format(entity_id, attr) + return {'success': {success_key: value}} + + +class UPNPResponderThread(threading.Thread): + """Handle responding to UPNP/SSDP discovery requests.""" + + _interrupted = False + + def __init__(self, host_ip_addr, listen_port): + """Initialize the class.""" + threading.Thread.__init__(self) + + self.host_ip_addr = host_ip_addr + self.listen_port = listen_port + + # Note that the double newline at the end of + # this string is required per the SSDP spec + resp_template = """HTTP/1.1 200 OK +CACHE-CONTROL: max-age=60 +EXT: +LOCATION: http://{0}:{1}/description.xml +SERVER: FreeRTOS/6.0.5, UPnP/1.0, IpBridge/0.1 +ST: urn:schemas-upnp-org:device:basic:1 +USN: uuid:Socket-1_0-221438K0100073::urn:schemas-upnp-org:device:basic:1 + +""" + + self.upnp_response = resp_template.format(host_ip_addr, listen_port) \ + .replace("\n", "\r\n") \ + .encode('utf-8') + + # Set up a pipe for signaling to the receiver that it's time to + # shutdown. Essentially, we place the SSDP socket into nonblocking + # mode and use select() to wait for data to arrive on either the SSDP + # socket or the pipe. If data arrives on either one, select() returns + # and tells us which filenos have data ready to read. + # + # When we want to stop the responder, we write data to the pipe, which + # causes the select() to return and indicate that said pipe has data + # ready to be read, which indicates to us that the responder needs to + # be shutdown. + self._interrupted_read_pipe, self._interrupted_write_pipe = os.pipe() + + def run(self): + """Run the server.""" + # Listen for UDP port 1900 packets sent to SSDP multicast address + ssdp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + ssdp_socket.setblocking(False) + + # Required for receiving multicast + ssdp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + ssdp_socket.setsockopt( + socket.SOL_IP, + socket.IP_MULTICAST_IF, + socket.inet_aton(self.host_ip_addr)) + + ssdp_socket.setsockopt( + socket.SOL_IP, + socket.IP_ADD_MEMBERSHIP, + socket.inet_aton("239.255.255.250") + + socket.inet_aton(self.host_ip_addr)) + + ssdp_socket.bind(("239.255.255.250", 1900)) + + while True: + if self._interrupted: + clean_socket_close(ssdp_socket) + return + + try: + read, _, _ = select.select( + [self._interrupted_read_pipe, ssdp_socket], [], + [ssdp_socket]) + + if self._interrupted_read_pipe in read: + # Implies self._interrupted is True + clean_socket_close(ssdp_socket) + return + elif ssdp_socket in read: + data, addr = ssdp_socket.recvfrom(1024) + else: + continue + except socket.error as ex: + if self._interrupted: + clean_socket_close(ssdp_socket) + return + + _LOGGER.error("UPNP Responder socket exception occured: %s", + ex.__str__) + + if "M-SEARCH" in data.decode('utf-8'): + # SSDP M-SEARCH method received, respond to it with our info + resp_socket = socket.socket( + socket.AF_INET, socket.SOCK_DGRAM) + + resp_socket.sendto(self.upnp_response, addr) + resp_socket.close() + + def stop(self): + """Stop the server.""" + # Request for server + self._interrupted = True + os.write(self._interrupted_write_pipe, bytes([0])) + self.join() + + +def clean_socket_close(sock): + """Close a socket connection and logs its closure.""" + _LOGGER.info("UPNP responder shutting down.") + + sock.close() diff --git a/homeassistant/components/enocean.py b/homeassistant/components/enocean.py index 1e70e537c59..7c36e173510 100644 --- a/homeassistant/components/enocean.py +++ b/homeassistant/components/enocean.py @@ -4,23 +4,36 @@ EnOcean Component. For more details about this component, please refer to the documentation at https://home-assistant.io/components/EnOcean/ """ +import logging -DOMAIN = "enocean" +import voluptuous as vol + +from homeassistant.const import CONF_DEVICE +import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['enocean==0.31'] -CONF_DEVICE = "device" +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'enocean' ENOCEAN_DONGLE = None +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_DEVICE): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + def setup(hass, config): """Setup the EnOcean component.""" global ENOCEAN_DONGLE - serial_dev = config[DOMAIN].get(CONF_DEVICE, "/dev/ttyUSB0") + serial_dev = config[DOMAIN].get(CONF_DEVICE) ENOCEAN_DONGLE = EnOceanDongle(hass, serial_dev) + return True diff --git a/homeassistant/components/envisalink.py b/homeassistant/components/envisalink.py index ab4312ff20f..eea06307f7d 100644 --- a/homeassistant/components/envisalink.py +++ b/homeassistant/components/envisalink.py @@ -62,8 +62,7 @@ CONFIG_SCHEMA = vol.Schema({ vol.Required(CONF_CODE): cv.string, vol.Optional(CONF_ZONES): {vol.Coerce(int): ZONE_SCHEMA}, vol.Optional(CONF_PARTITIONS): {vol.Coerce(int): PARTITION_SCHEMA}, - vol.Optional(CONF_EVL_PORT, default=DEFAULT_PORT): - vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)), + vol.Optional(CONF_EVL_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_EVL_VERSION, default=DEFAULT_EVL_VERSION): vol.All(vol.Coerce(int), vol.Range(min=3, max=4)), vol.Optional(CONF_EVL_KEEPALIVE, default=DEFAULT_KEEPALIVE): diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py new file mode 100644 index 00000000000..13244569dbb --- /dev/null +++ b/homeassistant/components/fan/__init__.py @@ -0,0 +1,247 @@ +""" +Provides functionality to interact with fans. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/fan/ +""" +import logging +import os + +import voluptuous as vol + +from homeassistant.components import group +from homeassistant.config import load_yaml_config_file +from homeassistant.const import (SERVICE_TURN_ON, SERVICE_TOGGLE, + SERVICE_TURN_OFF, ATTR_ENTITY_ID, + STATE_UNKNOWN) +from homeassistant.helpers.entity import ToggleEntity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa +import homeassistant.helpers.config_validation as cv + + +DOMAIN = 'fan' +SCAN_INTERVAL = 30 + +GROUP_NAME_ALL_FANS = 'all fans' +ENTITY_ID_ALL_FANS = group.ENTITY_ID_FORMAT.format(GROUP_NAME_ALL_FANS) + +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +# Bitfield of features supported by the fan entity +ATTR_SUPPORTED_FEATURES = 'supported_features' +SUPPORT_SET_SPEED = 1 +SUPPORT_OSCILLATE = 2 + +SERVICE_SET_SPEED = 'set_speed' +SERVICE_OSCILLATE = 'oscillate' + +SPEED_OFF = 'off' +SPEED_LOW = 'low' +SPEED_MED = 'med' +SPEED_HIGH = 'high' + +ATTR_SPEED = 'speed' +ATTR_SPEED_LIST = 'speed_list' +ATTR_OSCILLATING = 'oscillating' + +PROP_TO_ATTR = { + 'speed': ATTR_SPEED, + 'speed_list': ATTR_SPEED_LIST, + 'oscillating': ATTR_OSCILLATING, + 'supported_features': ATTR_SUPPORTED_FEATURES, +} # type: dict + +FAN_SET_SPEED_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_SPEED): cv.string +}) # type: dict + +FAN_TURN_ON_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(ATTR_SPEED): cv.string +}) # type: dict + +FAN_TURN_OFF_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.entity_ids +}) # type: dict + +FAN_OSCILLATE_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_OSCILLATING): cv.boolean +}) # type: dict + +FAN_TOGGLE_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.entity_ids +}) + +_LOGGER = logging.getLogger(__name__) + + +def is_on(hass, entity_id: str=None) -> bool: + """Return if the fans are on based on the statemachine.""" + entity_id = entity_id or ENTITY_ID_ALL_FANS + state = hass.states.get(entity_id) + return state.attributes[ATTR_SPEED] not in [SPEED_OFF, STATE_UNKNOWN] + + +# pylint: disable=too-many-arguments +def turn_on(hass, entity_id: str=None, speed: str=None) -> None: + """Turn all or specified fan on.""" + data = { + key: value for key, value in [ + (ATTR_ENTITY_ID, entity_id), + (ATTR_SPEED, speed), + ] if value is not None + } + + hass.services.call(DOMAIN, SERVICE_TURN_ON, data) + + +def turn_off(hass, entity_id: str=None) -> None: + """Turn all or specified fan off.""" + data = { + ATTR_ENTITY_ID: entity_id, + } + + hass.services.call(DOMAIN, SERVICE_TURN_OFF, data) + + +def toggle(hass, entity_id: str=None) -> None: + """Toggle all or specified fans.""" + data = { + ATTR_ENTITY_ID: entity_id + } + + hass.services.call(DOMAIN, SERVICE_TOGGLE, data) + + +def oscillate(hass, entity_id: str=None, should_oscillate: bool=True) -> None: + """Set oscillation on all or specified fan.""" + data = { + key: value for key, value in [ + (ATTR_ENTITY_ID, entity_id), + (ATTR_OSCILLATING, should_oscillate), + ] if value is not None + } + + hass.services.call(DOMAIN, SERVICE_OSCILLATE, data) + + +def set_speed(hass, entity_id: str=None, speed: str=None) -> None: + """Set speed for all or specified fan.""" + data = { + key: value for key, value in [ + (ATTR_ENTITY_ID, entity_id), + (ATTR_SPEED, speed), + ] if value is not None + } + + hass.services.call(DOMAIN, SERVICE_SET_SPEED, data) + + +# pylint: disable=too-many-branches, too-many-locals, too-many-statements +def setup(hass, config: dict) -> None: + """Expose fan control via statemachine and services.""" + component = EntityComponent( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_FANS) + component.setup(config) + + def handle_fan_service(service: str) -> None: + """Hande service call for fans.""" + # Get the validated data + params = service.data.copy() + + # Convert the entity ids to valid fan ids + target_fans = component.extract_from_service(service) + params.pop(ATTR_ENTITY_ID, None) + + service_fun = None + for service_def in [SERVICE_TURN_ON, SERVICE_TURN_OFF, + SERVICE_SET_SPEED, SERVICE_OSCILLATE]: + if service_def == service.service: + service_fun = service_def + break + + if service_fun: + for fan in target_fans: + getattr(fan, service_fun)(**params) + + for fan in target_fans: + if fan.should_poll: + fan.update_ha_state(True) + return + + # Listen for fan service calls. + descriptions = load_yaml_config_file( + os.path.join(os.path.dirname(__file__), 'services.yaml')) + hass.services.register(DOMAIN, SERVICE_TURN_ON, handle_fan_service, + descriptions.get(SERVICE_TURN_ON), + schema=FAN_TURN_ON_SCHEMA) + + hass.services.register(DOMAIN, SERVICE_TURN_OFF, handle_fan_service, + descriptions.get(SERVICE_TURN_OFF), + schema=FAN_TURN_OFF_SCHEMA) + + hass.services.register(DOMAIN, SERVICE_SET_SPEED, handle_fan_service, + descriptions.get(SERVICE_SET_SPEED), + schema=FAN_SET_SPEED_SCHEMA) + + hass.services.register(DOMAIN, SERVICE_OSCILLATE, handle_fan_service, + descriptions.get(SERVICE_OSCILLATE), + schema=FAN_OSCILLATE_SCHEMA) + + return True + + +class FanEntity(ToggleEntity): + """Representation of a fan.""" + + # pylint: disable=no-self-use, abstract-method + + def set_speed(self: ToggleEntity, speed: str) -> None: + """Set the speed of the fan.""" + pass + + def turn_on(self: ToggleEntity, speed: str=None, **kwargs) -> None: + """Turn on the fan.""" + raise NotImplementedError() + + def turn_off(self: ToggleEntity, **kwargs) -> None: + """Turn off the fan.""" + raise NotImplementedError() + + def oscillate(self: ToggleEntity, oscillating: bool) -> None: + """Oscillate the fan.""" + pass + + @property + def is_on(self): + """Return true if the entity is on.""" + return self.state_attributes.get(ATTR_SPEED, STATE_UNKNOWN) \ + not in [SPEED_OFF, STATE_UNKNOWN] + + @property + def speed_list(self: ToggleEntity) -> list: + """Get the list of available speeds.""" + return [] + + @property + def state_attributes(self: ToggleEntity) -> dict: + """Return optional state attributes.""" + data = {} # type: dict + + for prop, attr in PROP_TO_ATTR.items(): + if not hasattr(self, prop): + continue + + value = getattr(self, prop) + if value is not None: + data[attr] = value + + return data + + @property + def supported_features(self: ToggleEntity) -> int: + """Flag supported features.""" + return 0 diff --git a/homeassistant/components/fan/demo.py b/homeassistant/components/fan/demo.py new file mode 100644 index 00000000000..83508063fa9 --- /dev/null +++ b/homeassistant/components/fan/demo.py @@ -0,0 +1,75 @@ +""" +Demo garage door platform that has a fake fan. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/demo/ +""" + +from homeassistant.components.fan import (SPEED_LOW, SPEED_MED, SPEED_HIGH, + FanEntity, SUPPORT_SET_SPEED, + SUPPORT_OSCILLATE) +from homeassistant.const import STATE_OFF + + +FAN_NAME = 'Living Room Fan' +FAN_ENTITY_ID = 'fan.living_room_fan' + +DEMO_SUPPORT = SUPPORT_SET_SPEED | SUPPORT_OSCILLATE + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """Setup demo garage door platform.""" + add_devices_callback([ + DemoFan(hass, FAN_NAME, STATE_OFF), + ]) + + +class DemoFan(FanEntity): + """A demonstration fan component.""" + + def __init__(self, hass, name: str, initial_state: str) -> None: + """Initialize the entity.""" + self.hass = hass + self.speed = initial_state + self.oscillating = False + self._name = name + + @property + def name(self) -> str: + """Get entity name.""" + return self._name + + @property + def should_poll(self): + """No polling needed for a demo fan.""" + return False + + @property + def speed_list(self) -> list: + """Get the list of available speeds.""" + return [STATE_OFF, SPEED_LOW, SPEED_MED, SPEED_HIGH] + + def turn_on(self, speed: str=SPEED_MED) -> None: + """Turn on the entity.""" + self.set_speed(speed) + + def turn_off(self) -> None: + """Turn off the entity.""" + self.oscillate(False) + self.set_speed(STATE_OFF) + + def set_speed(self, speed: str) -> None: + """Set the speed of the fan.""" + self.speed = speed + self.update_ha_state() + + def oscillate(self, oscillating: bool) -> None: + """Set oscillation.""" + self.oscillating = oscillating + self.update_ha_state() + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return DEMO_SUPPORT diff --git a/homeassistant/components/fan/insteon_hub.py b/homeassistant/components/fan/insteon_hub.py new file mode 100644 index 00000000000..4d65ee1f02b --- /dev/null +++ b/homeassistant/components/fan/insteon_hub.py @@ -0,0 +1,66 @@ +""" +Support for Insteon FanLinc. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/fan.insteon/ +""" + +import logging + +from homeassistant.components.fan import (FanEntity, SUPPORT_SET_SPEED, + SPEED_OFF, SPEED_LOW, SPEED_MED, + SPEED_HIGH) +from homeassistant.components.insteon_hub import (InsteonDevice, INSTEON, + filter_devices) +from homeassistant.const import STATE_UNKNOWN + +_LOGGER = logging.getLogger(__name__) + +DEVICE_CATEGORIES = [ + { + 'DevCat': 1, + 'SubCat': [46] + } +] + +DEPENDENCIES = ['insteon_hub'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Insteon Hub fan platform.""" + devs = [] + for device in filter_devices(INSTEON.devices, DEVICE_CATEGORIES): + devs.append(InsteonFanDevice(device)) + add_devices(devs) + + +class InsteonFanDevice(InsteonDevice, FanEntity): + """Represet an insteon fan device.""" + + def __init__(self, node: object) -> None: + """Initialize the device.""" + super(InsteonFanDevice, self).__init__(node) + self.speed = STATE_UNKNOWN # Insteon hub can't get state via REST + + def turn_on(self, speed: str=None): + """Turn the fan on.""" + self.set_speed(speed if speed else SPEED_MED) + + def turn_off(self): + """Turn the fan off.""" + self.set_speed(SPEED_OFF) + + def set_speed(self, speed: str) -> None: + """Set the fan speed.""" + if self._send_command('fan', payload={'speed', speed}): + self.speed = speed + + @property + def supported_features(self) -> int: + """Get the supported features for device.""" + return SUPPORT_SET_SPEED + + @property + def speed_list(self) -> list: + """Get the available speeds for the fan.""" + return [SPEED_OFF, SPEED_LOW, SPEED_MED, SPEED_HIGH] diff --git a/homeassistant/components/fan/services.yaml b/homeassistant/components/fan/services.yaml new file mode 100644 index 00000000000..e729e7f7e89 --- /dev/null +++ b/homeassistant/components/fan/services.yaml @@ -0,0 +1,53 @@ +# Describes the format for available fan services + +set_speed: + description: Sets fan speed + + fields: + entity_id: + description: Name(s) of the entities to set + example: 'fan.living_room' + + speed: + description: Speed setting + example: 'low' + +turn_on: + description: Turns fan on + + fields: + entity_id: + description: Names(s) of the entities to turn on + example: 'fan.living_room' + + speed: + description: Speed setting + example: 'high' + +turn_off: + description: Turns fan off + + fields: + entity_id: + description: Names(s) of the entities to turn off + example: 'fan.living_room' + +oscillate: + description: Oscillates the fan + + fields: + entity_id: + description: Name(s) of the entities to oscillate + example: 'fan.desk_fan' + + oscillating: + description: Flag to turn on/off oscillation + example: True + +toggle: + description: Toggle the fan on/off + + fields: + entity_id: + description: Name(s) of the entities to toggle + exampl: 'fan.living_room' \ No newline at end of file diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index cec18b66511..ab967fb114f 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -14,6 +14,19 @@ URL_PANEL_COMPONENT = '/frontend/panels/{}.html' URL_PANEL_COMPONENT_FP = '/frontend/panels/{}-{}.html' STATIC_PATH = os.path.join(os.path.dirname(__file__), 'www_static') PANELS = {} +MANIFEST_JSON = { + "background_color": "#FFFFFF", + "description": "Open-source home automation platform running on Python 3.", + "dir": "ltr", + "display": "standalone", + "icons": [], + "lang": "en-US", + "name": "Home Assistant", + "orientation": "any", + "short_name": "Assistant", + "start_url": "/", + "theme_color": "#03A9F4" +} # To keep track we don't register a component twice (gives a warning) _REGISTERED_COMPONENTS = set() @@ -94,9 +107,15 @@ def register_panel(hass, component_name, path, md5=None, sidebar_title=None, PANELS[url_path] = data +def add_manifest_json_key(key, val): + """Add a keyval to the manifest.json.""" + MANIFEST_JSON[key] = val + + def setup(hass, config): """Setup serving the frontend.""" hass.wsgi.register_view(BootstrapView) + hass.wsgi.register_view(ManifestJSONView) if hass.wsgi.development: sw_path = "home-assistant-polymer/build/service_worker.js" @@ -126,6 +145,13 @@ def setup(hass, config): hass.bus.listen_once(EVENT_HOMEASSISTANT_START, register_frontend_index) + for size in (192, 384, 512, 1024): + MANIFEST_JSON['icons'].append({ + "src": "/static/icons/favicon-{}x{}.png".format(size, size), + "sizes": "{}x{}".format(size, size), + "type": "image/png" + }) + return True @@ -199,3 +225,17 @@ class IndexView(HomeAssistantView): panel_url=panel_url, panels=PANELS) return self.Response(resp, mimetype='text/html') + + +class ManifestJSONView(HomeAssistantView): + """View to return a manifest.json.""" + + requires_auth = False + url = "/manifest.json" + name = "manifestjson" + + def get(self, request): + """Return the manifest.json.""" + import json + msg = json.dumps(MANIFEST_JSON, sort_keys=True).encode('UTF-8') + return self.Response(msg, mimetype="application/manifest+json") diff --git a/homeassistant/components/frontend/templates/index.html b/homeassistant/components/frontend/templates/index.html index 859b2af53f0..afa9ca68af9 100644 --- a/homeassistant/components/frontend/templates/index.html +++ b/homeassistant/components/frontend/templates/index.html @@ -4,7 +4,7 @@ Home Assistant - + diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index f7c483b3d71..5fce36f45b1 100644 --- a/homeassistant/components/frontend/version.py +++ b/homeassistant/components/frontend/version.py @@ -1,9 +1,9 @@ """DO NOT MODIFY. Auto-generated by script/fingerprint_frontend.""" FINGERPRINTS = { - "core.js": "457d5acd123e7dc38947c07984b3a5e8", - "frontend.html": "829ee7cb591b8a63d7f22948a7aeb07a", - "mdi.html": "b399b5d3798f5b68b0a4fbaae3432d48", + "core.js": "1fd10c1fcdf56a61f60cf861d5a0368c", + "frontend.html": "88c97d278de3320278da6c32fe9e7d61", + "mdi.html": "710b84acc99b32514f52291aba9cd8e8", "panels/ha-panel-dev-event.html": "3cc881ae8026c0fba5aa67d334a3ab2b", "panels/ha-panel-dev-info.html": "34e2df1af32e60fffcafe7e008a92169", "panels/ha-panel-dev-service.html": "bb5c587ada694e0fd42ceaaedd6fe6aa", diff --git a/homeassistant/components/frontend/www_static/core.js b/homeassistant/components/frontend/www_static/core.js index beb5b8c03e0..336aab04ffe 100644 --- a/homeassistant/components/frontend/www_static/core.js +++ b/homeassistant/components/frontend/www_static/core.js @@ -1,4 +1,4 @@ -!function(){"use strict";function t(t){return t&&"object"==typeof t&&"default"in t?t.default:t}function e(t,e){return e={exports:{}},t(e,e.exports),e.exports}function n(t,e){var n=e.authToken,r=e.host;return De({authToken:n,host:r,isValidating:!0,isInvalid:!1,errorMessage:""})}function r(){return Ce.getInitialState()}function i(t,e){var n=e.errorMessage;return t.withMutations(function(t){return t.set("isValidating",!1).set("isInvalid",!0).set("errorMessage",n)})}function o(t,e){var n=e.authToken,r=e.host;return Re({authToken:n,host:r})}function u(){return Le.getInitialState()}function a(t,e){var n=e.rememberAuth;return n}function s(t){return t.withMutations(function(t){t.set("isStreaming",!0).set("useStreaming",!0).set("hasError",!1)})}function c(t){return t.withMutations(function(t){t.set("isStreaming",!1).set("useStreaming",!1).set("hasError",!1)})}function f(t){return t.withMutations(function(t){t.set("isStreaming",!1).set("hasError",!0)})}function h(){return Pe.getInitialState()}function l(t,e){var n=e.model,r=e.result,i=e.params,o=n.entity;if(!r)return t;var u=i.replace?Be({}):t.get(o),a=Array.isArray(r)?r:[r],s=n.fromJSON||Be;return t.set(o,u.withMutations(function(t){for(var e=0;e199&&u.status<300?t(e):n(e)},u.onerror=function(){return n({})},r?u.send(JSON.stringify(r)):u.send()})}function A(t,e){var n=e.message;return t.set(t.size,n)}function D(){return In.getInitialState()}function C(t,e){t.dispatch(gn.NOTIFICATION_CREATED,{message:e})}function z(t){t.registerStores({notifications:In})}function R(t,e){if("lock"===t)return!0;if("garage_door"===t)return!0;var n=e.get(t);return!!n&&n.services.has("turn_on")}function L(t,e){return!!t&&("group"===t.domain?"on"===t.state||"off"===t.state:R(t.domain,e))}function M(t,e){return[Wn(t),function(t){return!!t&&t.services.has(e)}]}function j(t){return[yn.byId(t),Jn,L]}function N(t,e,n){function r(){var c=(new Date).getTime()-a;c0?i=setTimeout(r,e-c):(i=null,n||(s=t.apply(u,o),i||(u=o=null)))}var i,o,u,a,s;null==e&&(e=100);var c=function(){u=this,o=arguments,a=(new Date).getTime();var c=n&&!i;return i||(i=setTimeout(r,e)),c&&(s=t.apply(u,o),u=o=null),s};return c.clear=function(){i&&(clearTimeout(i),i=null)},c}function k(t,e){var n=e.component;return t.push(n)}function U(t,e){var n=e.components;return ar(n)}function H(){return sr.getInitialState()}function P(t,e){var n=e.latitude,r=e.longitude,i=e.location_name,o=e.temperature_unit,u=e.time_zone,a=e.version;return fr({latitude:n,longitude:r,location_name:i,temperature_unit:o,time_zone:u,serverVersion:a})}function x(){return hr.getInitialState()}function V(t,e){t.dispatch(or.SERVER_CONFIG_LOADED,e)}function q(t){rn(t,"GET","config").then(function(e){return V(t,e)})}function F(t,e){t.dispatch(or.COMPONENT_LOADED,{component:e})}function G(t){return[["serverComponent"],function(e){return e.contains(t)}]}function K(t){t.registerStores({serverComponent:sr,serverConfig:hr})}function Y(t,e){var n=e.pane;return n}function B(){return Ir.getInitialState()}function J(t,e){var n=e.panels;return Or(n)}function W(){return wr.getInitialState()}function X(t,e){var n=e.show;return!!n}function Q(){return Ar.getInitialState()}function Z(t,e){t.dispatch(mr.SHOW_SIDEBAR,{show:e})}function $(t,e){t.dispatch(mr.NAVIGATE,{pane:e})}function tt(t,e){t.dispatch(mr.PANELS_LOADED,{panels:e})}function et(t,e){var n=e.entityId;return n}function nt(){return kr.getInitialState()}function rt(t,e){t.dispatch(jr.SELECT_ENTITY,{entityId:e})}function it(t){t.dispatch(jr.SELECT_ENTITY,{entityId:null})}function ot(t){return!t||(new Date).getTime()-t>6e4}function ut(t,e){var n=e.date;return n.toISOString()}function at(){return xr.getInitialState()}function st(t,e){var n=e.date,r=e.stateHistory;return 0===r.length?t.set(n,qr({})):t.withMutations(function(t){r.forEach(function(e){return t.setIn([n,e[0].entity_id],qr(e.map(cn.fromJSON)))})})}function ct(){return Fr.getInitialState()}function ft(t,e){var n=e.stateHistory;return t.withMutations(function(t){n.forEach(function(e){return t.set(e[0].entity_id,Br(e.map(cn.fromJSON)))})})}function ht(){return Jr.getInitialState()}function lt(t,e){var n=e.stateHistory,r=(new Date).getTime();return t.withMutations(function(t){n.forEach(function(e){return t.set(e[0].entity_id,r)}),history.length>1&&t.set(Qr,r)})}function pt(){return Zr.getInitialState()}function _t(t,e){t.dispatch(Hr.ENTITY_HISTORY_DATE_SELECTED,{date:e})}function dt(t,e){void 0===e&&(e=null),t.dispatch(Hr.RECENT_ENTITY_HISTORY_FETCH_START,{});var n="history/period";return null!==e&&(n+="?filter_entity_id="+e),rn(t,"GET",n).then(function(e){return t.dispatch(Hr.RECENT_ENTITY_HISTORY_FETCH_SUCCESS,{stateHistory:e})},function(){return t.dispatch(Hr.RECENT_ENTITY_HISTORY_FETCH_ERROR,{})})}function vt(t,e){return t.dispatch(Hr.ENTITY_HISTORY_FETCH_START,{date:e}),rn(t,"GET","history/period/"+e).then(function(n){return t.dispatch(Hr.ENTITY_HISTORY_FETCH_SUCCESS,{date:e,stateHistory:n})},function(){return t.dispatch(Hr.ENTITY_HISTORY_FETCH_ERROR,{})})}function yt(t){var e=t.evaluate(ei);return vt(t,e)}function St(t){t.registerStores({currentEntityHistoryDate:xr,entityHistory:Fr,isLoadingEntityHistory:Kr,recentEntityHistory:Jr,recentEntityHistoryUpdated:Zr})}function gt(t){t.registerStores({moreInfoEntityId:kr})}function mt(t,e){var n=e.model,r=e.result,i=e.params;if(null===t||"entity"!==n.entity||!i.replace)return t;for(var o=0;oQo}function ae(t){t.registerStores({currentLogbookDate:Uo,isLoadingLogbookEntries:Po,logbookEntries:Ko,logbookEntriesUpdated:Jo})}function se(t,e){return rn(t,"POST","template",{template:e})}function ce(t){return t.set("isListening",!0)}function fe(t,e){var n=e.interimTranscript,r=e.finalTranscript;return t.withMutations(function(t){return t.set("isListening",!0).set("isTransmitting",!1).set("interimTranscript",n).set("finalTranscript",r)})}function he(t,e){var n=e.finalTranscript;return t.withMutations(function(t){return t.set("isListening",!1).set("isTransmitting",!0).set("interimTranscript","").set("finalTranscript",n)})}function le(){return _u.getInitialState()}function pe(){return _u.getInitialState()}function _e(){return _u.getInitialState()}function de(t){return du[t.hassId]}function ve(t){var e=de(t);if(e){var n=e.finalTranscript||e.interimTranscript;t.dispatch(hu.VOICE_TRANSMITTING,{finalTranscript:n}),Zn.callService(t,"conversation","process",{text:n}).then(function(){t.dispatch(hu.VOICE_DONE)},function(){t.dispatch(hu.VOICE_ERROR)})}}function ye(t){var e=de(t);e&&(e.recognition.stop(),du[t.hassId]=!1)}function Se(t){ve(t),ye(t)}function ge(t){var e=Se.bind(null,t);e();var n=new webkitSpeechRecognition;du[t.hassId]={recognition:n,interimTranscript:"",finalTranscript:""},n.interimResults=!0,n.onstart=function(){return t.dispatch(hu.VOICE_START)},n.onerror=function(){return t.dispatch(hu.VOICE_ERROR)},n.onend=e,n.onresult=function(e){var n=de(t);if(n){for(var r="",i="",o=e.resultIndex;o=n)}function c(t,e){return h(t,e,0)}function f(t,e){return h(t,e,e)}function h(t,e,n){return void 0===t?n:t<0?Math.max(0,e+t):void 0===e?t:Math.min(e,t)}function l(t){return v(t)?t:C(t)}function p(t){return y(t)?t:z(t)}function _(t){return S(t)?t:R(t)}function d(t){return v(t)&&!g(t)?t:L(t)}function v(t){return!(!t||!t[dn])}function y(t){return!(!t||!t[vn])}function S(t){return!(!t||!t[yn])}function g(t){return y(t)||S(t)}function m(t){return!(!t||!t[Sn])}function E(t){this.next=t}function I(t,e,n,r){var i=0===t?e:1===t?n:[e,n];return r?r.value=i:r={value:i,done:!1},r}function b(){return{value:void 0,done:!0}}function O(t){return!!A(t)}function w(t){return t&&"function"==typeof t.next}function T(t){var e=A(t);return e&&e.call(t)}function A(t){var e=t&&(In&&t[In]||t[bn]);if("function"==typeof e)return e}function D(t){return t&&"number"==typeof t.length}function C(t){return null===t||void 0===t?H():v(t)?t.toSeq():V(t)}function z(t){return null===t||void 0===t?H().toKeyedSeq():v(t)?y(t)?t.toSeq():t.fromEntrySeq():P(t)}function R(t){return null===t||void 0===t?H():v(t)?y(t)?t.entrySeq():t.toIndexedSeq():x(t)}function L(t){return(null===t||void 0===t?H():v(t)?y(t)?t.entrySeq():t:x(t)).toSetSeq()}function M(t){this._array=t,this.size=t.length}function j(t){var e=Object.keys(t);this._object=t,this._keys=e,this.size=e.length}function N(t){this._iterable=t,this.size=t.length||t.size}function k(t){this._iterator=t,this._iteratorCache=[]}function U(t){return!(!t||!t[wn])}function H(){return Tn||(Tn=new M([]))}function P(t){var e=Array.isArray(t)?new M(t).fromEntrySeq():w(t)?new k(t).fromEntrySeq():O(t)?new N(t).fromEntrySeq():"object"==typeof t?new j(t):void 0;if(!e)throw new TypeError("Expected Array or iterable object of [k, v] entries, or keyed object: "+t);return e}function x(t){var e=q(t);if(!e)throw new TypeError("Expected Array or iterable object of values: "+t);return e}function V(t){var e=q(t)||"object"==typeof t&&new j(t);if(!e)throw new TypeError("Expected Array or iterable object of values, or keyed object: "+t);return e}function q(t){return D(t)?new M(t):w(t)?new k(t):O(t)?new N(t):void 0}function F(t,e,n,r){var i=t._cache;if(i){for(var o=i.length-1,u=0;u<=o;u++){var a=i[n?o-u:u];if(e(a[1],r?a[0]:u,t)===!1)return u+1}return u}return t.__iterateUncached(e,n)}function G(t,e,n,r){var i=t._cache;if(i){var o=i.length-1,u=0;return new E(function(){var t=i[n?o-u:u];return u++>o?b():I(e,r?t[0]:u-1,t[1])})}return t.__iteratorUncached(e,n)}function K(){throw TypeError("Abstract")}function Y(){}function B(){}function J(){}function W(t,e){if(t===e||t!==t&&e!==e)return!0;if(!t||!e)return!1;if("function"==typeof t.valueOf&&"function"==typeof e.valueOf){if(t=t.valueOf(),e=e.valueOf(),t===e||t!==t&&e!==e)return!0;if(!t||!e)return!1}return!("function"!=typeof t.equals||"function"!=typeof e.equals||!t.equals(e))}function X(t,e){return e?Q(e,t,"",{"":t}):Z(t)}function Q(t,e,n,r){return Array.isArray(e)?t.call(r,n,R(e).map(function(n,r){return Q(t,n,r,e)})):$(e)?t.call(r,n,z(e).map(function(n,r){return Q(t,n,r,e)})):e}function Z(t){return Array.isArray(t)?R(t).map(Z).toList():$(t)?z(t).map(Z).toMap():t}function $(t){return t&&(t.constructor===Object||void 0===t.constructor)}function tt(t){return t>>>1&1073741824|3221225471&t}function et(t){if(t===!1||null===t||void 0===t)return 0;if("function"==typeof t.valueOf&&(t=t.valueOf(),t===!1||null===t||void 0===t))return 0;if(t===!0)return 1;var e=typeof t;if("number"===e){var n=0|t;for(n!==t&&(n^=4294967295*t);t>4294967295;)t/=4294967295,n^=t;return tt(n)}return"string"===e?t.length>jn?nt(t):rt(t):"function"==typeof t.hashCode?t.hashCode():it(t)}function nt(t){var e=Un[t];return void 0===e&&(e=rt(t),kn===Nn&&(kn=0,Un={}),kn++,Un[t]=e),e}function rt(t){for(var e=0,n=0;n0)switch(t.nodeType){case 1:return t.uniqueID;case 9:return t.documentElement&&t.documentElement.uniqueID}}function ut(t,e){if(!t)throw new Error(e)}function at(t){ut(t!==1/0,"Cannot perform this action with an infinite size.")}function st(t,e){this._iter=t,this._useKeys=e,this.size=t.size}function ct(t){this._iter=t,this.size=t.size}function ft(t){this._iter=t,this.size=t.size}function ht(t){this._iter=t,this.size=t.size}function lt(t){var e=Mt(t);return e._iter=t,e.size=t.size,e.flip=function(){return t},e.reverse=function(){var e=t.reverse.apply(this);return e.flip=function(){return t.reverse()},e},e.has=function(e){return t.includes(e)},e.includes=function(e){return t.has(e)},e.cacheResult=jt,e.__iterateUncached=function(e,n){var r=this;return t.__iterate(function(t,n){return e(n,t,r)!==!1},n)},e.__iteratorUncached=function(e,n){if(e===En){var r=t.__iterator(e,n);return new E(function(){var t=r.next();if(!t.done){var e=t.value[0];t.value[0]=t.value[1],t.value[1]=e}return t})}return t.__iterator(e===mn?gn:mn,n)},e}function pt(t,e,n){var r=Mt(t);return r.size=t.size,r.has=function(e){return t.has(e)},r.get=function(r,i){var o=t.get(r,ln);return o===ln?i:e.call(n,o,r,t)},r.__iterateUncached=function(r,i){var o=this;return t.__iterate(function(t,i,u){return r(e.call(n,t,i,u),i,o)!==!1},i)},r.__iteratorUncached=function(r,i){var o=t.__iterator(En,i);return new E(function(){var i=o.next();if(i.done)return i;var u=i.value,a=u[0];return I(r,a,e.call(n,u[1],a,t),i)})},r}function _t(t,e){var n=Mt(t);return n._iter=t,n.size=t.size,n.reverse=function(){return t},t.flip&&(n.flip=function(){var e=lt(t);return e.reverse=function(){return t.flip()},e}),n.get=function(n,r){return t.get(e?n:-1-n,r)},n.has=function(n){return t.has(e?n:-1-n)},n.includes=function(e){return t.includes(e)},n.cacheResult=jt,n.__iterate=function(e,n){var r=this;return t.__iterate(function(t,n){return e(t,n,r)},!n)},n.__iterator=function(e,n){return t.__iterator(e,!n)},n}function dt(t,e,n,r){var i=Mt(t);return r&&(i.has=function(r){var i=t.get(r,ln);return i!==ln&&!!e.call(n,i,r,t)},i.get=function(r,i){var o=t.get(r,ln);return o!==ln&&e.call(n,o,r,t)?o:i}),i.__iterateUncached=function(i,o){var u=this,a=0;return t.__iterate(function(t,o,s){if(e.call(n,t,o,s))return a++,i(t,r?o:a-1,u)},o),a},i.__iteratorUncached=function(i,o){var u=t.__iterator(En,o),a=0;return new E(function(){for(;;){var o=u.next();if(o.done)return o;var s=o.value,c=s[0],f=s[1];if(e.call(n,f,c,t))return I(i,r?c:a++,f,o)}})},i}function vt(t,e,n){var r=Ut().asMutable();return t.__iterate(function(i,o){r.update(e.call(n,i,o,t),0,function(t){return t+1})}),r.asImmutable()}function yt(t,e,n){var r=y(t),i=(m(t)?be():Ut()).asMutable();t.__iterate(function(o,u){i.update(e.call(n,o,u,t),function(t){return t=t||[],t.push(r?[u,o]:o),t})});var o=Lt(t);return i.map(function(e){return Ct(t,o(e))})}function St(t,e,n,r){var i=t.size;if(void 0!==e&&(e=0|e),void 0!==n&&(n=0|n),s(e,n,i))return t;var o=c(e,i),a=f(n,i);if(o!==o||a!==a)return St(t.toSeq().cacheResult(),e,n,r);var h,l=a-o;l===l&&(h=l<0?0:l);var p=Mt(t);return p.size=0===h?h:t.size&&h||void 0,!r&&U(t)&&h>=0&&(p.get=function(e,n){return e=u(this,e),e>=0&&eh)return b();var t=i.next();return r||e===mn?t:e===gn?I(e,a-1,void 0,t):I(e,a-1,t.value[1],t)})},p}function gt(t,e,n){var r=Mt(t);return r.__iterateUncached=function(r,i){var o=this;if(i)return this.cacheResult().__iterate(r,i);var u=0;return t.__iterate(function(t,i,a){return e.call(n,t,i,a)&&++u&&r(t,i,o)}),u},r.__iteratorUncached=function(r,i){var o=this;if(i)return this.cacheResult().__iterator(r,i);var u=t.__iterator(En,i),a=!0;return new E(function(){if(!a)return b();var t=u.next();if(t.done)return t;var i=t.value,s=i[0],c=i[1];return e.call(n,c,s,o)?r===En?t:I(r,s,c,t):(a=!1,b())})},r}function mt(t,e,n,r){var i=Mt(t);return i.__iterateUncached=function(i,o){var u=this;if(o)return this.cacheResult().__iterate(i,o);var a=!0,s=0;return t.__iterate(function(t,o,c){if(!a||!(a=e.call(n,t,o,c)))return s++,i(t,r?o:s-1,u)}),s},i.__iteratorUncached=function(i,o){var u=this;if(o)return this.cacheResult().__iterator(i,o);var a=t.__iterator(En,o),s=!0,c=0;return new E(function(){var t,o,f;do{if(t=a.next(),t.done)return r||i===mn?t:i===gn?I(i,c++,void 0,t):I(i,c++,t.value[1],t);var h=t.value;o=h[0],f=h[1],s&&(s=e.call(n,f,o,u))}while(s);return i===En?t:I(i,o,f,t)})},i}function Et(t,e){var n=y(t),r=[t].concat(e).map(function(t){return v(t)?n&&(t=p(t)):t=n?P(t):x(Array.isArray(t)?t:[t]),t}).filter(function(t){return 0!==t.size});if(0===r.length)return t;if(1===r.length){var i=r[0];if(i===t||n&&y(i)||S(t)&&S(i))return i}var o=new M(r);return n?o=o.toKeyedSeq():S(t)||(o=o.toSetSeq()),o=o.flatten(!0),o.size=r.reduce(function(t,e){if(void 0!==t){var n=e.size;if(void 0!==n)return t+n}},0),o}function It(t,e,n){var r=Mt(t);return r.__iterateUncached=function(r,i){function o(t,s){var c=this;t.__iterate(function(t,i){return(!e||s0}function Dt(t,e,n){var r=Mt(t);return r.size=new M(n).map(function(t){return t.size}).min(),r.__iterate=function(t,e){for(var n,r=this,i=this.__iterator(mn,e),o=0;!(n=i.next()).done&&t(n.value,o++,r)!==!1;);return o},r.__iteratorUncached=function(t,r){var i=n.map(function(t){return t=l(t),T(r?t.reverse():t)}),o=0,u=!1;return new E(function(){var n;return u||(n=i.map(function(t){return t.next()}),u=n.some(function(t){return t.done})),u?b():I(t,o++,e.apply(null,n.map(function(t){return t.value})))})},r}function Ct(t,e){return U(t)?e:t.constructor(e)}function zt(t){if(t!==Object(t))throw new TypeError("Expected [K, V] tuple: "+t)}function Rt(t){return at(t.size),o(t)}function Lt(t){return y(t)?p:S(t)?_:d}function Mt(t){return Object.create((y(t)?z:S(t)?R:L).prototype)}function jt(){return this._iter.cacheResult?(this._iter.cacheResult(),this.size=this._iter.size,this):C.prototype.cacheResult.call(this)}function Nt(t,e){return t>e?1:t>>n)&hn,a=(0===n?r:r>>>n)&hn,s=u===a?[Zt(t,e,n+cn,r,i)]:(o=new Ft(e,r,i),u>>=1)u[a]=1&n?e[o++]:void 0;return u[r]=i,new Vt(t,o+1,u)}function ne(t,e,n){for(var r=[],i=0;i>1&1431655765,t=(858993459&t)+(t>>2&858993459),t=t+(t>>4)&252645135,t+=t>>8,t+=t>>16,127&t}function ae(t,e,n,r){var o=r?t:i(t);return o[e]=n,o}function se(t,e,n,r){var i=t.length+1;if(r&&e+1===i)return t[e]=n,t;for(var o=new Array(i),u=0,a=0;a0&&ro?0:o-n,c=u-n;return c>fn&&(c=fn),function(){if(i===c)return Bn;var t=e?--c:i++;return r&&r[t]}}function i(t,r,i){var a,s=t&&t.array,c=i>o?0:o-i>>r,f=(u-i>>r)+1;return f>fn&&(f=fn),function(){for(;;){if(a){var t=a();if(t!==Bn)return t;a=null}if(c===f)return Bn;var o=e?--f:c++;a=n(s&&s[o],r-cn,i+(o<=t.size||n<0)return t.withMutations(function(t){n<0?me(t,n).set(0,r):me(t,0,n+1).set(n,r)});n+=t._origin;var i=t._tail,o=t._root,a=e(_n);return n>=Ie(t._capacity)?i=ye(i,t.__ownerID,0,n,r,a):o=ye(o,t.__ownerID,t._level,n,r,a),a.value?t.__ownerID?(t._root=o,t._tail=i,t.__hash=void 0,t.__altered=!0,t):_e(t._origin,t._capacity,t._level,o,i):t}function ye(t,e,r,i,o,u){var a=i>>>r&hn,s=t&&a0){var f=t&&t.array[a],h=ye(f,e,r-cn,i,o,u);return h===f?t:(c=Se(t,e),c.array[a]=h,c)}return s&&t.array[a]===o?t:(n(u),c=Se(t,e),void 0===o&&a===c.array.length-1?c.array.pop():c.array[a]=o,c)}function Se(t,e){return e&&t&&e===t.ownerID?t:new le(t?t.array.slice():[],e)}function ge(t,e){if(e>=Ie(t._capacity))return t._tail;if(e<1<0;)n=n.array[e>>>r&hn],r-=cn;return n}}function me(t,e,n){void 0!==e&&(e=0|e),void 0!==n&&(n=0|n);var i=t.__ownerID||new r,o=t._origin,u=t._capacity,a=o+e,s=void 0===n?u:n<0?u+n:o+n;if(a===o&&s===u)return t;if(a>=s)return t.clear();for(var c=t._level,f=t._root,h=0;a+h<0;)f=new le(f&&f.array.length?[void 0,f]:[],i),c+=cn,h+=1<=1<l?new le([],i):_;if(_&&p>l&&acn;y-=cn){var S=l>>>y&hn;v=v.array[S]=Se(v.array[S],i)}v.array[l>>>cn&hn]=_}if(s=p)a-=p,s-=p,c=cn,f=null,d=d&&d.removeBefore(i,0,a);else if(a>o||p>>c&hn;if(g!==p>>>c&hn)break;g&&(h+=(1<o&&(f=f.removeBefore(i,c,a-h)),f&&pi&&(i=a.size),v(u)||(a=a.map(function(t){return X(t)})),r.push(a)}return i>t.size&&(t=t.setSize(i)),ie(t,e,r)}function Ie(t){return t>>cn<=fn&&u.size>=2*o.size?(i=u.filter(function(t,e){return void 0!==t&&a!==e}),r=i.toKeyedSeq().map(function(t){return t[0]}).flip().toMap(),t.__ownerID&&(r.__ownerID=i.__ownerID=t.__ownerID)):(r=o.remove(e),i=a===u.size-1?u.pop():u.set(a,void 0))}else if(s){if(n===u.get(a)[1])return t;r=o,i=u.set(a,[e,n])}else r=o.set(e,u.size),i=u.set(u.size,[e,n]);return t.__ownerID?(t.size=r.size,t._map=r,t._list=i,t.__hash=void 0,t):we(r,i)}function De(t){return null===t||void 0===t?Re():Ce(t)?t:Re().unshiftAll(t)}function Ce(t){return!(!t||!t[Wn])}function ze(t,e,n,r){var i=Object.create(Xn);return i.size=t,i._head=e,i.__ownerID=n,i.__hash=r,i.__altered=!1,i}function Re(){return Qn||(Qn=ze(0))}function Le(t){return null===t||void 0===t?ke():Me(t)&&!m(t)?t:ke().withMutations(function(e){var n=d(t);at(n.size),n.forEach(function(t){return e.add(t)})})}function Me(t){return!(!t||!t[Zn])}function je(t,e){return t.__ownerID?(t.size=e.size,t._map=e,t):e===t._map?t:0===e.size?t.__empty():t.__make(e)}function Ne(t,e){var n=Object.create($n);return n.size=t?t.size:0,n._map=t,n.__ownerID=e,n}function ke(){return tr||(tr=Ne(Jt()))}function Ue(t){return null===t||void 0===t?xe():He(t)?t:xe().withMutations(function(e){var n=d(t);at(n.size),n.forEach(function(t){return e.add(t)})})}function He(t){return Me(t)&&m(t)}function Pe(t,e){var n=Object.create(er);return n.size=t?t.size:0,n._map=t,n.__ownerID=e,n}function xe(){return nr||(nr=Pe(Te()))}function Ve(t,e){var n,r=function(o){if(o instanceof r)return o;if(!(this instanceof r))return new r(o);if(!n){n=!0;var u=Object.keys(t);Ge(i,u),i.size=u.length,i._name=e,i._keys=u,i._defaultValues=t}this._map=Ut(o)},i=r.prototype=Object.create(rr);return i.constructor=r,r}function qe(t,e,n){var r=Object.create(Object.getPrototypeOf(t));return r._map=e,r.__ownerID=n,r}function Fe(t){return t._name||t.constructor.name||"Record"}function Ge(t,e){try{e.forEach(Ke.bind(void 0,t))}catch(t){}}function Ke(t,e){Object.defineProperty(t,e,{get:function(){return this.get(e)},set:function(t){ut(this.__ownerID,"Cannot set on an immutable record."),this.set(e,t)}})}function Ye(t,e){if(t===e)return!0;if(!v(e)||void 0!==t.size&&void 0!==e.size&&t.size!==e.size||void 0!==t.__hash&&void 0!==e.__hash&&t.__hash!==e.__hash||y(t)!==y(e)||S(t)!==S(e)||m(t)!==m(e))return!1;if(0===t.size&&0===e.size)return!0;var n=!g(t);if(m(t)){var r=t.entries();return e.every(function(t,e){var i=r.next().value;return i&&W(i[1],t)&&(n||W(i[0],e))})&&r.next().done}var i=!1;if(void 0===t.size)if(void 0===e.size)"function"==typeof t.cacheResult&&t.cacheResult();else{i=!0;var o=t;t=e,e=o}var u=!0,a=e.__iterate(function(e,r){if(n?!t.has(e):i?!W(e,t.get(r,ln)):!W(t.get(r,ln),e))return u=!1,!1});return u&&t.size===a}function Be(t,e,n){if(!(this instanceof Be))return new Be(t,e,n);if(ut(0!==n,"Cannot step a Range by 0"),t=t||0,void 0===e&&(e=1/0),n=void 0===n?1:Math.abs(n),ee?-1:0}function rn(t){if(t.size===1/0)return 0;var e=m(t),n=y(t),r=e?1:0,i=t.__iterate(n?e?function(t,e){r=31*r+un(et(t),et(e))|0}:function(t,e){r=r+un(et(t),et(e))|0}:e?function(t){r=31*r+et(t)|0}:function(t){r=r+et(t)|0});return on(i,r)}function on(t,e){return e=Dn(e,3432918353),e=Dn(e<<15|e>>>-15,461845907),e=Dn(e<<13|e>>>-13,5),e=(e+3864292196|0)^t,e=Dn(e^e>>>16,2246822507),e=Dn(e^e>>>13,3266489909),e=tt(e^e>>>16)}function un(t,e){return t^e+2654435769+(t<<6)+(t>>2)|0}var an=Array.prototype.slice,sn="delete",cn=5,fn=1<r?b():I(t,i,n[e?r-i++:i++])})},t(j,z),j.prototype.get=function(t,e){return void 0===e||this.has(t)?this._object[t]:e},j.prototype.has=function(t){return this._object.hasOwnProperty(t)},j.prototype.__iterate=function(t,e){for(var n=this,r=this._object,i=this._keys,o=i.length-1,u=0;u<=o;u++){var a=i[e?o-u:u];if(t(r[a],a,n)===!1)return u+1}return u},j.prototype.__iterator=function(t,e){var n=this._object,r=this._keys,i=r.length-1,o=0;return new E(function(){var u=r[e?i-o:o];return o++>i?b():I(t,u,n[u])})},j.prototype[Sn]=!0,t(N,R),N.prototype.__iterateUncached=function(t,e){var n=this;if(e)return this.cacheResult().__iterate(t,e);var r=this._iterable,i=T(r),o=0;if(w(i))for(var u;!(u=i.next()).done&&t(u.value,o++,n)!==!1;);return o},N.prototype.__iteratorUncached=function(t,e){if(e)return this.cacheResult().__iterator(t,e);var n=this._iterable,r=T(n);if(!w(r))return new E(b);var i=0;return new E(function(){var e=r.next();return e.done?e:I(t,i++,e.value)})},t(k,R),k.prototype.__iterateUncached=function(t,e){var n=this;if(e)return this.cacheResult().__iterate(t,e);for(var r=this._iterator,i=this._iteratorCache,o=0;o=r.length){var e=n.next();if(e.done)return e;r[i]=e.value}return I(t,i,r[i++])})};var Tn;t(K,l),t(Y,K),t(B,K),t(J,K),K.Keyed=Y,K.Indexed=B,K.Set=J;var An,Dn="function"==typeof Math.imul&&Math.imul(4294967295,2)===-2?Math.imul:function(t,e){t=0|t,e=0|e;var n=65535&t,r=65535&e;return n*r+((t>>>16)*r+n*(e>>>16)<<16>>>0)|0},Cn=Object.isExtensible,zn=function(){try{return Object.defineProperty({},"@",{}),!0}catch(t){return!1}}(),Rn="function"==typeof WeakMap;Rn&&(An=new WeakMap);var Ln=0,Mn="__immutablehash__";"function"==typeof Symbol&&(Mn=Symbol(Mn));var jn=16,Nn=255,kn=0,Un={};t(st,z),st.prototype.get=function(t,e){return this._iter.get(t,e)},st.prototype.has=function(t){return this._iter.has(t)},st.prototype.valueSeq=function(){return this._iter.valueSeq()},st.prototype.reverse=function(){var t=this,e=_t(this,!0);return this._useKeys||(e.valueSeq=function(){return t._iter.toSeq().reverse()}),e},st.prototype.map=function(t,e){var n=this,r=pt(this,t,e);return this._useKeys||(r.valueSeq=function(){return n._iter.toSeq().map(t,e)}),r},st.prototype.__iterate=function(t,e){var n,r=this;return this._iter.__iterate(this._useKeys?function(e,n){return t(e,n,r)}:(n=e?Rt(this):0,function(i){return t(i,e?--n:n++,r)}),e)},st.prototype.__iterator=function(t,e){if(this._useKeys)return this._iter.__iterator(t,e);var n=this._iter.__iterator(mn,e),r=e?Rt(this):0;return new E(function(){var i=n.next();return i.done?i:I(t,e?--r:r++,i.value,i)})},st.prototype[Sn]=!0,t(ct,R),ct.prototype.includes=function(t){return this._iter.includes(t)},ct.prototype.__iterate=function(t,e){var n=this,r=0;return this._iter.__iterate(function(e){return t(e,r++,n)},e)},ct.prototype.__iterator=function(t,e){var n=this._iter.__iterator(mn,e),r=0;return new E(function(){var e=n.next();return e.done?e:I(t,r++,e.value,e)})},t(ft,L),ft.prototype.has=function(t){return this._iter.includes(t)},ft.prototype.__iterate=function(t,e){var n=this;return this._iter.__iterate(function(e){return t(e,e,n)},e)},ft.prototype.__iterator=function(t,e){var n=this._iter.__iterator(mn,e);return new E(function(){var e=n.next();return e.done?e:I(t,e.value,e.value,e)})},t(ht,z),ht.prototype.entrySeq=function(){return this._iter.toSeq()},ht.prototype.__iterate=function(t,e){var n=this;return this._iter.__iterate(function(e){if(e){zt(e);var r=v(e);return t(r?e.get(1):e[1],r?e.get(0):e[0],n)}},e)},ht.prototype.__iterator=function(t,e){var n=this._iter.__iterator(mn,e);return new E(function(){for(;;){var e=n.next();if(e.done)return e;var r=e.value;if(r){zt(r);var i=v(r);return I(t,i?r.get(0):r[0],i?r.get(1):r[1],e)}}})},ct.prototype.cacheResult=st.prototype.cacheResult=ft.prototype.cacheResult=ht.prototype.cacheResult=jt,t(Ut,Y),Ut.prototype.toString=function(){return this.__toString("Map {","}")},Ut.prototype.get=function(t,e){return this._root?this._root.get(0,void 0,t,e):e},Ut.prototype.set=function(t,e){return Wt(this,t,e)},Ut.prototype.setIn=function(t,e){return this.updateIn(t,ln,function(){return e})},Ut.prototype.remove=function(t){return Wt(this,t,ln)},Ut.prototype.deleteIn=function(t){return this.updateIn(t,function(){return ln})},Ut.prototype.update=function(t,e,n){return 1===arguments.length?t(this):this.updateIn([t],e,n)},Ut.prototype.updateIn=function(t,e,n){n||(n=e,e=void 0);var r=oe(this,kt(t),e,n);return r===ln?void 0:r},Ut.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=0,this._root=null,this.__hash=void 0,this.__altered=!0,this):Jt()},Ut.prototype.merge=function(){return ne(this,void 0,arguments)},Ut.prototype.mergeWith=function(t){var e=an.call(arguments,1);return ne(this,t,e)},Ut.prototype.mergeIn=function(t){var e=an.call(arguments,1);return this.updateIn(t,Jt(),function(t){return"function"==typeof t.merge?t.merge.apply(t,e):e[e.length-1]})},Ut.prototype.mergeDeep=function(){return ne(this,re(void 0),arguments)},Ut.prototype.mergeDeepWith=function(t){var e=an.call(arguments,1);return ne(this,re(t),e)},Ut.prototype.mergeDeepIn=function(t){var e=an.call(arguments,1);return this.updateIn(t,Jt(),function(t){return"function"==typeof t.mergeDeep?t.mergeDeep.apply(t,e):e[e.length-1]})},Ut.prototype.sort=function(t){return be(wt(this,t))},Ut.prototype.sortBy=function(t,e){return be(wt(this,e,t))},Ut.prototype.withMutations=function(t){var e=this.asMutable();return t(e),e.wasAltered()?e.__ensureOwner(this.__ownerID):this},Ut.prototype.asMutable=function(){return this.__ownerID?this:this.__ensureOwner(new r)},Ut.prototype.asImmutable=function(){return this.__ensureOwner()},Ut.prototype.wasAltered=function(){return this.__altered},Ut.prototype.__iterator=function(t,e){return new Gt(this,t,e)},Ut.prototype.__iterate=function(t,e){var n=this,r=0;return this._root&&this._root.iterate(function(e){return r++,t(e[1],e[0],n)},e),r},Ut.prototype.__ensureOwner=function(t){return t===this.__ownerID?this:t?Bt(this.size,this._root,t,this.__hash):(this.__ownerID=t,this.__altered=!1,this)},Ut.isMap=Ht;var Hn="@@__IMMUTABLE_MAP__@@",Pn=Ut.prototype;Pn[Hn]=!0,Pn[sn]=Pn.remove,Pn.removeIn=Pn.deleteIn,Pt.prototype.get=function(t,e,n,r){for(var i=this.entries,o=0,u=i.length;o=Vn)return $t(t,f,o,u);var _=t&&t===this.ownerID,d=_?f:i(f);return p?c?h===l-1?d.pop():d[h]=d.pop():d[h]=[o,u]:d.push([o,u]),_?(this.entries=d,this):new Pt(t,d)}},xt.prototype.get=function(t,e,n,r){void 0===e&&(e=et(n));var i=1<<((0===t?e:e>>>t)&hn),o=this.bitmap;return 0===(o&i)?r:this.nodes[ue(o&i-1)].get(t+cn,e,n,r)},xt.prototype.update=function(t,e,n,r,i,o,u){void 0===n&&(n=et(r));var a=(0===e?n:n>>>e)&hn,s=1<=qn)return ee(t,l,c,a,_);if(f&&!_&&2===l.length&&Qt(l[1^h]))return l[1^h];if(f&&_&&1===l.length&&Qt(_))return _;var d=t&&t===this.ownerID,v=f?_?c:c^s:c|s,y=f?_?ae(l,h,_,d):ce(l,h,d):se(l,h,_,d);return d?(this.bitmap=v,this.nodes=y,this):new xt(t,v,y)},Vt.prototype.get=function(t,e,n,r){void 0===e&&(e=et(n));var i=(0===t?e:e>>>t)&hn,o=this.nodes[i];return o?o.get(t+cn,e,n,r):r},Vt.prototype.update=function(t,e,n,r,i,o,u){void 0===n&&(n=et(r));var a=(0===e?n:n>>>e)&hn,s=i===ln,c=this.nodes,f=c[a];if(s&&!f)return this;var h=Xt(f,t,e+cn,n,r,i,o,u);if(h===f)return this;var l=this.count;if(f){if(!h&&(l--,l=0&&t>>e&hn;if(r>=this.array.length)return new le([],t);var i,o=0===r;if(e>0){var u=this.array[r];if(i=u&&u.removeBefore(t,e-cn,n),i===u&&o)return this}if(o&&!i)return this;var a=Se(this,t);if(!o)for(var s=0;s>>e&hn;if(r>=this.array.length)return this;var i;if(e>0){var o=this.array[r];if(i=o&&o.removeAfter(t,e-cn,n),i===o&&r===this.array.length-1)return this}var u=Se(this,t);return u.array.splice(r+1),i&&(u.array[r]=i),u};var Yn,Bn={};t(be,Ut),be.of=function(){return this(arguments)},be.prototype.toString=function(){return this.__toString("OrderedMap {","}")},be.prototype.get=function(t,e){var n=this._map.get(t);return void 0!==n?this._list.get(n)[1]:e},be.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=0,this._map.clear(),this._list.clear(),this):Te()},be.prototype.set=function(t,e){return Ae(this,t,e)},be.prototype.remove=function(t){return Ae(this,t,ln)},be.prototype.wasAltered=function(){return this._map.wasAltered()||this._list.wasAltered()},be.prototype.__iterate=function(t,e){var n=this;return this._list.__iterate(function(e){return e&&t(e[1],e[0],n)},e)},be.prototype.__iterator=function(t,e){return this._list.fromEntrySeq().__iterator(t,e)},be.prototype.__ensureOwner=function(t){if(t===this.__ownerID)return this;var e=this._map.__ensureOwner(t),n=this._list.__ensureOwner(t);return t?we(e,n,t,this.__hash):(this.__ownerID=t,this._map=e,this._list=n,this)},be.isOrderedMap=Oe,be.prototype[Sn]=!0,be.prototype[sn]=be.prototype.remove;var Jn;t(De,B),De.of=function(){return this(arguments)},De.prototype.toString=function(){return this.__toString("Stack [","]")},De.prototype.get=function(t,e){var n=this._head;for(t=u(this,t);n&&t--;)n=n.next;return n?n.value:e},De.prototype.peek=function(){return this._head&&this._head.value},De.prototype.push=function(){var t=arguments;if(0===arguments.length)return this;for(var e=this.size+arguments.length,n=this._head,r=arguments.length-1;r>=0;r--)n={value:t[r],next:n};return this.__ownerID?(this.size=e,this._head=n,this.__hash=void 0,this.__altered=!0,this):ze(e,n)},De.prototype.pushAll=function(t){if(t=_(t),0===t.size)return this;at(t.size);var e=this.size,n=this._head;return t.reverse().forEach(function(t){e++,n={value:t,next:n}}),this.__ownerID?(this.size=e,this._head=n,this.__hash=void 0,this.__altered=!0,this):ze(e,n)},De.prototype.pop=function(){return this.slice(1)},De.prototype.unshift=function(){return this.push.apply(this,arguments)},De.prototype.unshiftAll=function(t){return this.pushAll(t)},De.prototype.shift=function(){return this.pop.apply(this,arguments)},De.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=0,this._head=void 0,this.__hash=void 0,this.__altered=!0,this):Re()},De.prototype.slice=function(t,e){if(s(t,e,this.size))return this;var n=c(t,this.size),r=f(e,this.size);if(r!==this.size)return B.prototype.slice.call(this,t,e);for(var i=this.size-n,o=this._head;n--;)o=o.next;return this.__ownerID?(this.size=i,this._head=o,this.__hash=void 0,this.__altered=!0,this):ze(i,o)},De.prototype.__ensureOwner=function(t){return t===this.__ownerID?this:t?ze(this.size,this._head,t,this.__hash):(this.__ownerID=t,this.__altered=!1,this)},De.prototype.__iterate=function(t,e){var n=this;if(e)return this.reverse().__iterate(t);for(var r=0,i=this._head;i&&t(i.value,r++,n)!==!1;)i=i.next;return r},De.prototype.__iterator=function(t,e){if(e)return this.reverse().__iterator(t);var n=0,r=this._head;return new E(function(){if(r){var e=r.value;return r=r.next,I(t,n++,e)}return b()})},De.isStack=Ce;var Wn="@@__IMMUTABLE_STACK__@@",Xn=De.prototype;Xn[Wn]=!0,Xn.withMutations=Pn.withMutations,Xn.asMutable=Pn.asMutable,Xn.asImmutable=Pn.asImmutable,Xn.wasAltered=Pn.wasAltered;var Qn;t(Le,J),Le.of=function(){return this(arguments)},Le.fromKeys=function(t){return this(p(t).keySeq())},Le.prototype.toString=function(){return this.__toString("Set {","}")},Le.prototype.has=function(t){return this._map.has(t)},Le.prototype.add=function(t){return je(this,this._map.set(t,!0))},Le.prototype.remove=function(t){return je(this,this._map.remove(t))},Le.prototype.clear=function(){return je(this,this._map.clear())},Le.prototype.union=function(){var t=an.call(arguments,0);return t=t.filter(function(t){return 0!==t.size}),0===t.length?this:0!==this.size||this.__ownerID||1!==t.length?this.withMutations(function(e){for(var n=0;n1?" by "+this._step:"")+" ]"},Be.prototype.get=function(t,e){return this.has(t)?this._start+u(this,t)*this._step:e},Be.prototype.includes=function(t){var e=(t-this._start)/this._step;return e>=0&&e=0&&nn?b():I(t,o++,u)})},Be.prototype.equals=function(t){return t instanceof Be?this._start===t._start&&this._end===t._end&&this._step===t._step:Ye(this,t)};var ir;t(Je,R),Je.prototype.toString=function(){return 0===this.size?"Repeat []":"Repeat [ "+this._value+" "+this.size+" times ]"},Je.prototype.get=function(t,e){return this.has(t)?this._value:e},Je.prototype.includes=function(t){return W(this._value,t)},Je.prototype.slice=function(t,e){var n=this.size;return s(t,e,n)?this:new Je(this._value,f(e,n)-c(t,n))},Je.prototype.reverse=function(){return this},Je.prototype.indexOf=function(t){return W(this._value,t)?0:-1},Je.prototype.lastIndexOf=function(t){return W(this._value,t)?this.size:-1},Je.prototype.__iterate=function(t,e){for(var n=this,r=0;rthis.size?e:this.find(function(e,n){return n===t},void 0,e)},has:function(t){return t=u(this,t),t>=0&&(void 0!==this.size?this.size===1/0||t-1&&t%1===0&&t<=Number.MAX_VALUE}var i=Function.prototype.bind;e.isString=function(t){return"string"==typeof t||"[object String]"===n(t)},e.isArray=Array.isArray||function(t){return"[object Array]"===n(t)},"function"!=typeof/./&&"object"!=typeof Int8Array?e.isFunction=function(t){return"function"==typeof t||!1}:e.isFunction=function(t){return"[object Function]"===toString.call(t)},e.isObject=function(t){var e=typeof t;return"function"===e||"object"===e&&!!t},e.extend=function(t){var e=arguments,n=arguments.length;if(!t||n<2)return t||{};for(var r=1;r0)){var e=this.reactorState.get("dirtyStores");if(0!==e.size){var n=c.default.Set().withMutations(function(n){n.union(t.observerState.get("any")),e.forEach(function(e){var r=t.observerState.getIn(["stores",e]);r&&n.union(r)})});n.forEach(function(e){var n=t.observerState.getIn(["observersMap",e]);if(n){var r=n.get("getter"),i=n.get("handler"),o=p.evaluate(t.prevReactorState,r),u=p.evaluate(t.reactorState,r);t.prevReactorState=o.reactorState,t.reactorState=u.reactorState;var a=o.result,s=u.result;c.default.is(a,s)||i.call(null,s)}});var r=p.resetDirtyStores(this.reactorState);this.prevReactorState=r,this.reactorState=r}}}},{key:"batchStart",value:function(){this.__batchDepth++}},{key:"batchEnd",value:function(){if(this.__batchDepth--,this.__batchDepth<=0){this.__isDispatching=!0;try{this.__notify()}catch(t){throw this.__isDispatching=!1,t}this.__isDispatching=!1}}}]),t}();e.default=(0,y.toFactory)(g),t.exports=e.default},function(t,e,n){function r(t,e,n){return e in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function i(t,e){var n={};return(0,o.each)(e,function(e,r){n[r]=t.evaluate(e)}),n}Object.defineProperty(e,"__esModule",{value:!0});var o=n(4);e.default=function(t){return{getInitialState:function(){return i(t,this.getDataBindings())},componentDidMount:function(){var e=this;this.__unwatchFns=[],(0,o.each)(this.getDataBindings(),function(n,i){var o=t.observe(n,function(t){e.setState(r({},i,t))});e.__unwatchFns.push(o)})},componentWillUnmount:function(){for(var t=this;this.__unwatchFns.length;)t.__unwatchFns.shift()()}}},t.exports=e.default},function(t,e,n){function r(t){return t&&t.__esModule?t:{default:t}}function i(t,e){return new L({result:t,reactorState:e})}function o(t,e){return t.withMutations(function(t){(0,R.each)(e,function(e,n){t.getIn(["stores",n])&&console.warn("Store already defined for id = "+n);var r=e.getInitialState();if(void 0===r&&f(t,"throwOnUndefinedStoreReturnValue"))throw new Error("Store getInitialState() must return a value, did you forget a return statement");if(f(t,"throwOnNonImmutableStore")&&!(0,D.isImmutableValue)(r))throw new Error("Store getInitialState() must return an immutable value, did you forget to call toImmutable");t.update("stores",function(t){return t.set(n,e)}).update("state",function(t){return t.set(n,r)}).update("dirtyStores",function(t){return t.add(n)}).update("storeStates",function(t){return b(t,[n])})}),I(t)})}function u(t,e){return t.withMutations(function(t){(0,R.each)(e,function(e,n){t.update("stores",function(t){return t.set(n,e)})})})}function a(t,e,n){if(void 0===e&&f(t,"throwOnUndefinedActionType"))throw new Error("`dispatch` cannot be called with an `undefined` action type.");var r=t.get("state"),i=t.get("dirtyStores"),o=r.withMutations(function(r){A.default.dispatchStart(t,e,n),t.get("stores").forEach(function(o,u){var a=r.get(u),s=void 0;try{s=o.handle(a,e,n)}catch(e){throw A.default.dispatchError(t,e.message),e}if(void 0===s&&f(t,"throwOnUndefinedStoreReturnValue")){var c="Store handler must return a value, did you forget a return statement";throw A.default.dispatchError(t,c),new Error(c)}r.set(u,s),a!==s&&(i=i.add(u))}),A.default.dispatchEnd(t,r,i)}),u=t.set("state",o).set("dirtyStores",i).update("storeStates",function(t){return b(t,i)});return I(u)}function s(t,e){var n=[],r=(0,D.toImmutable)({}).withMutations(function(r){(0,R.each)(e,function(e,i){var o=t.getIn(["stores",i]);if(o){var u=o.deserialize(e);void 0!==u&&(r.set(i,u),n.push(i))}})}),i=w.default.Set(n);return t.update("state",function(t){return t.merge(r)}).update("dirtyStores",function(t){return t.union(i)}).update("storeStates",function(t){return b(t,n)})}function c(t,e,n){var r=e;(0,z.isKeyPath)(e)&&(e=(0,C.fromKeyPath)(e));var i=t.get("nextId"),o=(0,C.getStoreDeps)(e),u=w.default.Map({id:i,storeDeps:o,getterKey:r,getter:e,handler:n}),a=void 0;return a=0===o.size?t.update("any",function(t){return t.add(i)}):t.withMutations(function(t){o.forEach(function(e){var n=["stores",e];t.hasIn(n)||t.setIn(n,w.default.Set()),t.updateIn(["stores",e],function(t){return t.add(i)})})}),a=a.set("nextId",i+1).setIn(["observersMap",i],u),{observerState:a,entry:u}}function f(t,e){var n=t.getIn(["options",e]);if(void 0===n)throw new Error("Invalid option: "+e);return n}function h(t,e,n){var r=t.get("observersMap").filter(function(t){var r=t.get("getterKey"),i=!n||t.get("handler")===n;return!!i&&((0,z.isKeyPath)(e)&&(0,z.isKeyPath)(r)?(0,z.isEqual)(e,r):e===r)});return t.withMutations(function(t){r.forEach(function(e){return l(t,e)})})}function l(t,e){return t.withMutations(function(t){var n=e.get("id"),r=e.get("storeDeps");0===r.size?t.update("any",function(t){return t.remove(n)}):r.forEach(function(e){t.updateIn(["stores",e],function(t){return t?t.remove(n):t})}),t.removeIn(["observersMap",n])})}function p(t){var e=t.get("state");return t.withMutations(function(t){var n=t.get("stores"),r=n.keySeq().toJS();n.forEach(function(n,r){var i=e.get(r),o=n.handleReset(i);if(void 0===o&&f(t,"throwOnUndefinedStoreReturnValue"))throw new Error("Store handleReset() must return a value, did you forget a return statement");if(f(t,"throwOnNonImmutableStore")&&!(0,D.isImmutableValue)(o))throw new Error("Store reset state must be an immutable value, did you forget to call toImmutable");t.setIn(["state",r],o)}),t.update("storeStates",function(t){return b(t,r)}),v(t)})}function _(t,e){var n=t.get("state");if((0,z.isKeyPath)(e))return i(n.getIn(e),t);if(!(0,C.isGetter)(e))throw new Error("evaluate must be passed a keyPath or Getter");if(g(t,e))return i(E(t,e),t);var r=(0,C.getDeps)(e).map(function(e){return _(t,e).result}),o=(0,C.getComputeFn)(e).apply(null,r);return i(o,m(t,e,o))}function d(t){var e={};return t.get("stores").forEach(function(n,r){var i=t.getIn(["state",r]),o=n.serialize(i);void 0!==o&&(e[r]=o)}),e}function v(t){return t.set("dirtyStores",w.default.Set())}function y(t){return t}function S(t,e){var n=y(e);return t.getIn(["cache",n])}function g(t,e){var n=S(t,e);if(!n)return!1;var r=n.get("storeStates");return 0!==r.size&&r.every(function(e,n){return t.getIn(["storeStates",n])===e})}function m(t,e,n){var r=y(e),i=t.get("dispatchId"),o=(0,C.getStoreDeps)(e),u=(0,D.toImmutable)({}).withMutations(function(e){o.forEach(function(n){var r=t.getIn(["storeStates",n]);e.set(n,r)})});return t.setIn(["cache",r],w.default.Map({value:n,storeStates:u,dispatchId:i}))}function E(t,e){var n=y(e);return t.getIn(["cache",n,"value"])}function I(t){return t.update("dispatchId",function(t){return t+1})}function b(t,e){return t.withMutations(function(t){e.forEach(function(e){var n=t.has(e)?t.get(e)+1:1;t.set(e,n)})})}Object.defineProperty(e,"__esModule",{value:!0}),e.registerStores=o,e.replaceStores=u,e.dispatch=a,e.loadState=s,e.addObserver=c,e.getOption=f,e.removeObserver=h,e.removeObserverByEntry=l,e.reset=p,e.evaluate=_,e.serialize=d,e.resetDirtyStores=v;var O=n(3),w=r(O),T=n(9),A=r(T),D=n(5),C=n(10),z=n(11),R=n(4),L=w.default.Record({result:null,reactorState:null})},function(t,e,n){var r=n(8);e.dispatchStart=function(t,e,n){(0,r.getOption)(t,"logDispatches")&&console.group&&(console.groupCollapsed("Dispatch: %s",e),console.group("payload"),console.debug(n),console.groupEnd())},e.dispatchError=function(t,e){(0,r.getOption)(t,"logDispatches")&&console.group&&(console.debug("Dispatch error: "+e),console.groupEnd())},e.dispatchEnd=function(t,e,n){(0,r.getOption)(t,"logDispatches")&&console.group&&((0,r.getOption)(t,"logDirtyStores")&&console.log("Stores updated:",n.toList().toJS()),(0,r.getOption)(t,"logAppState")&&console.debug("Dispatch done, new state: ",e.toJS()),console.groupEnd())}},function(t,e,n){function r(t){return t&&t.__esModule?t:{default:t}}function i(t){return(0,l.isArray)(t)&&(0,l.isFunction)(t[t.length-1])}function o(t){return t[t.length-1]}function u(t){return t.slice(0,t.length-1)}function a(t,e){e||(e=h.default.Set());var n=h.default.Set().withMutations(function(e){if(!i(t))throw new Error("getFlattenedDeps must be passed a Getter");u(t).forEach(function(t){if((0,p.isKeyPath)(t))e.add((0,f.List)(t));else{if(!i(t))throw new Error("Invalid getter, each dependency must be a KeyPath or Getter");e.union(a(t))}})});return e.union(n)}function s(t){if(!(0,p.isKeyPath)(t))throw new Error("Cannot create Getter from KeyPath: "+t);return[t,_]}function c(t){if(t.hasOwnProperty("__storeDeps"))return t.__storeDeps;var e=a(t).map(function(t){return t.first()}).filter(function(t){return!!t});return Object.defineProperty(t,"__storeDeps",{enumerable:!1,configurable:!1,writable:!1,value:e}),e}Object.defineProperty(e,"__esModule",{value:!0});var f=n(3),h=r(f),l=n(4),p=n(11),_=function(t){return t};e.default={isGetter:i,getComputeFn:o,getFlattenedDeps:a,getStoreDeps:c,getDeps:u,fromKeyPath:s},t.exports=e.default},function(t,e,n){function r(t){return t&&t.__esModule?t:{default:t}}function i(t){return(0,s.isArray)(t)&&!(0,s.isFunction)(t[t.length-1])}function o(t,e){var n=a.default.List(t),r=a.default.List(e);return a.default.is(n,r)}Object.defineProperty(e,"__esModule",{value:!0}),e.isKeyPath=i,e.isEqual=o;var u=n(3),a=r(u),s=n(4)},function(t,e,n){Object.defineProperty(e,"__esModule",{value:!0});var r=n(3),i=(0,r.Map)({logDispatches:!1,logAppState:!1,logDirtyStores:!1,throwOnUndefinedActionType:!1,throwOnUndefinedStoreReturnValue:!1,throwOnNonImmutableStore:!1,throwOnDispatchInDispatch:!1});e.PROD_OPTIONS=i;var o=(0,r.Map)({logDispatches:!0,logAppState:!0,logDirtyStores:!0,throwOnUndefinedActionType:!0,throwOnUndefinedStoreReturnValue:!0,throwOnNonImmutableStore:!0,throwOnDispatchInDispatch:!0});e.DEBUG_OPTIONS=o;var u=(0,r.Record)({dispatchId:0,state:(0,r.Map)(),stores:(0,r.Map)(),cache:(0,r.Map)(),storeStates:(0,r.Map)(),dirtyStores:(0,r.Set)(),debug:!1,options:i});e.ReactorState=u;var a=(0,r.Record)({any:(0,r.Set)(),stores:(0,r.Map)({}),observersMap:(0,r.Map)({}),nextId:1});e.ObserverState=a}])})}),be=t(Ie),Oe=e(function(t){var e=function(t){var e,n={};if(!(t instanceof Object)||Array.isArray(t))throw new Error("keyMirror(...): Argument must be an object.");for(e in t)t.hasOwnProperty(e)&&(n[e]=e);return n};t.exports=e}),we=t(Oe),Te=we({VALIDATING_AUTH_TOKEN:null,VALID_AUTH_TOKEN:null,INVALID_AUTH_TOKEN:null,LOG_OUT:null}),Ae=be.Store,De=be.toImmutable,Ce=new Ae({getInitialState:function(){return De({isValidating:!1,authToken:!1,host:null,isInvalid:!1,errorMessage:""})},initialize:function(){this.on(Te.VALIDATING_AUTH_TOKEN,n),this.on(Te.VALID_AUTH_TOKEN,r),this.on(Te.INVALID_AUTH_TOKEN,i)}}),ze=be.Store,Re=be.toImmutable,Le=new ze({getInitialState:function(){return Re({authToken:null,host:""})},initialize:function(){this.on(Te.VALID_AUTH_TOKEN,o),this.on(Te.LOG_OUT,u)}}),Me=be.Store,je=new Me({getInitialState:function(){return!0},initialize:function(){this.on(Te.VALID_AUTH_TOKEN,a)}}),Ne=we({STREAM_START:null,STREAM_STOP:null,STREAM_ERROR:null}),ke="object"==typeof window&&"EventSource"in window,Ue=be.Store,He=be.toImmutable,Pe=new Ue({getInitialState:function(){return He({isSupported:ke,isStreaming:!1,useStreaming:!0,hasError:!1})},initialize:function(){this.on(Ne.STREAM_START,s),this.on(Ne.STREAM_STOP,c),this.on(Ne.STREAM_ERROR,f),this.on(Ne.LOG_OUT,h)}}),xe=we({API_FETCH_ALL_START:null,API_FETCH_ALL_SUCCESS:null,API_FETCH_ALL_FAIL:null,SYNC_SCHEDULED:null,SYNC_SCHEDULE_CANCELLED:null}),Ve=be.Store,qe=new Ve({getInitialState:function(){return!0},initialize:function(){this.on(xe.API_FETCH_ALL_START,function(){return!0}),this.on(xe.API_FETCH_ALL_SUCCESS,function(){return!1}),this.on(xe.API_FETCH_ALL_FAIL,function(){return!1}),this.on(xe.LOG_OUT,function(){return!1})}}),Fe=be.Store,Ge=new Fe({getInitialState:function(){return!1},initialize:function(){this.on(xe.SYNC_SCHEDULED,function(){return!0}),this.on(xe.SYNC_SCHEDULE_CANCELLED,function(){return!1}),this.on(xe.LOG_OUT,function(){return!1})}}),Ke=we({API_FETCH_SUCCESS:null,API_FETCH_START:null,API_FETCH_FAIL:null,API_SAVE_SUCCESS:null,API_SAVE_START:null,API_SAVE_FAIL:null,API_DELETE_SUCCESS:null,API_DELETE_START:null,API_DELETE_FAIL:null,LOG_OUT:null}),Ye=be.Store,Be=be.toImmutable,Je=new Ye({getInitialState:function(){return Be({})},initialize:function(){var t=this;this.on(Ke.API_FETCH_SUCCESS,l),this.on(Ke.API_SAVE_SUCCESS,l),this.on(Ke.API_DELETE_SUCCESS,p),this.on(Ke.LOG_OUT,function(){return t.getInitialState()})}}),We=e(function(t){function e(t){if(null===t||void 0===t)throw new TypeError("Object.assign cannot be called with null or undefined");return Object(t)}function n(){try{if(!Object.assign)return!1;var t=new String("abc");if(t[5]="de","5"===Object.getOwnPropertyNames(t)[0])return!1;for(var e={},n=0;n<10;n++)e["_"+String.fromCharCode(n)]=n;var r=Object.getOwnPropertyNames(e).map(function(t){return e[t]});if("0123456789"!==r.join(""))return!1;var i={};return"abcdefghijklmnopqrst".split("").forEach(function(t){i[t]=t}),"abcdefghijklmnopqrst"===Object.keys(Object.assign({},i)).join("")}catch(t){return!1}}var r=Object.prototype.hasOwnProperty,i=Object.prototype.propertyIsEnumerable;t.exports=n()?Object.assign:function(t,n){for(var o,u,a=arguments,s=e(t),c=1;c199&&u.status<300?t(e):n(e)},u.onerror=function(){return n({})},r?(u.setRequestHeader("Content-Type","application/json;charset=UTF-8"),u.send(JSON.stringify(r))):u.send()})}function A(t,e){var n=e.message;return t.set(t.size,n)}function D(){return Dn.getInitialState()}function C(t,e){t.dispatch(wn.NOTIFICATION_CREATED,{message:e})}function z(t){t.registerStores({notifications:Dn})}function R(t,e){if("lock"===t)return!0;if("garage_door"===t)return!0;var n=e.get(t);return!!n&&n.services.has("turn_on")}function L(t,e){return!!t&&("group"===t.domain?"on"===t.state||"off"===t.state:R(t.domain,e))}function M(t,e){return[er(t),function(t){return!!t&&t.services.has(e)}]}function j(t){return[bn.byId(t),tr,L]}function N(t,e,n){function r(){var c=(new Date).getTime()-a;c0?i=setTimeout(r,e-c):(i=null,n||(s=t.apply(u,o),i||(u=o=null)))}var i,o,u,a,s;null==e&&(e=100);var c=function(){u=this,o=arguments,a=(new Date).getTime();var c=n&&!i;return i||(i=setTimeout(r,e)),c&&(s=t.apply(u,o),u=o=null),s};return c.clear=function(){i&&(clearTimeout(i),i=null)},c}function k(t,e){var n=e.component;return t.push(n)}function U(t,e){var n=e.components;return pr(n)}function P(){return _r.getInitialState()}function H(t,e){var n=e.latitude,r=e.longitude,i=e.location_name,o=e.temperature_unit,u=e.time_zone,a=e.version;return vr({latitude:n,longitude:r,location_name:i,temperature_unit:o,time_zone:u,serverVersion:a})}function x(){return yr.getInitialState()}function V(t,e){t.dispatch(hr.SERVER_CONFIG_LOADED,e)}function q(t){fn(t,"GET","config").then(function(e){return V(t,e)})}function F(t,e){t.dispatch(hr.COMPONENT_LOADED,{component:e})}function G(t){return[["serverComponent"],function(e){return e.contains(t)}]}function K(t){t.registerStores({serverComponent:_r,serverConfig:yr})}function Y(t,e){var n=e.pane;return n}function B(){return Dr.getInitialState()}function J(t,e){var n=e.panels;return zr(n)}function W(){return Rr.getInitialState()}function X(t,e){var n=e.show;return!!n}function Q(){return Mr.getInitialState()}function Z(t,e){t.dispatch(Tr.SHOW_SIDEBAR,{show:e})}function $(t,e){t.dispatch(Tr.NAVIGATE,{pane:e})}function tt(t,e){t.dispatch(Tr.PANELS_LOADED,{panels:e})}function et(t,e){var n=e.entityId;return n}function nt(){return qr.getInitialState()}function rt(t,e){t.dispatch(xr.SELECT_ENTITY,{entityId:e})}function it(t){t.dispatch(xr.SELECT_ENTITY,{entityId:null})}function ot(t){return!t||(new Date).getTime()-t>6e4}function ut(t,e){var n=e.date;return n.toISOString()}function at(){return Yr.getInitialState()}function st(t,e){var n=e.date,r=e.stateHistory;return 0===r.length?t.set(n,Jr({})):t.withMutations(function(t){r.forEach(function(e){return t.setIn([n,e[0].entity_id],Jr(e.map(dn.fromJSON)))})})}function ct(){return Wr.getInitialState()}function ft(t,e){var n=e.stateHistory;return t.withMutations(function(t){n.forEach(function(e){return t.set(e[0].entity_id,$r(e.map(dn.fromJSON)))})})}function ht(){return ti.getInitialState()}function lt(t,e){var n=e.stateHistory,r=(new Date).getTime();return t.withMutations(function(t){n.forEach(function(e){return t.set(e[0].entity_id,r)}),history.length>1&&t.set(ri,r)})}function pt(){return ii.getInitialState()}function _t(t,e){t.dispatch(Gr.ENTITY_HISTORY_DATE_SELECTED,{date:e})}function dt(t,e){void 0===e&&(e=null),t.dispatch(Gr.RECENT_ENTITY_HISTORY_FETCH_START,{});var n="history/period";return null!==e&&(n+="?filter_entity_id="+e),fn(t,"GET",n).then(function(e){return t.dispatch(Gr.RECENT_ENTITY_HISTORY_FETCH_SUCCESS,{stateHistory:e})},function(){return t.dispatch(Gr.RECENT_ENTITY_HISTORY_FETCH_ERROR,{})})}function vt(t,e){return t.dispatch(Gr.ENTITY_HISTORY_FETCH_START,{date:e}),fn(t,"GET","history/period/"+e).then(function(n){return t.dispatch(Gr.ENTITY_HISTORY_FETCH_SUCCESS,{date:e,stateHistory:n})},function(){return t.dispatch(Gr.ENTITY_HISTORY_FETCH_ERROR,{})})}function yt(t){var e=t.evaluate(ai);return vt(t,e)}function St(t){t.registerStores({currentEntityHistoryDate:Yr,entityHistory:Wr,isLoadingEntityHistory:Qr,recentEntityHistory:ti,recentEntityHistoryUpdated:ii})}function gt(t){t.registerStores({moreInfoEntityId:qr})}function mt(t,e){var n=e.model,r=e.result,i=e.params;if(null===t||"entity"!==n.entity||!i.replace)return t;for(var o=0;oru}function ae(t){t.registerStores({currentLogbookDate:Fo,isLoadingLogbookEntries:Ko,logbookEntries:Qo,logbookEntriesUpdated:tu})}function se(t){return t.set("active",!0)}function ce(t){return t.set("active",!1)}function fe(){return du.getInitialState()}function he(t){return navigator.serviceWorker.getRegistration().then(function(t){if(!t)throw new Error("No service worker registered.");return t.pushManager.subscribe({userVisibleOnly:!0})}).then(function(e){var n;return n=navigator.userAgent.toLowerCase().indexOf("firefox")>-1?"firefox":"chrome",fn(t,"POST","notify.html5",{subscription:e,browser:n}).then(function(){return t.dispatch(lu.PUSH_NOTIFICATIONS_SUBSCRIBE,{})}).then(function(){return!0})})["catch"](function(e){var n;return n=e.message&&e.message.indexOf("gcm_sender_id")!==-1?"Please setup the notify.html5 platform.":"Notification registration failed.",console.error(e),Mn.createNotification(t,n),!1})}function le(t){return navigator.serviceWorker.getRegistration().then(function(t){if(!t)throw new Error("No service worker registered");return t.pushManager.subscribe({userVisibleOnly:!0})}).then(function(e){return fn(t,"DELETE","notify.html5",{subscription:e}).then(function(){return e.unsubscribe()}).then(function(){return t.dispatch(lu.PUSH_NOTIFICATIONS_UNSUBSCRIBE,{})}).then(function(){return!0})})["catch"](function(e){var n="Failed unsubscribing for push notifications.";return console.error(e),Mn.createNotification(t,n),!1})}function pe(t){t.registerStores({pushNotifications:du})}function _e(t,e){return fn(t,"POST","template",{template:e})}function de(t){return t.set("isListening",!0)}function ve(t,e){var n=e.interimTranscript,r=e.finalTranscript;return t.withMutations(function(t){return t.set("isListening",!0).set("isTransmitting",!1).set("interimTranscript",n).set("finalTranscript",r)})}function ye(t,e){var n=e.finalTranscript;return t.withMutations(function(t){return t.set("isListening",!1).set("isTransmitting",!0).set("interimTranscript","").set("finalTranscript",n)})}function Se(){return Ru.getInitialState()}function ge(){return Ru.getInitialState()}function me(){return Ru.getInitialState()}function Ee(t){return Lu[t.hassId]}function Ie(t){var e=Ee(t);if(e){var n=e.finalTranscript||e.interimTranscript;t.dispatch(Du.VOICE_TRANSMITTING,{finalTranscript:n}),ir.callService(t,"conversation","process",{text:n}).then(function(){t.dispatch(Du.VOICE_DONE)},function(){t.dispatch(Du.VOICE_ERROR)})}}function be(t){var e=Ee(t);e&&(e.recognition.stop(),Lu[t.hassId]=!1)}function Oe(t){Ie(t),be(t)}function we(t){var e=Oe.bind(null,t);e();var n=new webkitSpeechRecognition;Lu[t.hassId]={recognition:n,interimTranscript:"",finalTranscript:""},n.interimResults=!0,n.onstart=function(){return t.dispatch(Du.VOICE_START)},n.onerror=function(){return t.dispatch(Du.VOICE_ERROR)},n.onend=e,n.onresult=function(e){var n=Ee(t);if(n){for(var r="",i="",o=e.resultIndex;o=n)}function c(t,e){return h(t,e,0)}function f(t,e){return h(t,e,e)}function h(t,e,n){return void 0===t?n:t<0?Math.max(0,e+t):void 0===e?t:Math.min(e,t)}function l(t){return v(t)?t:C(t)}function p(t){return y(t)?t:z(t)}function _(t){return S(t)?t:R(t)}function d(t){return v(t)&&!g(t)?t:L(t)}function v(t){return!(!t||!t[dn])}function y(t){return!(!t||!t[vn])}function S(t){return!(!t||!t[yn])}function g(t){return y(t)||S(t)}function m(t){return!(!t||!t[Sn])}function E(t){this.next=t}function I(t,e,n,r){var i=0===t?e:1===t?n:[e,n];return r?r.value=i:r={value:i,done:!1},r}function b(){return{value:void 0,done:!0}}function O(t){return!!A(t)}function w(t){return t&&"function"==typeof t.next}function T(t){var e=A(t);return e&&e.call(t)}function A(t){var e=t&&(In&&t[In]||t[bn]);if("function"==typeof e)return e}function D(t){return t&&"number"==typeof t.length}function C(t){return null===t||void 0===t?P():v(t)?t.toSeq():V(t)}function z(t){return null===t||void 0===t?P().toKeyedSeq():v(t)?y(t)?t.toSeq():t.fromEntrySeq():H(t)}function R(t){return null===t||void 0===t?P():v(t)?y(t)?t.entrySeq():t.toIndexedSeq():x(t)}function L(t){return(null===t||void 0===t?P():v(t)?y(t)?t.entrySeq():t:x(t)).toSetSeq()}function M(t){this._array=t,this.size=t.length}function j(t){var e=Object.keys(t);this._object=t,this._keys=e,this.size=e.length}function N(t){this._iterable=t,this.size=t.length||t.size}function k(t){this._iterator=t,this._iteratorCache=[]}function U(t){return!(!t||!t[wn])}function P(){return Tn||(Tn=new M([]))}function H(t){var e=Array.isArray(t)?new M(t).fromEntrySeq():w(t)?new k(t).fromEntrySeq():O(t)?new N(t).fromEntrySeq():"object"==typeof t?new j(t):void 0;if(!e)throw new TypeError("Expected Array or iterable object of [k, v] entries, or keyed object: "+t);return e}function x(t){var e=q(t);if(!e)throw new TypeError("Expected Array or iterable object of values: "+t);return e}function V(t){var e=q(t)||"object"==typeof t&&new j(t);if(!e)throw new TypeError("Expected Array or iterable object of values, or keyed object: "+t);return e}function q(t){return D(t)?new M(t):w(t)?new k(t):O(t)?new N(t):void 0}function F(t,e,n,r){var i=t._cache;if(i){for(var o=i.length-1,u=0;u<=o;u++){var a=i[n?o-u:u];if(e(a[1],r?a[0]:u,t)===!1)return u+1}return u}return t.__iterateUncached(e,n)}function G(t,e,n,r){var i=t._cache;if(i){var o=i.length-1,u=0;return new E(function(){var t=i[n?o-u:u];return u++>o?b():I(e,r?t[0]:u-1,t[1])})}return t.__iteratorUncached(e,n)}function K(){throw TypeError("Abstract")}function Y(){}function B(){}function J(){}function W(t,e){if(t===e||t!==t&&e!==e)return!0;if(!t||!e)return!1;if("function"==typeof t.valueOf&&"function"==typeof e.valueOf){if(t=t.valueOf(),e=e.valueOf(),t===e||t!==t&&e!==e)return!0;if(!t||!e)return!1}return!("function"!=typeof t.equals||"function"!=typeof e.equals||!t.equals(e))}function X(t,e){return e?Q(e,t,"",{"":t}):Z(t)}function Q(t,e,n,r){return Array.isArray(e)?t.call(r,n,R(e).map(function(n,r){return Q(t,n,r,e)})):$(e)?t.call(r,n,z(e).map(function(n,r){return Q(t,n,r,e)})):e}function Z(t){return Array.isArray(t)?R(t).map(Z).toList():$(t)?z(t).map(Z).toMap():t}function $(t){return t&&(t.constructor===Object||void 0===t.constructor)}function tt(t){return t>>>1&1073741824|3221225471&t}function et(t){if(t===!1||null===t||void 0===t)return 0;if("function"==typeof t.valueOf&&(t=t.valueOf(),t===!1||null===t||void 0===t))return 0;if(t===!0)return 1;var e=typeof t;if("number"===e){var n=0|t;for(n!==t&&(n^=4294967295*t);t>4294967295;)t/=4294967295,n^=t;return tt(n)}return"string"===e?t.length>jn?nt(t):rt(t):"function"==typeof t.hashCode?t.hashCode():it(t)}function nt(t){var e=Un[t];return void 0===e&&(e=rt(t),kn===Nn&&(kn=0,Un={}),kn++,Un[t]=e),e}function rt(t){for(var e=0,n=0;n0)switch(t.nodeType){case 1:return t.uniqueID;case 9:return t.documentElement&&t.documentElement.uniqueID}}function ut(t,e){if(!t)throw new Error(e)}function at(t){ut(t!==1/0,"Cannot perform this action with an infinite size.")}function st(t,e){this._iter=t,this._useKeys=e,this.size=t.size}function ct(t){this._iter=t,this.size=t.size}function ft(t){this._iter=t,this.size=t.size}function ht(t){this._iter=t,this.size=t.size}function lt(t){var e=Mt(t);return e._iter=t,e.size=t.size,e.flip=function(){return t},e.reverse=function(){var e=t.reverse.apply(this);return e.flip=function(){return t.reverse()},e},e.has=function(e){return t.includes(e)},e.includes=function(e){return t.has(e)},e.cacheResult=jt,e.__iterateUncached=function(e,n){var r=this;return t.__iterate(function(t,n){return e(n,t,r)!==!1},n)},e.__iteratorUncached=function(e,n){if(e===En){var r=t.__iterator(e,n);return new E(function(){var t=r.next();if(!t.done){var e=t.value[0];t.value[0]=t.value[1],t.value[1]=e}return t})}return t.__iterator(e===mn?gn:mn,n)},e}function pt(t,e,n){var r=Mt(t);return r.size=t.size,r.has=function(e){return t.has(e)},r.get=function(r,i){var o=t.get(r,ln);return o===ln?i:e.call(n,o,r,t)},r.__iterateUncached=function(r,i){var o=this;return t.__iterate(function(t,i,u){return r(e.call(n,t,i,u),i,o)!==!1},i)},r.__iteratorUncached=function(r,i){var o=t.__iterator(En,i);return new E(function(){var i=o.next();if(i.done)return i;var u=i.value,a=u[0];return I(r,a,e.call(n,u[1],a,t),i)})},r}function _t(t,e){var n=Mt(t);return n._iter=t,n.size=t.size,n.reverse=function(){return t},t.flip&&(n.flip=function(){var e=lt(t);return e.reverse=function(){return t.flip()},e}),n.get=function(n,r){return t.get(e?n:-1-n,r)},n.has=function(n){return t.has(e?n:-1-n)},n.includes=function(e){return t.includes(e)},n.cacheResult=jt,n.__iterate=function(e,n){var r=this;return t.__iterate(function(t,n){return e(t,n,r)},!n)},n.__iterator=function(e,n){return t.__iterator(e,!n)},n}function dt(t,e,n,r){var i=Mt(t);return r&&(i.has=function(r){var i=t.get(r,ln);return i!==ln&&!!e.call(n,i,r,t)},i.get=function(r,i){var o=t.get(r,ln);return o!==ln&&e.call(n,o,r,t)?o:i}),i.__iterateUncached=function(i,o){var u=this,a=0;return t.__iterate(function(t,o,s){if(e.call(n,t,o,s))return a++,i(t,r?o:a-1,u)},o),a},i.__iteratorUncached=function(i,o){var u=t.__iterator(En,o),a=0;return new E(function(){for(;;){var o=u.next();if(o.done)return o;var s=o.value,c=s[0],f=s[1];if(e.call(n,f,c,t))return I(i,r?c:a++,f,o)}})},i}function vt(t,e,n){var r=Ut().asMutable();return t.__iterate(function(i,o){r.update(e.call(n,i,o,t),0,function(t){return t+1})}),r.asImmutable()}function yt(t,e,n){var r=y(t),i=(m(t)?be():Ut()).asMutable();t.__iterate(function(o,u){i.update(e.call(n,o,u,t),function(t){return t=t||[],t.push(r?[u,o]:o),t})});var o=Lt(t);return i.map(function(e){return Ct(t,o(e))})}function St(t,e,n,r){var i=t.size;if(void 0!==e&&(e=0|e),void 0!==n&&(n=0|n),s(e,n,i))return t;var o=c(e,i),a=f(n,i);if(o!==o||a!==a)return St(t.toSeq().cacheResult(),e,n,r);var h,l=a-o;l===l&&(h=l<0?0:l);var p=Mt(t);return p.size=0===h?h:t.size&&h||void 0,!r&&U(t)&&h>=0&&(p.get=function(e,n){return e=u(this,e),e>=0&&eh)return b();var t=i.next();return r||e===mn?t:e===gn?I(e,a-1,void 0,t):I(e,a-1,t.value[1],t)})},p}function gt(t,e,n){var r=Mt(t);return r.__iterateUncached=function(r,i){var o=this;if(i)return this.cacheResult().__iterate(r,i);var u=0;return t.__iterate(function(t,i,a){return e.call(n,t,i,a)&&++u&&r(t,i,o)}),u},r.__iteratorUncached=function(r,i){var o=this;if(i)return this.cacheResult().__iterator(r,i);var u=t.__iterator(En,i),a=!0;return new E(function(){if(!a)return b();var t=u.next();if(t.done)return t;var i=t.value,s=i[0],c=i[1];return e.call(n,c,s,o)?r===En?t:I(r,s,c,t):(a=!1,b())})},r}function mt(t,e,n,r){var i=Mt(t);return i.__iterateUncached=function(i,o){var u=this;if(o)return this.cacheResult().__iterate(i,o);var a=!0,s=0;return t.__iterate(function(t,o,c){if(!a||!(a=e.call(n,t,o,c)))return s++,i(t,r?o:s-1,u)}),s},i.__iteratorUncached=function(i,o){var u=this;if(o)return this.cacheResult().__iterator(i,o);var a=t.__iterator(En,o),s=!0,c=0;return new E(function(){var t,o,f;do{if(t=a.next(),t.done)return r||i===mn?t:i===gn?I(i,c++,void 0,t):I(i,c++,t.value[1],t);var h=t.value;o=h[0],f=h[1],s&&(s=e.call(n,f,o,u))}while(s);return i===En?t:I(i,o,f,t)})},i}function Et(t,e){var n=y(t),r=[t].concat(e).map(function(t){return v(t)?n&&(t=p(t)):t=n?H(t):x(Array.isArray(t)?t:[t]),t}).filter(function(t){return 0!==t.size});if(0===r.length)return t;if(1===r.length){var i=r[0];if(i===t||n&&y(i)||S(t)&&S(i))return i}var o=new M(r);return n?o=o.toKeyedSeq():S(t)||(o=o.toSetSeq()),o=o.flatten(!0),o.size=r.reduce(function(t,e){if(void 0!==t){var n=e.size;if(void 0!==n)return t+n}},0),o}function It(t,e,n){var r=Mt(t);return r.__iterateUncached=function(r,i){function o(t,s){var c=this;t.__iterate(function(t,i){return(!e||s0}function Dt(t,e,n){var r=Mt(t);return r.size=new M(n).map(function(t){return t.size}).min(),r.__iterate=function(t,e){for(var n,r=this,i=this.__iterator(mn,e),o=0;!(n=i.next()).done&&t(n.value,o++,r)!==!1;);return o},r.__iteratorUncached=function(t,r){var i=n.map(function(t){return t=l(t),T(r?t.reverse():t)}),o=0,u=!1;return new E(function(){var n;return u||(n=i.map(function(t){return t.next()}),u=n.some(function(t){return t.done})),u?b():I(t,o++,e.apply(null,n.map(function(t){return t.value})))})},r}function Ct(t,e){return U(t)?e:t.constructor(e)}function zt(t){if(t!==Object(t))throw new TypeError("Expected [K, V] tuple: "+t)}function Rt(t){ +return at(t.size),o(t)}function Lt(t){return y(t)?p:S(t)?_:d}function Mt(t){return Object.create((y(t)?z:S(t)?R:L).prototype)}function jt(){return this._iter.cacheResult?(this._iter.cacheResult(),this.size=this._iter.size,this):C.prototype.cacheResult.call(this)}function Nt(t,e){return t>e?1:t>>n)&hn,a=(0===n?r:r>>>n)&hn,s=u===a?[Zt(t,e,n+cn,r,i)]:(o=new Ft(e,r,i),u>>=1)u[a]=1&n?e[o++]:void 0;return u[r]=i,new Vt(t,o+1,u)}function ne(t,e,n){for(var r=[],i=0;i>1&1431655765,t=(858993459&t)+(t>>2&858993459),t=t+(t>>4)&252645135,t+=t>>8,t+=t>>16,127&t}function ae(t,e,n,r){var o=r?t:i(t);return o[e]=n,o}function se(t,e,n,r){var i=t.length+1;if(r&&e+1===i)return t[e]=n,t;for(var o=new Array(i),u=0,a=0;a0&&ro?0:o-n,c=u-n;return c>fn&&(c=fn),function(){if(i===c)return Bn;var t=e?--c:i++;return r&&r[t]}}function i(t,r,i){var a,s=t&&t.array,c=i>o?0:o-i>>r,f=(u-i>>r)+1;return f>fn&&(f=fn),function(){for(;;){if(a){var t=a();if(t!==Bn)return t;a=null}if(c===f)return Bn;var o=e?--f:c++;a=n(s&&s[o],r-cn,i+(o<=t.size||n<0)return t.withMutations(function(t){n<0?me(t,n).set(0,r):me(t,0,n+1).set(n,r)});n+=t._origin;var i=t._tail,o=t._root,a=e(_n);return n>=Ie(t._capacity)?i=ye(i,t.__ownerID,0,n,r,a):o=ye(o,t.__ownerID,t._level,n,r,a),a.value?t.__ownerID?(t._root=o,t._tail=i,t.__hash=void 0,t.__altered=!0,t):_e(t._origin,t._capacity,t._level,o,i):t}function ye(t,e,r,i,o,u){var a=i>>>r&hn,s=t&&a0){var f=t&&t.array[a],h=ye(f,e,r-cn,i,o,u);return h===f?t:(c=Se(t,e),c.array[a]=h,c)}return s&&t.array[a]===o?t:(n(u),c=Se(t,e),void 0===o&&a===c.array.length-1?c.array.pop():c.array[a]=o,c)}function Se(t,e){return e&&t&&e===t.ownerID?t:new le(t?t.array.slice():[],e)}function ge(t,e){if(e>=Ie(t._capacity))return t._tail;if(e<1<0;)n=n.array[e>>>r&hn],r-=cn;return n}}function me(t,e,n){void 0!==e&&(e=0|e),void 0!==n&&(n=0|n);var i=t.__ownerID||new r,o=t._origin,u=t._capacity,a=o+e,s=void 0===n?u:n<0?u+n:o+n;if(a===o&&s===u)return t;if(a>=s)return t.clear();for(var c=t._level,f=t._root,h=0;a+h<0;)f=new le(f&&f.array.length?[void 0,f]:[],i),c+=cn,h+=1<=1<l?new le([],i):_;if(_&&p>l&&acn;y-=cn){var S=l>>>y&hn;v=v.array[S]=Se(v.array[S],i)}v.array[l>>>cn&hn]=_}if(s=p)a-=p,s-=p,c=cn,f=null,d=d&&d.removeBefore(i,0,a);else if(a>o||p>>c&hn;if(g!==p>>>c&hn)break;g&&(h+=(1<o&&(f=f.removeBefore(i,c,a-h)),f&&pi&&(i=a.size),v(u)||(a=a.map(function(t){return X(t)})),r.push(a)}return i>t.size&&(t=t.setSize(i)),ie(t,e,r)}function Ie(t){return t>>cn<=fn&&u.size>=2*o.size?(i=u.filter(function(t,e){return void 0!==t&&a!==e}),r=i.toKeyedSeq().map(function(t){return t[0]}).flip().toMap(),t.__ownerID&&(r.__ownerID=i.__ownerID=t.__ownerID)):(r=o.remove(e),i=a===u.size-1?u.pop():u.set(a,void 0))}else if(s){if(n===u.get(a)[1])return t;r=o,i=u.set(a,[e,n])}else r=o.set(e,u.size),i=u.set(u.size,[e,n]);return t.__ownerID?(t.size=r.size,t._map=r,t._list=i,t.__hash=void 0,t):we(r,i)}function De(t){return null===t||void 0===t?Re():Ce(t)?t:Re().unshiftAll(t)}function Ce(t){return!(!t||!t[Wn])}function ze(t,e,n,r){var i=Object.create(Xn);return i.size=t,i._head=e,i.__ownerID=n,i.__hash=r,i.__altered=!1,i}function Re(){return Qn||(Qn=ze(0))}function Le(t){return null===t||void 0===t?ke():Me(t)&&!m(t)?t:ke().withMutations(function(e){var n=d(t);at(n.size),n.forEach(function(t){return e.add(t)})})}function Me(t){return!(!t||!t[Zn])}function je(t,e){return t.__ownerID?(t.size=e.size,t._map=e,t):e===t._map?t:0===e.size?t.__empty():t.__make(e)}function Ne(t,e){var n=Object.create($n);return n.size=t?t.size:0,n._map=t,n.__ownerID=e,n}function ke(){return tr||(tr=Ne(Jt()))}function Ue(t){return null===t||void 0===t?xe():Pe(t)?t:xe().withMutations(function(e){var n=d(t);at(n.size),n.forEach(function(t){return e.add(t)})})}function Pe(t){return Me(t)&&m(t)}function He(t,e){var n=Object.create(er);return n.size=t?t.size:0,n._map=t,n.__ownerID=e,n}function xe(){return nr||(nr=He(Te()))}function Ve(t,e){var n,r=function(o){if(o instanceof r)return o;if(!(this instanceof r))return new r(o);if(!n){n=!0;var u=Object.keys(t);Ge(i,u),i.size=u.length,i._name=e,i._keys=u,i._defaultValues=t}this._map=Ut(o)},i=r.prototype=Object.create(rr);return i.constructor=r,r}function qe(t,e,n){var r=Object.create(Object.getPrototypeOf(t));return r._map=e,r.__ownerID=n,r}function Fe(t){return t._name||t.constructor.name||"Record"}function Ge(t,e){try{e.forEach(Ke.bind(void 0,t))}catch(n){}}function Ke(t,e){Object.defineProperty(t,e,{get:function(){return this.get(e)},set:function(t){ut(this.__ownerID,"Cannot set on an immutable record."),this.set(e,t)}})}function Ye(t,e){if(t===e)return!0;if(!v(e)||void 0!==t.size&&void 0!==e.size&&t.size!==e.size||void 0!==t.__hash&&void 0!==e.__hash&&t.__hash!==e.__hash||y(t)!==y(e)||S(t)!==S(e)||m(t)!==m(e))return!1;if(0===t.size&&0===e.size)return!0;var n=!g(t);if(m(t)){var r=t.entries();return e.every(function(t,e){var i=r.next().value;return i&&W(i[1],t)&&(n||W(i[0],e))})&&r.next().done}var i=!1;if(void 0===t.size)if(void 0===e.size)"function"==typeof t.cacheResult&&t.cacheResult();else{i=!0;var o=t;t=e,e=o}var u=!0,a=e.__iterate(function(e,r){if(n?!t.has(e):i?!W(e,t.get(r,ln)):!W(t.get(r,ln),e))return u=!1,!1});return u&&t.size===a}function Be(t,e,n){if(!(this instanceof Be))return new Be(t,e,n);if(ut(0!==n,"Cannot step a Range by 0"),t=t||0,void 0===e&&(e=1/0),n=void 0===n?1:Math.abs(n),ee?-1:0}function rn(t){if(t.size===1/0)return 0;var e=m(t),n=y(t),r=e?1:0,i=t.__iterate(n?e?function(t,e){r=31*r+un(et(t),et(e))|0}:function(t,e){r=r+un(et(t),et(e))|0}:e?function(t){r=31*r+et(t)|0}:function(t){r=r+et(t)|0});return on(i,r)}function on(t,e){return e=Dn(e,3432918353),e=Dn(e<<15|e>>>-15,461845907),e=Dn(e<<13|e>>>-13,5),e=(e+3864292196|0)^t,e=Dn(e^e>>>16,2246822507),e=Dn(e^e>>>13,3266489909),e=tt(e^e>>>16)}function un(t,e){return t^e+2654435769+(t<<6)+(t>>2)|0}var an=Array.prototype.slice,sn="delete",cn=5,fn=1<r?b():I(t,i,n[e?r-i++:i++])})},t(j,z),j.prototype.get=function(t,e){return void 0===e||this.has(t)?this._object[t]:e},j.prototype.has=function(t){return this._object.hasOwnProperty(t)},j.prototype.__iterate=function(t,e){for(var n=this,r=this._object,i=this._keys,o=i.length-1,u=0;u<=o;u++){var a=i[e?o-u:u];if(t(r[a],a,n)===!1)return u+1}return u},j.prototype.__iterator=function(t,e){var n=this._object,r=this._keys,i=r.length-1,o=0;return new E(function(){var u=r[e?i-o:o];return o++>i?b():I(t,u,n[u])})},j.prototype[Sn]=!0,t(N,R),N.prototype.__iterateUncached=function(t,e){var n=this;if(e)return this.cacheResult().__iterate(t,e);var r=this._iterable,i=T(r),o=0;if(w(i))for(var u;!(u=i.next()).done&&t(u.value,o++,n)!==!1;);return o},N.prototype.__iteratorUncached=function(t,e){if(e)return this.cacheResult().__iterator(t,e);var n=this._iterable,r=T(n);if(!w(r))return new E(b);var i=0;return new E(function(){var e=r.next();return e.done?e:I(t,i++,e.value)})},t(k,R),k.prototype.__iterateUncached=function(t,e){var n=this;if(e)return this.cacheResult().__iterate(t,e);for(var r=this._iterator,i=this._iteratorCache,o=0;o=r.length){var e=n.next();if(e.done)return e;r[i]=e.value}return I(t,i,r[i++])})};var Tn;t(K,l),t(Y,K),t(B,K),t(J,K),K.Keyed=Y,K.Indexed=B,K.Set=J;var An,Dn="function"==typeof Math.imul&&Math.imul(4294967295,2)===-2?Math.imul:function(t,e){t=0|t,e=0|e;var n=65535&t,r=65535&e;return n*r+((t>>>16)*r+n*(e>>>16)<<16>>>0)|0},Cn=Object.isExtensible,zn=function(){try{return Object.defineProperty({},"@",{}),!0}catch(t){return!1}}(),Rn="function"==typeof WeakMap;Rn&&(An=new WeakMap);var Ln=0,Mn="__immutablehash__";"function"==typeof Symbol&&(Mn=Symbol(Mn));var jn=16,Nn=255,kn=0,Un={};t(st,z),st.prototype.get=function(t,e){return this._iter.get(t,e)},st.prototype.has=function(t){return this._iter.has(t)},st.prototype.valueSeq=function(){return this._iter.valueSeq()},st.prototype.reverse=function(){var t=this,e=_t(this,!0);return this._useKeys||(e.valueSeq=function(){return t._iter.toSeq().reverse()}),e},st.prototype.map=function(t,e){var n=this,r=pt(this,t,e);return this._useKeys||(r.valueSeq=function(){return n._iter.toSeq().map(t,e)}),r},st.prototype.__iterate=function(t,e){var n,r=this;return this._iter.__iterate(this._useKeys?function(e,n){return t(e,n,r)}:(n=e?Rt(this):0,function(i){return t(i,e?--n:n++,r)}),e)},st.prototype.__iterator=function(t,e){if(this._useKeys)return this._iter.__iterator(t,e);var n=this._iter.__iterator(mn,e),r=e?Rt(this):0;return new E(function(){var i=n.next();return i.done?i:I(t,e?--r:r++,i.value,i)})},st.prototype[Sn]=!0,t(ct,R),ct.prototype.includes=function(t){return this._iter.includes(t)},ct.prototype.__iterate=function(t,e){var n=this,r=0;return this._iter.__iterate(function(e){return t(e,r++,n)},e)},ct.prototype.__iterator=function(t,e){var n=this._iter.__iterator(mn,e),r=0;return new E(function(){var e=n.next();return e.done?e:I(t,r++,e.value,e)})},t(ft,L),ft.prototype.has=function(t){return this._iter.includes(t)},ft.prototype.__iterate=function(t,e){var n=this;return this._iter.__iterate(function(e){return t(e,e,n)},e)},ft.prototype.__iterator=function(t,e){var n=this._iter.__iterator(mn,e);return new E(function(){var e=n.next();return e.done?e:I(t,e.value,e.value,e)})},t(ht,z),ht.prototype.entrySeq=function(){return this._iter.toSeq()},ht.prototype.__iterate=function(t,e){var n=this;return this._iter.__iterate(function(e){if(e){zt(e);var r=v(e);return t(r?e.get(1):e[1],r?e.get(0):e[0],n)}},e)},ht.prototype.__iterator=function(t,e){var n=this._iter.__iterator(mn,e);return new E(function(){for(;;){var e=n.next();if(e.done)return e;var r=e.value;if(r){zt(r);var i=v(r);return I(t,i?r.get(0):r[0],i?r.get(1):r[1],e)}}})},ct.prototype.cacheResult=st.prototype.cacheResult=ft.prototype.cacheResult=ht.prototype.cacheResult=jt,t(Ut,Y),Ut.prototype.toString=function(){return this.__toString("Map {","}")},Ut.prototype.get=function(t,e){return this._root?this._root.get(0,void 0,t,e):e},Ut.prototype.set=function(t,e){return Wt(this,t,e)},Ut.prototype.setIn=function(t,e){return this.updateIn(t,ln,function(){return e})},Ut.prototype.remove=function(t){return Wt(this,t,ln)},Ut.prototype.deleteIn=function(t){return this.updateIn(t,function(){return ln})},Ut.prototype.update=function(t,e,n){return 1===arguments.length?t(this):this.updateIn([t],e,n)},Ut.prototype.updateIn=function(t,e,n){n||(n=e,e=void 0);var r=oe(this,kt(t),e,n);return r===ln?void 0:r},Ut.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=0,this._root=null,this.__hash=void 0,this.__altered=!0,this):Jt()},Ut.prototype.merge=function(){return ne(this,void 0,arguments)},Ut.prototype.mergeWith=function(t){var e=an.call(arguments,1);return ne(this,t,e)},Ut.prototype.mergeIn=function(t){var e=an.call(arguments,1);return this.updateIn(t,Jt(),function(t){return"function"==typeof t.merge?t.merge.apply(t,e):e[e.length-1]})},Ut.prototype.mergeDeep=function(){return ne(this,re(void 0),arguments)},Ut.prototype.mergeDeepWith=function(t){var e=an.call(arguments,1);return ne(this,re(t),e)},Ut.prototype.mergeDeepIn=function(t){var e=an.call(arguments,1);return this.updateIn(t,Jt(),function(t){return"function"==typeof t.mergeDeep?t.mergeDeep.apply(t,e):e[e.length-1]})},Ut.prototype.sort=function(t){return be(wt(this,t))},Ut.prototype.sortBy=function(t,e){return be(wt(this,e,t))},Ut.prototype.withMutations=function(t){var e=this.asMutable();return t(e),e.wasAltered()?e.__ensureOwner(this.__ownerID):this},Ut.prototype.asMutable=function(){return this.__ownerID?this:this.__ensureOwner(new r)},Ut.prototype.asImmutable=function(){return this.__ensureOwner()},Ut.prototype.wasAltered=function(){return this.__altered},Ut.prototype.__iterator=function(t,e){return new Gt(this,t,e)},Ut.prototype.__iterate=function(t,e){var n=this,r=0;return this._root&&this._root.iterate(function(e){return r++,t(e[1],e[0],n)},e),r},Ut.prototype.__ensureOwner=function(t){return t===this.__ownerID?this:t?Bt(this.size,this._root,t,this.__hash):(this.__ownerID=t,this.__altered=!1,this)},Ut.isMap=Pt;var Pn="@@__IMMUTABLE_MAP__@@",Hn=Ut.prototype;Hn[Pn]=!0,Hn[sn]=Hn.remove,Hn.removeIn=Hn.deleteIn,Ht.prototype.get=function(t,e,n,r){for(var i=this.entries,o=0,u=i.length;o=Vn)return $t(t,f,o,u);var _=t&&t===this.ownerID,d=_?f:i(f);return p?c?h===l-1?d.pop():d[h]=d.pop():d[h]=[o,u]:d.push([o,u]),_?(this.entries=d,this):new Ht(t,d)}},xt.prototype.get=function(t,e,n,r){void 0===e&&(e=et(n));var i=1<<((0===t?e:e>>>t)&hn),o=this.bitmap;return 0===(o&i)?r:this.nodes[ue(o&i-1)].get(t+cn,e,n,r)},xt.prototype.update=function(t,e,n,r,i,o,u){void 0===n&&(n=et(r));var a=(0===e?n:n>>>e)&hn,s=1<=qn)return ee(t,l,c,a,_);if(f&&!_&&2===l.length&&Qt(l[1^h]))return l[1^h];if(f&&_&&1===l.length&&Qt(_))return _;var d=t&&t===this.ownerID,v=f?_?c:c^s:c|s,y=f?_?ae(l,h,_,d):ce(l,h,d):se(l,h,_,d);return d?(this.bitmap=v,this.nodes=y,this):new xt(t,v,y)},Vt.prototype.get=function(t,e,n,r){void 0===e&&(e=et(n));var i=(0===t?e:e>>>t)&hn,o=this.nodes[i];return o?o.get(t+cn,e,n,r):r},Vt.prototype.update=function(t,e,n,r,i,o,u){void 0===n&&(n=et(r));var a=(0===e?n:n>>>e)&hn,s=i===ln,c=this.nodes,f=c[a];if(s&&!f)return this;var h=Xt(f,t,e+cn,n,r,i,o,u);if(h===f)return this;var l=this.count;if(f){if(!h&&(l--,l=0&&t>>e&hn;if(r>=this.array.length)return new le([],t);var i,o=0===r;if(e>0){var u=this.array[r];if(i=u&&u.removeBefore(t,e-cn,n),i===u&&o)return this}if(o&&!i)return this;var a=Se(this,t);if(!o)for(var s=0;s>>e&hn;if(r>=this.array.length)return this;var i;if(e>0){var o=this.array[r];if(i=o&&o.removeAfter(t,e-cn,n),i===o&&r===this.array.length-1)return this}var u=Se(this,t);return u.array.splice(r+1),i&&(u.array[r]=i),u};var Yn,Bn={};t(be,Ut),be.of=function(){return this(arguments)},be.prototype.toString=function(){return this.__toString("OrderedMap {","}")},be.prototype.get=function(t,e){var n=this._map.get(t);return void 0!==n?this._list.get(n)[1]:e},be.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=0,this._map.clear(),this._list.clear(),this):Te()},be.prototype.set=function(t,e){return Ae(this,t,e)},be.prototype.remove=function(t){return Ae(this,t,ln)},be.prototype.wasAltered=function(){return this._map.wasAltered()||this._list.wasAltered()},be.prototype.__iterate=function(t,e){var n=this;return this._list.__iterate(function(e){return e&&t(e[1],e[0],n)},e)},be.prototype.__iterator=function(t,e){return this._list.fromEntrySeq().__iterator(t,e)},be.prototype.__ensureOwner=function(t){if(t===this.__ownerID)return this;var e=this._map.__ensureOwner(t),n=this._list.__ensureOwner(t);return t?we(e,n,t,this.__hash):(this.__ownerID=t,this._map=e,this._list=n,this)},be.isOrderedMap=Oe,be.prototype[Sn]=!0,be.prototype[sn]=be.prototype.remove;var Jn;t(De,B),De.of=function(){return this(arguments)},De.prototype.toString=function(){return this.__toString("Stack [","]")},De.prototype.get=function(t,e){var n=this._head;for(t=u(this,t);n&&t--;)n=n.next;return n?n.value:e},De.prototype.peek=function(){return this._head&&this._head.value},De.prototype.push=function(){var t=arguments;if(0===arguments.length)return this;for(var e=this.size+arguments.length,n=this._head,r=arguments.length-1;r>=0;r--)n={value:t[r],next:n};return this.__ownerID?(this.size=e,this._head=n,this.__hash=void 0,this.__altered=!0,this):ze(e,n)},De.prototype.pushAll=function(t){if(t=_(t),0===t.size)return this;at(t.size);var e=this.size,n=this._head;return t.reverse().forEach(function(t){e++,n={value:t,next:n}}),this.__ownerID?(this.size=e,this._head=n,this.__hash=void 0,this.__altered=!0,this):ze(e,n)},De.prototype.pop=function(){return this.slice(1)},De.prototype.unshift=function(){return this.push.apply(this,arguments)},De.prototype.unshiftAll=function(t){return this.pushAll(t)},De.prototype.shift=function(){return this.pop.apply(this,arguments)},De.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=0,this._head=void 0,this.__hash=void 0,this.__altered=!0,this):Re()},De.prototype.slice=function(t,e){if(s(t,e,this.size))return this;var n=c(t,this.size),r=f(e,this.size);if(r!==this.size)return B.prototype.slice.call(this,t,e);for(var i=this.size-n,o=this._head;n--;)o=o.next;return this.__ownerID?(this.size=i,this._head=o,this.__hash=void 0,this.__altered=!0,this):ze(i,o)},De.prototype.__ensureOwner=function(t){return t===this.__ownerID?this:t?ze(this.size,this._head,t,this.__hash):(this.__ownerID=t,this.__altered=!1,this)},De.prototype.__iterate=function(t,e){var n=this;if(e)return this.reverse().__iterate(t);for(var r=0,i=this._head;i&&t(i.value,r++,n)!==!1;)i=i.next;return r},De.prototype.__iterator=function(t,e){if(e)return this.reverse().__iterator(t);var n=0,r=this._head;return new E(function(){if(r){var e=r.value;return r=r.next,I(t,n++,e)}return b()})},De.isStack=Ce;var Wn="@@__IMMUTABLE_STACK__@@",Xn=De.prototype;Xn[Wn]=!0,Xn.withMutations=Hn.withMutations,Xn.asMutable=Hn.asMutable,Xn.asImmutable=Hn.asImmutable,Xn.wasAltered=Hn.wasAltered;var Qn;t(Le,J),Le.of=function(){return this(arguments)},Le.fromKeys=function(t){return this(p(t).keySeq())},Le.prototype.toString=function(){return this.__toString("Set {","}")},Le.prototype.has=function(t){return this._map.has(t)},Le.prototype.add=function(t){return je(this,this._map.set(t,!0))},Le.prototype.remove=function(t){return je(this,this._map.remove(t))},Le.prototype.clear=function(){return je(this,this._map.clear())},Le.prototype.union=function(){var t=an.call(arguments,0);return t=t.filter(function(t){return 0!==t.size}),0===t.length?this:0!==this.size||this.__ownerID||1!==t.length?this.withMutations(function(e){for(var n=0;n1?" by "+this._step:"")+" ]"},Be.prototype.get=function(t,e){return this.has(t)?this._start+u(this,t)*this._step:e},Be.prototype.includes=function(t){var e=(t-this._start)/this._step;return e>=0&&e=0&&nn?b():I(t,o++,u)})},Be.prototype.equals=function(t){return t instanceof Be?this._start===t._start&&this._end===t._end&&this._step===t._step:Ye(this,t)};var ir;t(Je,R),Je.prototype.toString=function(){return 0===this.size?"Repeat []":"Repeat [ "+this._value+" "+this.size+" times ]"},Je.prototype.get=function(t,e){return this.has(t)?this._value:e},Je.prototype.includes=function(t){return W(this._value,t)},Je.prototype.slice=function(t,e){var n=this.size;return s(t,e,n)?this:new Je(this._value,f(e,n)-c(t,n))},Je.prototype.reverse=function(){return this},Je.prototype.indexOf=function(t){return W(this._value,t)?0:-1},Je.prototype.lastIndexOf=function(t){return W(this._value,t)?this.size:-1},Je.prototype.__iterate=function(t,e){for(var n=this,r=0;rthis.size?e:this.find(function(e,n){return n===t},void 0,e)},has:function(t){return t=u(this,t),t>=0&&(void 0!==this.size?this.size===1/0||t-1&&t%1===0&&t<=Number.MAX_VALUE}var i=Function.prototype.bind;e.isString=function(t){return"string"==typeof t||"[object String]"===n(t)},e.isArray=Array.isArray||function(t){return"[object Array]"===n(t)},"function"!=typeof/./&&"object"!=typeof Int8Array?e.isFunction=function(t){return"function"==typeof t||!1}:e.isFunction=function(t){return"[object Function]"===toString.call(t)},e.isObject=function(t){var e=typeof t;return"function"===e||"object"===e&&!!t},e.extend=function(t){var e=arguments,n=arguments.length;if(!t||n<2)return t||{};for(var r=1;r0)){var e=this.reactorState.get("dirtyStores");if(0!==e.size){var n=c["default"].Set().withMutations(function(n){n.union(t.observerState.get("any")),e.forEach(function(e){var r=t.observerState.getIn(["stores",e]);r&&n.union(r)})});n.forEach(function(e){var n=t.observerState.getIn(["observersMap",e]);if(n){var r=n.get("getter"),i=n.get("handler"),o=p.evaluate(t.prevReactorState,r),u=p.evaluate(t.reactorState,r);t.prevReactorState=o.reactorState,t.reactorState=u.reactorState;var a=o.result,s=u.result;c["default"].is(a,s)||i.call(null,s)}});var r=p.resetDirtyStores(this.reactorState);this.prevReactorState=r,this.reactorState=r}}}},{key:"batchStart",value:function(){this.__batchDepth++}},{key:"batchEnd",value:function(){if(this.__batchDepth--,this.__batchDepth<=0){this.__isDispatching=!0;try{this.__notify()}catch(t){throw this.__isDispatching=!1,t}this.__isDispatching=!1}}}]),t}();e["default"]=(0,y.toFactory)(g),t.exports=e["default"]},function(t,e,n){function r(t,e,n){return e in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function i(t,e){var n={};return(0,o.each)(e,function(e,r){n[r]=t.evaluate(e)}),n}Object.defineProperty(e,"__esModule",{value:!0});var o=n(4);e["default"]=function(t){return{getInitialState:function(){return i(t,this.getDataBindings())},componentDidMount:function(){var e=this;this.__unwatchFns=[],(0,o.each)(this.getDataBindings(),function(n,i){var o=t.observe(n,function(t){e.setState(r({},i,t))});e.__unwatchFns.push(o)})},componentWillUnmount:function(){for(var t=this;this.__unwatchFns.length;)t.__unwatchFns.shift()()}}},t.exports=e["default"]},function(t,e,n){function r(t){return t&&t.__esModule?t:{"default":t}}function i(t,e){return new L({result:t,reactorState:e})}function o(t,e){return t.withMutations(function(t){(0,R.each)(e,function(e,n){t.getIn(["stores",n])&&console.warn("Store already defined for id = "+n);var r=e.getInitialState();if(void 0===r&&f(t,"throwOnUndefinedStoreReturnValue"))throw new Error("Store getInitialState() must return a value, did you forget a return statement");if(f(t,"throwOnNonImmutableStore")&&!(0,D.isImmutableValue)(r))throw new Error("Store getInitialState() must return an immutable value, did you forget to call toImmutable");t.update("stores",function(t){return t.set(n,e)}).update("state",function(t){return t.set(n,r)}).update("dirtyStores",function(t){return t.add(n)}).update("storeStates",function(t){return b(t,[n])})}),I(t)})}function u(t,e){return t.withMutations(function(t){(0,R.each)(e,function(e,n){t.update("stores",function(t){return t.set(n,e)})})})}function a(t,e,n){if(void 0===e&&f(t,"throwOnUndefinedActionType"))throw new Error("`dispatch` cannot be called with an `undefined` action type.");var r=t.get("state"),i=t.get("dirtyStores"),o=r.withMutations(function(r){A["default"].dispatchStart(t,e,n),t.get("stores").forEach(function(o,u){var a=r.get(u),s=void 0;try{s=o.handle(a,e,n)}catch(c){throw A["default"].dispatchError(t,c.message),c}if(void 0===s&&f(t,"throwOnUndefinedStoreReturnValue")){var h="Store handler must return a value, did you forget a return statement";throw A["default"].dispatchError(t,h),new Error(h)}r.set(u,s),a!==s&&(i=i.add(u))}),A["default"].dispatchEnd(t,r,i)}),u=t.set("state",o).set("dirtyStores",i).update("storeStates",function(t){return b(t,i)});return I(u)}function s(t,e){var n=[],r=(0,D.toImmutable)({}).withMutations(function(r){(0,R.each)(e,function(e,i){var o=t.getIn(["stores",i]);if(o){var u=o.deserialize(e);void 0!==u&&(r.set(i,u),n.push(i))}})}),i=w["default"].Set(n);return t.update("state",function(t){return t.merge(r)}).update("dirtyStores",function(t){return t.union(i)}).update("storeStates",function(t){return b(t,n)})}function c(t,e,n){var r=e;(0,z.isKeyPath)(e)&&(e=(0,C.fromKeyPath)(e));var i=t.get("nextId"),o=(0,C.getStoreDeps)(e),u=w["default"].Map({id:i,storeDeps:o,getterKey:r,getter:e,handler:n}),a=void 0;return a=0===o.size?t.update("any",function(t){return t.add(i)}):t.withMutations(function(t){o.forEach(function(e){var n=["stores",e];t.hasIn(n)||t.setIn(n,w["default"].Set()),t.updateIn(["stores",e],function(t){return t.add(i)})})}),a=a.set("nextId",i+1).setIn(["observersMap",i],u),{observerState:a,entry:u}}function f(t,e){var n=t.getIn(["options",e]);if(void 0===n)throw new Error("Invalid option: "+e);return n}function h(t,e,n){var r=t.get("observersMap").filter(function(t){var r=t.get("getterKey"),i=!n||t.get("handler")===n;return!!i&&((0,z.isKeyPath)(e)&&(0,z.isKeyPath)(r)?(0,z.isEqual)(e,r):e===r)});return t.withMutations(function(t){r.forEach(function(e){return l(t,e)})})}function l(t,e){return t.withMutations(function(t){var n=e.get("id"),r=e.get("storeDeps");0===r.size?t.update("any",function(t){return t.remove(n)}):r.forEach(function(e){t.updateIn(["stores",e],function(t){return t?t.remove(n):t})}),t.removeIn(["observersMap",n])})}function p(t){var e=t.get("state");return t.withMutations(function(t){var n=t.get("stores"),r=n.keySeq().toJS();n.forEach(function(n,r){var i=e.get(r),o=n.handleReset(i);if(void 0===o&&f(t,"throwOnUndefinedStoreReturnValue"))throw new Error("Store handleReset() must return a value, did you forget a return statement");if(f(t,"throwOnNonImmutableStore")&&!(0,D.isImmutableValue)(o))throw new Error("Store reset state must be an immutable value, did you forget to call toImmutable");t.setIn(["state",r],o)}),t.update("storeStates",function(t){return b(t,r)}),v(t)})}function _(t,e){var n=t.get("state");if((0,z.isKeyPath)(e))return i(n.getIn(e),t);if(!(0,C.isGetter)(e))throw new Error("evaluate must be passed a keyPath or Getter");if(g(t,e))return i(E(t,e),t);var r=(0,C.getDeps)(e).map(function(e){return _(t,e).result}),o=(0,C.getComputeFn)(e).apply(null,r);return i(o,m(t,e,o))}function d(t){var e={};return t.get("stores").forEach(function(n,r){var i=t.getIn(["state",r]),o=n.serialize(i);void 0!==o&&(e[r]=o)}),e}function v(t){return t.set("dirtyStores",w["default"].Set())}function y(t){return t}function S(t,e){var n=y(e);return t.getIn(["cache",n])}function g(t,e){var n=S(t,e);if(!n)return!1;var r=n.get("storeStates");return 0!==r.size&&r.every(function(e,n){return t.getIn(["storeStates",n])===e})}function m(t,e,n){var r=y(e),i=t.get("dispatchId"),o=(0,C.getStoreDeps)(e),u=(0,D.toImmutable)({}).withMutations(function(e){o.forEach(function(n){var r=t.getIn(["storeStates",n]);e.set(n,r)})});return t.setIn(["cache",r],w["default"].Map({value:n,storeStates:u,dispatchId:i}))}function E(t,e){var n=y(e);return t.getIn(["cache",n,"value"])}function I(t){return t.update("dispatchId",function(t){return t+1})}function b(t,e){return t.withMutations(function(t){e.forEach(function(e){var n=t.has(e)?t.get(e)+1:1;t.set(e,n)})})}Object.defineProperty(e,"__esModule",{value:!0}),e.registerStores=o,e.replaceStores=u,e.dispatch=a,e.loadState=s,e.addObserver=c,e.getOption=f,e.removeObserver=h,e.removeObserverByEntry=l,e.reset=p,e.evaluate=_,e.serialize=d,e.resetDirtyStores=v;var O=n(3),w=r(O),T=n(9),A=r(T),D=n(5),C=n(10),z=n(11),R=n(4),L=w["default"].Record({result:null,reactorState:null})},function(t,e,n){var r=n(8);e.dispatchStart=function(t,e,n){(0,r.getOption)(t,"logDispatches")&&console.group&&(console.groupCollapsed("Dispatch: %s",e),console.group("payload"),console.debug(n),console.groupEnd())},e.dispatchError=function(t,e){(0,r.getOption)(t,"logDispatches")&&console.group&&(console.debug("Dispatch error: "+e),console.groupEnd())},e.dispatchEnd=function(t,e,n){(0,r.getOption)(t,"logDispatches")&&console.group&&((0,r.getOption)(t,"logDirtyStores")&&console.log("Stores updated:",n.toList().toJS()),(0,r.getOption)(t,"logAppState")&&console.debug("Dispatch done, new state: ",e.toJS()),console.groupEnd())}},function(t,e,n){function r(t){return t&&t.__esModule?t:{"default":t}}function i(t){return(0,l.isArray)(t)&&(0,l.isFunction)(t[t.length-1])}function o(t){return t[t.length-1]}function u(t){return t.slice(0,t.length-1)}function a(t,e){e||(e=h["default"].Set());var n=h["default"].Set().withMutations(function(e){if(!i(t))throw new Error("getFlattenedDeps must be passed a Getter");u(t).forEach(function(t){if((0,p.isKeyPath)(t))e.add((0,f.List)(t));else{if(!i(t))throw new Error("Invalid getter, each dependency must be a KeyPath or Getter");e.union(a(t))}})});return e.union(n)}function s(t){if(!(0,p.isKeyPath)(t))throw new Error("Cannot create Getter from KeyPath: "+t);return[t,_]}function c(t){if(t.hasOwnProperty("__storeDeps"))return t.__storeDeps;var e=a(t).map(function(t){return t.first()}).filter(function(t){return!!t});return Object.defineProperty(t,"__storeDeps",{enumerable:!1,configurable:!1,writable:!1,value:e}),e}Object.defineProperty(e,"__esModule",{value:!0});var f=n(3),h=r(f),l=n(4),p=n(11),_=function(t){return t};e["default"]={isGetter:i,getComputeFn:o,getFlattenedDeps:a,getStoreDeps:c,getDeps:u,fromKeyPath:s},t.exports=e["default"]},function(t,e,n){function r(t){return t&&t.__esModule?t:{"default":t}}function i(t){return(0,s.isArray)(t)&&!(0,s.isFunction)(t[t.length-1])}function o(t,e){var n=a["default"].List(t),r=a["default"].List(e);return a["default"].is(n,r)}Object.defineProperty(e,"__esModule",{value:!0}),e.isKeyPath=i,e.isEqual=o;var u=n(3),a=r(u),s=n(4)},function(t,e,n){Object.defineProperty(e,"__esModule",{value:!0});var r=n(3),i=(0,r.Map)({logDispatches:!1,logAppState:!1,logDirtyStores:!1,throwOnUndefinedActionType:!1,throwOnUndefinedStoreReturnValue:!1,throwOnNonImmutableStore:!1,throwOnDispatchInDispatch:!1});e.PROD_OPTIONS=i;var o=(0,r.Map)({logDispatches:!0,logAppState:!0,logDirtyStores:!0,throwOnUndefinedActionType:!0,throwOnUndefinedStoreReturnValue:!0,throwOnNonImmutableStore:!0,throwOnDispatchInDispatch:!0});e.DEBUG_OPTIONS=o;var u=(0,r.Record)({dispatchId:0,state:(0,r.Map)(),stores:(0,r.Map)(),cache:(0,r.Map)(),storeStates:(0,r.Map)(),dirtyStores:(0,r.Set)(),debug:!1,options:i});e.ReactorState=u;var a=(0,r.Record)({any:(0,r.Set)(),stores:(0,r.Map)({}),observersMap:(0,r.Map)({}),nextId:1});e.ObserverState=a}])})}),Ce=t(De),ze=e(function(t){var e=function(t){var e,n={};if(!(t instanceof Object)||Array.isArray(t))throw new Error("keyMirror(...): Argument must be an object.");for(e in t)t.hasOwnProperty(e)&&(n[e]=e);return n};t.exports=e}),Re=t(ze),Le=Re({VALIDATING_AUTH_TOKEN:null,VALID_AUTH_TOKEN:null,INVALID_AUTH_TOKEN:null,LOG_OUT:null}),Me=Ce.Store,je=Ce.toImmutable,Ne=new Me({getInitialState:function(){return je({isValidating:!1,authToken:!1,host:null,isInvalid:!1,errorMessage:""})},initialize:function(){this.on(Le.VALIDATING_AUTH_TOKEN,n),this.on(Le.VALID_AUTH_TOKEN,r),this.on(Le.INVALID_AUTH_TOKEN,i)}}),ke=Ce.Store,Ue=Ce.toImmutable,Pe=new ke({getInitialState:function(){return Ue({authToken:null,host:""})},initialize:function(){this.on(Le.VALID_AUTH_TOKEN,o),this.on(Le.LOG_OUT,u)}}),He=Ce.Store,xe=new He({getInitialState:function(){return!0},initialize:function(){this.on(Le.VALID_AUTH_TOKEN,a)}}),Ve=Re({STREAM_START:null,STREAM_STOP:null,STREAM_ERROR:null}),qe="object"==typeof window&&"EventSource"in window,Fe=Ce.Store,Ge=Ce.toImmutable,Ke=new Fe({getInitialState:function(){return Ge({isSupported:qe,isStreaming:!1,useStreaming:!0,hasError:!1})},initialize:function(){this.on(Ve.STREAM_START,s),this.on(Ve.STREAM_STOP,c),this.on(Ve.STREAM_ERROR,f),this.on(Ve.LOG_OUT,h)}}),Ye=Re({API_FETCH_ALL_START:null,API_FETCH_ALL_SUCCESS:null,API_FETCH_ALL_FAIL:null,SYNC_SCHEDULED:null,SYNC_SCHEDULE_CANCELLED:null}),Be=Ce.Store,Je=new Be({getInitialState:function(){return!0},initialize:function(){this.on(Ye.API_FETCH_ALL_START,function(){return!0}),this.on(Ye.API_FETCH_ALL_SUCCESS,function(){return!1}),this.on(Ye.API_FETCH_ALL_FAIL,function(){return!1}),this.on(Ye.LOG_OUT,function(){return!1})}}),We=Ce.Store,Xe=new We({getInitialState:function(){return!1},initialize:function(){this.on(Ye.SYNC_SCHEDULED,function(){return!0}),this.on(Ye.SYNC_SCHEDULE_CANCELLED,function(){return!1}),this.on(Ye.LOG_OUT,function(){return!1})}}),Qe=Re({API_FETCH_SUCCESS:null,API_FETCH_START:null,API_FETCH_FAIL:null,API_SAVE_SUCCESS:null,API_SAVE_START:null,API_SAVE_FAIL:null,API_DELETE_SUCCESS:null,API_DELETE_START:null,API_DELETE_FAIL:null,LOG_OUT:null}),Ze=Ce.Store,$e=Ce.toImmutable,tn=new Ze({getInitialState:function(){return $e({})},initialize:function(){var t=this;this.on(Qe.API_FETCH_SUCCESS,l),this.on(Qe.API_SAVE_SUCCESS,l),this.on(Qe.API_DELETE_SUCCESS,p),this.on(Qe.LOG_OUT,function(){return t.getInitialState()})}}),en=e(function(t){function e(t){if(null===t||void 0===t)throw new TypeError("Object.assign cannot be called with null or undefined");return Object(t)}function n(){try{if(!Object.assign)return!1;var t=new String("abc");if(t[5]="de","5"===Object.getOwnPropertyNames(t)[0])return!1;for(var e={},n=0;n<10;n++)e["_"+String.fromCharCode(n)]=n;var r=Object.getOwnPropertyNames(e).map(function(t){return e[t]});if("0123456789"!==r.join(""))return!1;var i={};return"abcdefghijklmnopqrst".split("").forEach(function(t){i[t]=t}),"abcdefghijklmnopqrst"===Object.keys(Object.assign({},i)).join("")}catch(o){return!1}}var r=Object.prototype.hasOwnProperty,i=Object.prototype.propertyIsEnumerable;t.exports=n()?Object.assign:function(t,n){for(var o,u,a=arguments,s=e(t),c=1;c \ No newline at end of file +var r=t.propertyDataFromStyles(n._styles,this),i=!this.__notStyleScopeCacheable;i&&(r.key.customStyle=this.customStyle,e=n._styleCache.retrieve(this.is,r.key,this._styles));var a=Boolean(e);a?this._styleProperties=e._styleProperties:this._computeStyleProperties(r.properties),this._computeOwnStyleProperties(),a||(e=o.retrieve(this.is,this._ownStyleProperties,this._styles));var l=Boolean(e)&&!a,h=this._applyStyleProperties(e);a||(h=h&&s?h.cloneNode(!0):h,e={style:h,_scopeSelector:this._scopeSelector,_styleProperties:this._styleProperties},i&&(r.key.customStyle={},this.mixin(r.key.customStyle,this.customStyle),n._styleCache.store(this.is,e,r.key,this._styles)),l||o.store(this.is,Object.create(e),this._ownStyleProperties,this._styles))},_computeStyleProperties:function(e){var n=this._findStyleHost();n._styleProperties||n._computeStyleProperties();var r=Object.create(n._styleProperties),s=t.hostAndRootPropertiesForScope(this);this.mixin(r,s.hostProps),e=e||t.propertyDataFromStyles(n._styles,this).properties,this.mixin(r,e),this.mixin(r,s.rootProps),t.mixinCustomStyle(r,this.customStyle),t.reify(r),this._styleProperties=r},_computeOwnStyleProperties:function(){for(var e,t={},n=0;n0&&l.push(t);return[{removed:a,added:l}]}},Polymer.Collection.get=function(e){return Polymer._collections.get(e)||new Polymer.Collection(e)},Polymer.Collection.applySplices=function(e,t){var n=Polymer._collections.get(e);return n?n._applySplices(t):null},Polymer({is:"dom-repeat",extends:"template",_template:null,properties:{items:{type:Array},as:{type:String,value:"item"},indexAs:{type:String,value:"index"},sort:{type:Function,observer:"_sortChanged"},filter:{type:Function,observer:"_filterChanged"},observe:{type:String,observer:"_observeChanged"},delay:Number,renderedItemCount:{type:Number,notify:!0,readOnly:!0},initialCount:{type:Number,observer:"_initializeChunking"},targetFramerate:{type:Number,value:20},_targetFrameTime:{type:Number,computed:"_computeFrameTime(targetFramerate)"}},behaviors:[Polymer.Templatizer],observers:["_itemsChanged(items.*)"],created:function(){this._instances=[],this._pool=[],this._limit=1/0;var e=this;this._boundRenderChunk=function(){e._renderChunk()}},detached:function(){this.__isDetached=!0;for(var e=0;e=0;t--){var n=this._instances[t];n.isPlaceholder&&t=this._limit&&(n=this._downgradeInstance(t,n.__key__)),e[n.__key__]=t,n.isPlaceholder||n.__setProperty(this.indexAs,t,!0)}this._pool.length=0,this._setRenderedItemCount(this._instances.length),this.fire("dom-change"),this._tryRenderChunk()},_applyFullRefresh:function(){var e,t=this.collection;if(this._sortFn)e=t?t.getKeys():[];else{e=[];var n=this.items;if(n)for(var r=0;r=r;a--)this._detachAndRemoveInstance(a)},_numericSort:function(e,t){return e-t},_applySplicesUserSort:function(e){for(var t,n,r=this.collection,s={},i=0;i=0;i--){var h=a[i];void 0!==h&&this._detachAndRemoveInstance(h)}var c=this;if(l.length){this._filterFn&&(l=l.filter(function(e){return c._filterFn(r.getItem(e))})),l.sort(function(e,t){return c._sortFn(r.getItem(e),r.getItem(t))});var u=0;for(i=0;i>1,a=this._instances[o].__key__,l=this._sortFn(n.getItem(a),r);if(l<0)e=o+1;else{if(!(l>0)){i=o;break}s=o-1}}return i<0&&(i=s+1),this._insertPlaceholder(i,t),i},_applySplicesArrayOrder:function(e){for(var t,n=0;n=0?(e=this.as+"."+e.substring(n+1),i._notifyPath(e,t,!0)):i.__setProperty(this.as,t,!0))}},itemForElement:function(e){var t=this.modelForElement(e);return t&&t[this.as]},keyForElement:function(e){var t=this.modelForElement(e);return t&&t.__key__},indexForElement:function(e){var t=this.modelForElement(e);return t&&t[this.indexAs]}}),Polymer({is:"array-selector",_template:null,properties:{items:{type:Array,observer:"clearSelection"},multi:{type:Boolean,value:!1,observer:"clearSelection"},selected:{type:Object,notify:!0},selectedItem:{type:Object,notify:!0},toggle:{type:Boolean,value:!1}},clearSelection:function(){if(Array.isArray(this.selected))for(var e=0;e \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/frontend.html.gz b/homeassistant/components/frontend/www_static/frontend.html.gz index 5997604af41..bde1fe17a8d 100644 Binary files a/homeassistant/components/frontend/www_static/frontend.html.gz and b/homeassistant/components/frontend/www_static/frontend.html.gz differ diff --git a/homeassistant/components/frontend/www_static/home-assistant-polymer b/homeassistant/components/frontend/www_static/home-assistant-polymer index 474366c536e..670ba0292bf 160000 --- a/homeassistant/components/frontend/www_static/home-assistant-polymer +++ b/homeassistant/components/frontend/www_static/home-assistant-polymer @@ -1 +1 @@ -Subproject commit 474366c536ec3e471da12d5f15b07b79fe9b07e2 +Subproject commit 670ba0292bfca2b65aeca70804c0856b6cabf10e diff --git a/homeassistant/components/frontend/www_static/images/notification-badge.png b/homeassistant/components/frontend/www_static/images/notification-badge.png new file mode 100644 index 00000000000..2d254444915 Binary files /dev/null and b/homeassistant/components/frontend/www_static/images/notification-badge.png differ diff --git a/homeassistant/components/frontend/www_static/manifest.json b/homeassistant/components/frontend/www_static/manifest.json deleted file mode 100644 index 4cd13ad5470..00000000000 --- a/homeassistant/components/frontend/www_static/manifest.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "Home Assistant", - "short_name": "Assistant", - "start_url": "/", - "display": "standalone", - "theme_color": "#03A9F4", - "background_color": "#FFFFFF", - "icons": [ - { - "src": "/static/icons/favicon-192x192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "/static/icons/favicon-384x384.png", - "sizes": "384x384", - "type": "image/png" - }, - { - "src": "/static/icons/favicon-512x512.png", - "sizes": "512x512", - "type": "image/png" - }, - { - "src": "/static/icons/favicon-1024x1024.png", - "sizes": "1024x1024", - "type": "image/png" - } - ] -} diff --git a/homeassistant/components/frontend/www_static/mdi.html b/homeassistant/components/frontend/www_static/mdi.html index 8bc5ae36aef..ff359e902b3 100644 --- a/homeassistant/components/frontend/www_static/mdi.html +++ b/homeassistant/components/frontend/www_static/mdi.html @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/mdi.html.gz b/homeassistant/components/frontend/www_static/mdi.html.gz index 37bbb7ebf52..3ab4238a397 100644 Binary files a/homeassistant/components/frontend/www_static/mdi.html.gz and b/homeassistant/components/frontend/www_static/mdi.html.gz differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html.gz index 7ba8aeef59f..686df6e41ee 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html.gz differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html.gz index 737bbbd3f19..f2414ac3907 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html.gz differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html.gz index 3f13ec3811f..95cdd62d016 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html.gz differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html.gz index fce404f74bb..679cc353108 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html.gz differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html.gz index 0a1b22972c6..997d951f324 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html.gz differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-history.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-history.html.gz index b56925edc47..c0368bed006 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-history.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-history.html.gz differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.html.gz index e48eabf9a6d..7425bda4684 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.html.gz differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html.gz index 725c5f2b91d..124f8f5ace2 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html.gz differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz index 7b1c5b24b82..97afe1f0e84 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz differ diff --git a/homeassistant/components/frontend/www_static/service_worker.js b/homeassistant/components/frontend/www_static/service_worker.js index d3a04d4e9b3..d7c78d557d4 100644 --- a/homeassistant/components/frontend/www_static/service_worker.js +++ b/homeassistant/components/frontend/www_static/service_worker.js @@ -1 +1 @@ -"use strict";function setOfCachedUrls(e){return e.keys().then(function(e){return e.map(function(e){return e.url})}).then(function(e){return new Set(e)})}var precacheConfig=[["/","a463cb982f337e09c3ed47c41b2d9dda"],["/frontend/panels/dev-event-3cc881ae8026c0fba5aa67d334a3ab2b.html","e22ed0d2d10777c87eb9620d81f525b4"],["/frontend/panels/dev-info-34e2df1af32e60fffcafe7e008a92169.html","7e939dc762dc0c0ec769db4ea76a4b09"],["/frontend/panels/dev-service-bb5c587ada694e0fd42ceaaedd6fe6aa.html","782c4860c5e8ab274231ba9dfd528f29"],["/frontend/panels/dev-state-4608326978256644c42b13940c028e0a.html","26758b741ac1b7c8e9cfcb24762d8774"],["/frontend/panels/dev-template-0a099d4589636ed3038a3e9f020468a7.html","99114026cf9193263c74cc25f9f6a469"],["/frontend/panels/map-af7d04aff7dd5479c5a0016bc8d4dd7d.html","6031df1b4d23d5b321208449b2d293f8"],["/static/core-457d5acd123e7dc38947c07984b3a5e8.js","69e2a5b421d7ed7a7e70390cd9ced80e"],["/static/frontend-829ee7cb591b8a63d7f22948a7aeb07a.html","2afa980f1c1fdf9e596580112ac8e51a"],["/static/mdi-b399b5d3798f5b68b0a4fbaae3432d48.html","819d479ae2b690589687469045b22c26"],["static/fonts/roboto/Roboto-Bold.ttf","d329cc8b34667f114a95422aaad1b063"],["static/fonts/roboto/Roboto-Light.ttf","7b5fb88f12bec8143f00e21bc3222124"],["static/fonts/roboto/Roboto-Medium.ttf","fe13e4170719c2fc586501e777bde143"],["static/fonts/roboto/Roboto-Regular.ttf","ac3f799d5bbaf5196fab15ab8de8431c"],["static/icons/favicon-192x192.png","419903b8422586a7e28021bbe9011175"],["static/icons/favicon.ico","04235bda7843ec2fceb1cbe2bc696cf4"],["static/images/card_media_player_bg.png","a34281d1c1835d338a642e90930e61aa"],["static/webcomponents-lite.min.js","b0f32ad3c7749c40d486603f31c9d8b1"]],cacheName="sw-precache-v2--"+(self.registration?self.registration.scope:""),ignoreUrlParametersMatching=[/^utm_/],addDirectoryIndex=function(e,t){var a=new URL(e);return"/"===a.pathname.slice(-1)&&(a.pathname+=t),a.toString()},createCacheKey=function(e,t,a,n){var c=new URL(e);return n&&c.toString().match(n)||(c.search+=(c.search?"&":"")+encodeURIComponent(t)+"="+encodeURIComponent(a)),c.toString()},isPathWhitelisted=function(e,t){if(0===e.length)return!0;var a=new URL(t).pathname;return e.some(function(e){return a.match(e)})},stripIgnoredUrlParameters=function(e,t){var a=new URL(e);return a.search=a.search.slice(1).split("&").map(function(e){return e.split("=")}).filter(function(e){return t.every(function(t){return!t.test(e[0])})}).map(function(e){return e.join("=")}).join("&"),a.toString()},hashParamName="_sw-precache",urlsToCacheKeys=new Map(precacheConfig.map(function(e){var t=e[0],a=e[1],n=new URL(t,self.location),c=createCacheKey(n,hashParamName,a,!1);return[n.toString(),c]}));self.addEventListener("install",function(e){e.waitUntil(caches.open(cacheName).then(function(e){return setOfCachedUrls(e).then(function(t){return Promise.all(Array.from(urlsToCacheKeys.values()).map(function(a){if(!t.has(a))return e.add(new Request(a,{credentials:"same-origin"}))}))})}).then(function(){return self.skipWaiting()}))}),self.addEventListener("activate",function(e){var t=new Set(urlsToCacheKeys.values());e.waitUntil(caches.open(cacheName).then(function(e){return e.keys().then(function(a){return Promise.all(a.map(function(a){if(!t.has(a.url))return e.delete(a)}))})}).then(function(){return self.clients.claim()}))}),self.addEventListener("fetch",function(e){if("GET"===e.request.method){var t,a=stripIgnoredUrlParameters(e.request.url,ignoreUrlParametersMatching);t=urlsToCacheKeys.has(a);var n="index.html";!t&&n&&(a=addDirectoryIndex(a,n),t=urlsToCacheKeys.has(a));var c="/";!t&&c&&"navigate"===e.request.mode&&isPathWhitelisted(["^((?!(static|api|local|service_worker.js)).)*$"],e.request.url)&&(a=new URL(c,self.location).toString(),t=urlsToCacheKeys.has(a)),t&&e.respondWith(caches.open(cacheName).then(function(e){return e.match(urlsToCacheKeys.get(a))}).catch(function(t){return console.warn('Couldn\'t serve response for "%s" from cache: %O',e.request.url,t),fetch(e.request)}))}}); \ No newline at end of file +"use strict";function setOfCachedUrls(e){return e.keys().then(function(e){return e.map(function(e){return e.url})}).then(function(e){return new Set(e)})}function notificationEventCallback(e,t){firePushCallback({action:t.action,data:t.notification.data,tag:t.notification.tag,type:e},t.notification.data.jwt)}function firePushCallback(e,t){delete e.data.jwt,0===Object.keys(e.data).length&&e.data.constructor===Object&&delete e.data,fetch("/api/notify.html5/callback",{method:"POST",headers:new Headers({"Content-Type":"application/json",Authorization:"Bearer "+t}),body:JSON.stringify(e)})}var precacheConfig=[["/","cf23e37da78b0dfa560d4a1895b39f76"],["/frontend/panels/dev-event-3cc881ae8026c0fba5aa67d334a3ab2b.html","e22ed0d2d10777c87eb9620d81f525b4"],["/frontend/panels/dev-info-34e2df1af32e60fffcafe7e008a92169.html","7e939dc762dc0c0ec769db4ea76a4b09"],["/frontend/panels/dev-service-bb5c587ada694e0fd42ceaaedd6fe6aa.html","782c4860c5e8ab274231ba9dfd528f29"],["/frontend/panels/dev-state-4608326978256644c42b13940c028e0a.html","26758b741ac1b7c8e9cfcb24762d8774"],["/frontend/panels/dev-template-0a099d4589636ed3038a3e9f020468a7.html","99114026cf9193263c74cc25f9f6a469"],["/frontend/panels/map-af7d04aff7dd5479c5a0016bc8d4dd7d.html","6031df1b4d23d5b321208449b2d293f8"],["/static/core-1fd10c1fcdf56a61f60cf861d5a0368c.js","800ebb1bbb48274790f2ee1a2e53a24c"],["/static/frontend-88c97d278de3320278da6c32fe9e7d61.html","be147f0848a4730291bac9cdb76e2d65"],["/static/mdi-710b84acc99b32514f52291aba9cd8e8.html","149c8eaf6bb78a9b642c7bcedab86900"],["static/fonts/roboto/Roboto-Bold.ttf","d329cc8b34667f114a95422aaad1b063"],["static/fonts/roboto/Roboto-Light.ttf","7b5fb88f12bec8143f00e21bc3222124"],["static/fonts/roboto/Roboto-Medium.ttf","fe13e4170719c2fc586501e777bde143"],["static/fonts/roboto/Roboto-Regular.ttf","ac3f799d5bbaf5196fab15ab8de8431c"],["static/icons/favicon-192x192.png","419903b8422586a7e28021bbe9011175"],["static/icons/favicon.ico","04235bda7843ec2fceb1cbe2bc696cf4"],["static/images/card_media_player_bg.png","a34281d1c1835d338a642e90930e61aa"],["static/webcomponents-lite.min.js","b0f32ad3c7749c40d486603f31c9d8b1"]],cacheName="sw-precache-v2--"+(self.registration?self.registration.scope:""),ignoreUrlParametersMatching=[/^utm_/],addDirectoryIndex=function(e,t){var a=new URL(e);return"/"===a.pathname.slice(-1)&&(a.pathname+=t),a.toString()},createCacheKey=function(e,t,a,n){var c=new URL(e);return n&&c.toString().match(n)||(c.search+=(c.search?"&":"")+encodeURIComponent(t)+"="+encodeURIComponent(a)),c.toString()},isPathWhitelisted=function(e,t){if(0===e.length)return!0;var a=new URL(t).pathname;return e.some(function(e){return a.match(e)})},stripIgnoredUrlParameters=function(e,t){var a=new URL(e);return a.search=a.search.slice(1).split("&").map(function(e){return e.split("=")}).filter(function(e){return t.every(function(t){return!t.test(e[0])})}).map(function(e){return e.join("=")}).join("&"),a.toString()},hashParamName="_sw-precache",urlsToCacheKeys=new Map(precacheConfig.map(function(e){var t=e[0],a=e[1],n=new URL(t,self.location),c=createCacheKey(n,hashParamName,a,!1);return[n.toString(),c]}));self.addEventListener("install",function(e){e.waitUntil(caches.open(cacheName).then(function(e){return setOfCachedUrls(e).then(function(t){return Promise.all(Array.from(urlsToCacheKeys.values()).map(function(a){if(!t.has(a))return e.add(new Request(a,{credentials:"same-origin"}))}))})}).then(function(){return self.skipWaiting()}))}),self.addEventListener("activate",function(e){var t=new Set(urlsToCacheKeys.values());e.waitUntil(caches.open(cacheName).then(function(e){return e.keys().then(function(a){return Promise.all(a.map(function(a){if(!t.has(a.url))return e["delete"](a)}))})}).then(function(){return self.clients.claim()}))}),self.addEventListener("fetch",function(e){if("GET"===e.request.method){var t,a=stripIgnoredUrlParameters(e.request.url,ignoreUrlParametersMatching);t=urlsToCacheKeys.has(a);var n="index.html";!t&&n&&(a=addDirectoryIndex(a,n),t=urlsToCacheKeys.has(a));var c="/";!t&&c&&"navigate"===e.request.mode&&isPathWhitelisted(["^((?!(static|api|local|service_worker.js|manifest.json)).)*$"],e.request.url)&&(a=new URL(c,self.location).toString(),t=urlsToCacheKeys.has(a)),t&&e.respondWith(caches.open(cacheName).then(function(e){return e.match(urlsToCacheKeys.get(a))})["catch"](function(t){return console.warn('Couldn\'t serve response for "%s" from cache: %O',e.request.url,t),fetch(e.request)}))}}),self.addEventListener("push",function(e){var t;e.data&&(t=e.data.json(),e.waitUntil(self.registration.showNotification(t.title,t).then(function(e){firePushCallback({type:"received",tag:t.tag,data:t.data},t.data.jwt)})))}),self.addEventListener("notificationclick",function(e){var t;notificationEventCallback("clicked",e),e.notification.close(),e.notification.data&&e.notification.data.url&&(t=e.notification.data.url,t&&e.waitUntil(clients.matchAll({type:"window"}).then(function(e){var a,n;for(a=0;a bool: + """Check http response for successful status.""" + return 'status' in response and response['status'] == 'succeeded' - This will automatically import associated lights. - """ + +def filter_devices(devices: list, categories: list) -> list: + """Filter insteon device list by category/subcategory.""" + categories = (categories + if isinstance(categories, list) + else [categories]) + matching_devices = [] + for device in devices: + if any( + device.DevCat == c[DEVCAT] and + (SUBCAT not in c or device.SubCat in c[SUBCAT]) + for c in categories): + matching_devices.append(device) + return matching_devices + + +def setup(hass, config: dict) -> bool: + """Setup Insteon Hub component.""" if not validate_config( config, {DOMAIN: [CONF_USERNAME, CONF_PASSWORD, CONF_API_KEY]}, _LOGGER): return False - import insteon + from insteon import Insteon username = config[DOMAIN][CONF_USERNAME] password = config[DOMAIN][CONF_PASSWORD] api_key = config[DOMAIN][CONF_API_KEY] global INSTEON - INSTEON = insteon.Insteon(username, password, api_key) + INSTEON = Insteon(username, password, api_key) if INSTEON is None: - _LOGGER.error("Could not connect to Insteon service.") + _LOGGER.error('Could not connect to Insteon service.') return - discovery.load_platform(hass, 'light', DOMAIN, {}, config) - + for device_class in DEVICE_CLASSES: + discovery.load_platform(hass, device_class, DOMAIN, {}, config) return True + + +class InsteonDevice(Entity): + """Represents an insteon device.""" + + def __init__(self: Entity, node: object) -> None: + """Initialize the insteon device.""" + self._node = node + + def update(self: Entity) -> None: + """Update state of the device.""" + pass + + @property + def name(self: Entity) -> str: + """Name of the insteon device.""" + return self._node.DeviceName + + @property + def unique_id(self: Entity) -> str: + """Unique identifier for the device.""" + return self._node.DeviceID + + @property + def supported_features(self: Entity) -> int: + """Supported feature flags.""" + return 0 + + def _send_command(self: Entity, command: str, level: int=None, + payload: dict=None) -> bool: + """Send command to insteon device.""" + resp = self._node.send_command(command, payload=payload, level=level, + wait=True) + return _is_successful(resp) diff --git a/homeassistant/components/knx.py b/homeassistant/components/knx.py index 763b9d81ded..ec837cbf7b6 100644 --- a/homeassistant/components/knx.py +++ b/homeassistant/components/knx.py @@ -10,7 +10,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.helpers.entity import Entity DOMAIN = "knx" -REQUIREMENTS = ['knxip==0.3.2'] +REQUIREMENTS = ['knxip==0.3.3'] EVENT_KNX_FRAME_RECEIVED = "knx_frame_received" diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 2a87d2e88bb..23afa58b628 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -30,6 +30,16 @@ ENTITY_ID_ALL_LIGHTS = group.ENTITY_ID_FORMAT.format('all_lights') ENTITY_ID_FORMAT = DOMAIN + ".{}" +# Bitfield of features supported by the light entity +ATTR_SUPPORTED_FEATURES = 'supported_features' +SUPPORT_BRIGHTNESS = 1 +SUPPORT_COLOR_TEMP = 2 +SUPPORT_EFFECT = 4 +SUPPORT_FLASH = 8 +SUPPORT_RGB_COLOR = 16 +SUPPORT_TRANSITION = 32 +SUPPORT_XY_COLOR = 64 + # Integer that represents transition time in seconds to make change. ATTR_TRANSITION = "transition" @@ -63,6 +73,7 @@ PROP_TO_ATTR = { 'color_temp': ATTR_COLOR_TEMP, 'rgb_color': ATTR_RGB_COLOR, 'xy_color': ATTR_XY_COLOR, + 'supported_features': ATTR_SUPPORTED_FEATURES, } # Service call validation schemas @@ -279,7 +290,7 @@ class Light(ToggleEntity): if self.is_on: for prop, attr in PROP_TO_ATTR.items(): value = getattr(self, prop) - if value: + if value is not None: data[attr] = value if ATTR_RGB_COLOR not in data and ATTR_XY_COLOR in data and \ @@ -287,5 +298,12 @@ class Light(ToggleEntity): data[ATTR_RGB_COLOR] = color_util.color_xy_brightness_to_RGB( data[ATTR_XY_COLOR][0], data[ATTR_XY_COLOR][1], data[ATTR_BRIGHTNESS]) + else: + data[ATTR_SUPPORTED_FEATURES] = self.supported_features return data + + @property + def supported_features(self): + """Flag supported features.""" + return 0 diff --git a/homeassistant/components/light/blinksticklight.py b/homeassistant/components/light/blinksticklight.py index 9e42a41cb91..627097d47b9 100644 --- a/homeassistant/components/light/blinksticklight.py +++ b/homeassistant/components/light/blinksticklight.py @@ -6,22 +6,37 @@ https://home-assistant.io/components/light.blinksticklight/ """ import logging -from homeassistant.components.light import ATTR_RGB_COLOR, Light +import voluptuous as vol + +from homeassistant.components.light import (ATTR_RGB_COLOR, SUPPORT_RGB_COLOR, + Light, PLATFORM_SCHEMA) +from homeassistant.const import CONF_NAME +import homeassistant.helpers.config_validation as cv + +CONF_SERIAL = 'serial' +DEFAULT_NAME = 'Blinkstick' +REQUIREMENTS = ["blinkstick==1.1.8"] +SUPPORT_BLINKSTICK = SUPPORT_RGB_COLOR + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SERIAL): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ["blinkstick==1.1.7"] - - # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices_callback, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Add device specified by serial number.""" from blinkstick import blinkstick - stick = blinkstick.find_by_serial(config['serial']) + name = config.get(CONF_NAME) + serial = config.get(CONF_SERIAL) - add_devices_callback([BlinkStickLight(stick, config['name'])]) + stick = blinkstick.find_by_serial(serial) + + add_devices([BlinkStickLight(stick, name)]) class BlinkStickLight(Light): @@ -54,6 +69,11 @@ class BlinkStickLight(Light): """Check whether any of the LEDs colors are non-zero.""" return sum(self._rgb_color) > 0 + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_BLINKSTICK + def update(self): """Read back the device state.""" self._rgb_color = self._stick.get_color() diff --git a/homeassistant/components/light/demo.py b/homeassistant/components/light/demo.py index 96095c49a39..4eb0a61d983 100644 --- a/homeassistant/components/light/demo.py +++ b/homeassistant/components/light/demo.py @@ -7,7 +7,8 @@ https://home-assistant.io/components/demo/ import random from homeassistant.components.light import ( - ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_RGB_COLOR, Light) + ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_RGB_COLOR, SUPPORT_BRIGHTNESS, + SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR, Light) LIGHT_COLORS = [ [237, 224, 33], @@ -16,6 +17,8 @@ LIGHT_COLORS = [ LIGHT_TEMPS = [240, 380] +SUPPORT_DEMO = SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_RGB_COLOR + def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Setup the demo light platform.""" @@ -68,6 +71,11 @@ class DemoLight(Light): """Return true if light is on.""" return self._state + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_DEMO + def turn_on(self, **kwargs): """Turn the light on.""" self._state = True diff --git a/homeassistant/components/light/enocean.py b/homeassistant/components/light/enocean.py index 2c9db86e662..772cb55c4e4 100644 --- a/homeassistant/components/light/enocean.py +++ b/homeassistant/components/light/enocean.py @@ -7,24 +7,34 @@ https://home-assistant.io/components/light.enocean/ import logging import math -from homeassistant.components.light import Light, ATTR_BRIGHTNESS -from homeassistant.const import CONF_NAME -from homeassistant.components import enocean +import voluptuous as vol +from homeassistant.components.light import ( + Light, ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, PLATFORM_SCHEMA) +from homeassistant.const import (CONF_NAME, CONF_ID) +from homeassistant.components import enocean +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -DEPENDENCIES = ["enocean"] +DEPENDENCIES = ['enocean'] +DEFAULT_NAME = 'EnOcean Light' +CONF_SENDER_ID = 'sender_id' -CONF_ID = "id" -CONF_SENDER_ID = "sender_id" +SUPPORT_ENOCEAN = SUPPORT_BRIGHTNESS + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ID): cv.string, + vol.Required(CONF_SENDER_ID): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the EnOcean light platform.""" - sender_id = config.get(CONF_SENDER_ID, None) - devname = config.get(CONF_NAME, "Enocean actuator") - dev_id = config.get(CONF_ID, [0x00, 0x00, 0x00, 0x00]) + sender_id = config.get(CONF_SENDER_ID) + devname = config.get(CONF_NAME) + dev_id = config.get(CONF_ID) add_devices([EnOceanLight(sender_id, devname, dev_id)]) @@ -61,6 +71,11 @@ class EnOceanLight(enocean.EnOceanDevice, Light): """If light is on.""" return self._on_state + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_ENOCEAN + def turn_on(self, **kwargs): """Turn the light source on or sets a specific dimmer value.""" brightness = kwargs.get(ATTR_BRIGHTNESS) diff --git a/homeassistant/components/light/flux_led.py b/homeassistant/components/light/flux_led.py index ed696b0654e..13a52fcb1a1 100644 --- a/homeassistant/components/light/flux_led.py +++ b/homeassistant/components/light/flux_led.py @@ -10,7 +10,8 @@ import socket import voluptuous as vol from homeassistant.components.light import (ATTR_BRIGHTNESS, ATTR_RGB_COLOR, - Light) + SUPPORT_BRIGHTNESS, + SUPPORT_RGB_COLOR, Light) import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['https://github.com/Danielhiversen/flux_led/archive/0.6.zip' @@ -30,6 +31,8 @@ PLATFORM_SCHEMA = vol.Schema({ vol.Optional('automatic_add', default=False): cv.boolean, }, extra=vol.ALLOW_EXTRA) +SUPPORT_FLUX_LED = SUPPORT_BRIGHTNESS | SUPPORT_RGB_COLOR + def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Setup the Flux lights.""" @@ -110,6 +113,11 @@ class FluxLight(Light): """Return the color property.""" return self._bulb.getRgb() + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_FLUX_LED + def turn_on(self, **kwargs): """Turn the specified or all lights on.""" if not self.is_on: diff --git a/homeassistant/components/light/homematic.py b/homeassistant/components/light/homematic.py index b7e0328a574..2e233e0e3ff 100644 --- a/homeassistant/components/light/homematic.py +++ b/homeassistant/components/light/homematic.py @@ -5,7 +5,8 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.homematic/ """ import logging -from homeassistant.components.light import (ATTR_BRIGHTNESS, Light) +from homeassistant.components.light import (ATTR_BRIGHTNESS, + SUPPORT_BRIGHTNESS, Light) from homeassistant.const import STATE_UNKNOWN import homeassistant.components.homematic as homematic @@ -13,6 +14,8 @@ _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['homematic'] +SUPPORT_HOMEMATIC = SUPPORT_BRIGHTNESS + def setup_platform(hass, config, add_callback_devices, discovery_info=None): """Setup the Homematic light platform.""" @@ -46,6 +49,11 @@ class HMLight(homematic.HMDevice, Light): except TypeError: return False + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_HOMEMATIC + def turn_on(self, **kwargs): """Turn the light on.""" if not self.available: diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index 1901af8ee4a..b818f4ee932 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -17,7 +17,9 @@ import homeassistant.util.color as color_util from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, ATTR_RGB_COLOR, ATTR_TRANSITION, ATTR_XY_COLOR, EFFECT_COLORLOOP, EFFECT_RANDOM, - FLASH_LONG, FLASH_SHORT, Light) + FLASH_LONG, FLASH_SHORT, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, + SUPPORT_EFFECT, SUPPORT_FLASH, SUPPORT_RGB_COLOR, SUPPORT_TRANSITION, + SUPPORT_XY_COLOR, Light) from homeassistant.const import CONF_FILENAME, CONF_HOST, DEVICE_DEFAULT_NAME from homeassistant.loader import get_component @@ -27,6 +29,10 @@ MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) PHUE_CONFIG_FILE = "phue.conf" +SUPPORT_HUE = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_EFFECT | + SUPPORT_FLASH | SUPPORT_RGB_COLOR | SUPPORT_TRANSITION | + SUPPORT_XY_COLOR) + # Map ip to request id for configuring _CONFIGURING = {} @@ -228,6 +234,11 @@ class HueLight(Light): else: return self.info['state']['reachable'] and self.info['state']['on'] + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_HUE + def turn_on(self, **kwargs): """Turn the specified or all lights on.""" command = {'on': True} diff --git a/homeassistant/components/light/hyperion.py b/homeassistant/components/light/hyperion.py index 8a03048d0bc..139edd9188e 100644 --- a/homeassistant/components/light/hyperion.py +++ b/homeassistant/components/light/hyperion.py @@ -8,12 +8,15 @@ import json import logging import socket -from homeassistant.components.light import ATTR_RGB_COLOR, Light +from homeassistant.components.light import (ATTR_RGB_COLOR, SUPPORT_RGB_COLOR, + Light) from homeassistant.const import CONF_HOST _LOGGER = logging.getLogger(__name__) REQUIREMENTS = [] +SUPPORT_HYPERION = SUPPORT_RGB_COLOR + def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Setup a Hyperion server remote.""" @@ -53,6 +56,11 @@ class Hyperion(Light): """Return true if not black.""" return self._rgb_color != [0, 0, 0] + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_HYPERION + def turn_on(self, **kwargs): """Turn the lights on.""" if ATTR_RGB_COLOR in kwargs: diff --git a/homeassistant/components/light/insteon_hub.py b/homeassistant/components/light/insteon_hub.py index 4cfa6b25b06..29254735ced 100644 --- a/homeassistant/components/light/insteon_hub.py +++ b/homeassistant/components/light/insteon_hub.py @@ -4,8 +4,13 @@ Support for Insteon Hub lights. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/insteon_hub/ """ -from homeassistant.components.insteon_hub import INSTEON -from homeassistant.components.light import ATTR_BRIGHTNESS, Light +from homeassistant.components.insteon_hub import (INSTEON, InsteonDevice) +from homeassistant.components.light import (ATTR_BRIGHTNESS, + SUPPORT_BRIGHTNESS, Light) + +SUPPORT_INSTEON_HUB = SUPPORT_BRIGHTNESS + +DEPENDENCIES = ['insteon_hub'] def setup_platform(hass, config, add_devices, discovery_info=None): @@ -13,57 +18,62 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devs = [] for device in INSTEON.devices: if device.DeviceCategory == "Switched Lighting Control": - devs.append(InsteonToggleDevice(device)) + devs.append(InsteonLightDevice(device)) if device.DeviceCategory == "Dimmable Lighting Control": - devs.append(InsteonToggleDevice(device)) + devs.append(InsteonDimmableDevice(device)) add_devices(devs) -class InsteonToggleDevice(Light): - """An abstract Class for an Insteon node.""" +class InsteonLightDevice(InsteonDevice, Light): + """A representation of a light device.""" - def __init__(self, node): + def __init__(self, node: object) -> None: """Initialize the device.""" - self.node = node + super(InsteonLightDevice, self).__init__(node) self._value = 0 - @property - def name(self): - """Return the the name of the node.""" - return self.node.DeviceName - - @property - def unique_id(self): - """Return the ID of this insteon node.""" - return self.node.DeviceID - - @property - def brightness(self): - """Return the brightness of this light between 0..255.""" - return self._value / 100 * 255 - - def update(self): - """Update state of the sensor.""" - resp = self.node.send_command('get_status', wait=True) + def update(self) -> None: + """Update state of the device.""" + resp = self._node.send_command('get_status', wait=True) try: self._value = resp['response']['level'] except KeyError: pass @property - def is_on(self): + def is_on(self) -> None: """Return the boolean response if the node is on.""" return self._value != 0 - def turn_on(self, **kwargs): + def turn_on(self, **kwargs) -> None: """Turn device on.""" - if ATTR_BRIGHTNESS in kwargs: - self._value = kwargs[ATTR_BRIGHTNESS] / 255 * 100 - self.node.send_command('on', self._value) - else: + if self._send_command('on'): self._value = 100 - self.node.send_command('on') - def turn_off(self, **kwargs): + def turn_off(self, **kwargs) -> None: """Turn device off.""" - self.node.send_command('off') + if self._send_command('off'): + self._value = 0 + + +class InsteonDimmableDevice(InsteonLightDevice): + """A representation for a dimmable device.""" + + @property + def brightness(self) -> int: + """Return the brightness of this light between 0..255.""" + return round(self._value / 100 * 255, 0) # type: int + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return SUPPORT_INSTEON_HUB + + def turn_on(self, **kwargs) -> None: + """Turn device on.""" + level = 100 # type: int + if ATTR_BRIGHTNESS in kwargs: + level = round(kwargs[ATTR_BRIGHTNESS] / 255 * 100, 0) # type: int + + if self._send_command('on', level=level): + self._value = level diff --git a/homeassistant/components/light/isy994.py b/homeassistant/components/light/isy994.py index f7261540ddd..031fa7debb6 100644 --- a/homeassistant/components/light/isy994.py +++ b/homeassistant/components/light/isy994.py @@ -8,9 +8,13 @@ import logging from homeassistant.components.isy994 import ( HIDDEN_STRING, ISY, SENSOR_STRING, ISYDeviceABC) -from homeassistant.components.light import ATTR_BRIGHTNESS +from homeassistant.components.light import (ATTR_BRIGHTNESS, + ATTR_SUPPORTED_FEATURES, + SUPPORT_BRIGHTNESS) from homeassistant.const import STATE_OFF, STATE_ON +SUPPORT_ISY994 = SUPPORT_BRIGHTNESS + def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the ISY994 platform.""" @@ -36,10 +40,18 @@ class ISYLightDevice(ISYDeviceABC): _domain = 'light' _dtype = 'analog' - _attrs = {ATTR_BRIGHTNESS: 'value'} + _attrs = { + ATTR_BRIGHTNESS: 'value', + ATTR_SUPPORTED_FEATURES: 'supported_features', + } _onattrs = [ATTR_BRIGHTNESS] _states = [STATE_ON, STATE_OFF] + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_ISY994 + def _attr_filter(self, attr): """Filter brightness out of entity while off.""" if ATTR_BRIGHTNESS in attr and not self.is_on: diff --git a/homeassistant/components/light/lifx.py b/homeassistant/components/light/lifx.py index e5b749037ad..39038fb0356 100644 --- a/homeassistant/components/light/lifx.py +++ b/homeassistant/components/light/lifx.py @@ -8,7 +8,9 @@ import colorsys import logging from homeassistant.components.light import ( - ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_RGB_COLOR, ATTR_TRANSITION, Light) + ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_RGB_COLOR, ATTR_TRANSITION, + SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR, + SUPPORT_TRANSITION, Light) from homeassistant.helpers.event import track_time_change _LOGGER = logging.getLogger(__name__) @@ -24,6 +26,9 @@ TEMP_MAX = 9000 # lifx maximum temperature TEMP_MIN_HASS = 154 # home assistant minimum temperature TEMP_MAX_HASS = 500 # home assistant maximum temperature +SUPPORT_LIFX = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_RGB_COLOR | + SUPPORT_TRANSITION) + class LIFX(): """Representation of a LIFX light.""" @@ -185,6 +190,11 @@ class LIFXLight(Light): _LOGGER.debug("is_on: %d", self._power) return self._power != 0 + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_LIFX + def turn_on(self, **kwargs): """Turn the device on.""" if ATTR_TRANSITION in kwargs: diff --git a/homeassistant/components/light/limitlessled.py b/homeassistant/components/light/limitlessled.py index 010088af824..aac28f9ced8 100644 --- a/homeassistant/components/light/limitlessled.py +++ b/homeassistant/components/light/limitlessled.py @@ -9,7 +9,9 @@ import logging from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, ATTR_RGB_COLOR, - ATTR_TRANSITION, EFFECT_COLORLOOP, EFFECT_WHITE, FLASH_LONG, Light) + ATTR_TRANSITION, EFFECT_COLORLOOP, EFFECT_WHITE, FLASH_LONG, + SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_FLASH, + SUPPORT_RGB_COLOR, SUPPORT_TRANSITION, Light) _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['limitlessled==1.0.0'] @@ -20,6 +22,12 @@ DEFAULT_VERSION = 5 DEFAULT_LED_TYPE = 'rgbw' WHITE = [255, 255, 255] +SUPPORT_LIMITLESSLED_WHITE = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | + SUPPORT_TRANSITION) +SUPPORT_LIMITLESSLED_RGB = (SUPPORT_BRIGHTNESS | SUPPORT_EFFECT | + SUPPORT_FLASH | SUPPORT_RGB_COLOR | + SUPPORT_TRANSITION) + def rewrite_legacy(config): """Rewrite legacy configuration to new format.""" @@ -168,6 +176,11 @@ class LimitlessLEDWhiteGroup(LimitlessLEDGroup): """Return the temperature property.""" return self._temperature + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_LIMITLESSLED_WHITE + @state(True) def turn_on(self, transition_time, pipeline, **kwargs): """Turn on (or adjust property of) a group.""" @@ -203,6 +216,11 @@ class LimitlessLEDRGBWGroup(LimitlessLEDGroup): """Return the color property.""" return self._color + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_LIMITLESSLED_RGB + @state(True) def turn_on(self, transition_time, pipeline, **kwargs): """Turn on (or adjust property of) a group.""" diff --git a/homeassistant/components/light/mqtt.py b/homeassistant/components/light/mqtt.py index 2d0e7bb6df0..ed8603a0ae8 100644 --- a/homeassistant/components/light/mqtt.py +++ b/homeassistant/components/light/mqtt.py @@ -11,7 +11,8 @@ import voluptuous as vol import homeassistant.components.mqtt as mqtt from homeassistant.components.light import ( - ATTR_BRIGHTNESS, ATTR_RGB_COLOR, Light) + ATTR_BRIGHTNESS, ATTR_RGB_COLOR, SUPPORT_BRIGHTNESS, SUPPORT_RGB_COLOR, + Light) from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE from homeassistant.components.mqtt import ( CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN) @@ -108,6 +109,11 @@ class MqttLight(Light): topic["brightness_state_topic"] is None) self._brightness_scale = brightness_scale self._state = False + self._supported_features = 0 + self._supported_features |= ( + topic['rgb_state_topic'] is not None and SUPPORT_RGB_COLOR) + self._supported_features |= ( + topic['brightness_state_topic'] is not None and SUPPORT_BRIGHTNESS) templates = {key: ((lambda value: value) if tpl is None else partial(render_with_possible_json_value, hass, tpl)) @@ -188,6 +194,11 @@ class MqttLight(Light): """Return true if we do optimistic updates.""" return self._optimistic + @property + def supported_features(self): + """Flag supported features.""" + return self._supported_features + def turn_on(self, **kwargs): """Turn the device on.""" should_update = False diff --git a/homeassistant/components/light/mqtt_json.py b/homeassistant/components/light/mqtt_json.py index 76db3fe9f0c..abc3c53f37f 100755 --- a/homeassistant/components/light/mqtt_json.py +++ b/homeassistant/components/light/mqtt_json.py @@ -12,7 +12,8 @@ import voluptuous as vol import homeassistant.components.mqtt as mqtt from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_RGB_COLOR, ATTR_TRANSITION, - ATTR_FLASH, FLASH_LONG, FLASH_SHORT, Light) + ATTR_FLASH, FLASH_LONG, FLASH_SHORT, SUPPORT_BRIGHTNESS, SUPPORT_FLASH, + SUPPORT_RGB_COLOR, SUPPORT_TRANSITION, Light) from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, CONF_PLATFORM from homeassistant.components.mqtt import ( CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN) @@ -36,6 +37,9 @@ CONF_RGB = "rgb" CONF_FLASH_TIME_SHORT = "flash_time_short" CONF_FLASH_TIME_LONG = "flash_time_long" +SUPPORT_MQTT_JSON = (SUPPORT_BRIGHTNESS | SUPPORT_FLASH | SUPPORT_RGB_COLOR | + SUPPORT_TRANSITION) + # Stealing some of these from the base MQTT configs. PLATFORM_SCHEMA = vol.Schema({ vol.Required(CONF_PLATFORM): DOMAIN, diff --git a/homeassistant/components/light/mysensors.py b/homeassistant/components/light/mysensors.py index d8d288afd0e..c33793127a2 100644 --- a/homeassistant/components/light/mysensors.py +++ b/homeassistant/components/light/mysensors.py @@ -9,7 +9,8 @@ import logging from homeassistant.components import mysensors from homeassistant.components.light import (ATTR_BRIGHTNESS, ATTR_RGB_COLOR, - Light) + SUPPORT_BRIGHTNESS, + SUPPORT_RGB_COLOR, Light) from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.util.color import rgb_hex_to_rgb_list @@ -18,6 +19,8 @@ ATTR_RGB_WHITE = 'rgb_white' ATTR_VALUE = 'value' ATTR_VALUE_TYPE = 'value_type' +SUPPORT_MYSENSORS = SUPPORT_BRIGHTNESS | SUPPORT_RGB_COLOR + def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the mysensors platform for sensors.""" @@ -37,7 +40,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): device_class_map = { pres.S_DIMMER: MySensorsLightDimmer, } - if float(gateway.version) >= 1.5: + if float(gateway.protocol_version) >= 1.5: # Add V_RGBW when rgb_white is implemented in the frontend map_sv_types.update({ pres.S_RGB_LIGHT: [set_req.V_RGB], @@ -87,6 +90,11 @@ class MySensorsLight(mysensors.MySensorsDeviceEntity, Light): """Return true if device is on.""" return self._state + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_MYSENSORS + def _turn_on_light(self): """Turn on light child device.""" set_req = self.gateway.const.SetReq @@ -161,7 +169,7 @@ class MySensorsLight(mysensors.MySensorsDeviceEntity, Light): def _turn_off_rgb_or_w(self, value_type=None, value=None): """Turn off RGB or RGBW child device.""" - if float(self.gateway.version) >= 1.5: + if float(self.gateway.protocol_version) >= 1.5: set_req = self.gateway.const.SetReq if self.value_type == set_req.V_RGB: value = '000000' @@ -219,7 +227,6 @@ class MySensorsLight(mysensors.MySensorsDeviceEntity, Light): """Update the controller with the latest value from a sensor.""" node = self.gateway.sensors[self.node_id] child = node.children[self.child_id] - self.battery_level = node.battery_level for value_type, value in child.values.items(): _LOGGER.debug( '%s: value_type %s, value = %s', self._name, value_type, value) diff --git a/homeassistant/components/light/osramlightify.py b/homeassistant/components/light/osramlightify.py index 243d11116da..41a226031d6 100644 --- a/homeassistant/components/light/osramlightify.py +++ b/homeassistant/components/light/osramlightify.py @@ -15,7 +15,11 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_RGB_COLOR, - ATTR_TRANSITION + ATTR_TRANSITION, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR_TEMP, + SUPPORT_RGB_COLOR, + SUPPORT_TRANSITION, ) _LOGGER = logging.getLogger(__name__) @@ -28,6 +32,9 @@ TEMP_MAX_HASS = 500 # home assistant maximum temperature MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) +SUPPORT_OSRAMLIGHTIFY = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | + SUPPORT_RGB_COLOR | SUPPORT_TRANSITION) + def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Setup Osram Lightify lights.""" @@ -114,6 +121,11 @@ class OsramLightifyLight(Light): self._light.name(), self._light.on()) return self._light.on() + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_OSRAMLIGHTIFY + def turn_on(self, **kwargs): """Turn the device on.""" brightness = 100 diff --git a/homeassistant/components/light/rfxtrx.py b/homeassistant/components/light/rfxtrx.py index 798dcc66010..f63a03c0534 100644 --- a/homeassistant/components/light/rfxtrx.py +++ b/homeassistant/components/light/rfxtrx.py @@ -7,7 +7,8 @@ https://home-assistant.io/components/light.rfxtrx/ import logging import homeassistant.components.rfxtrx as rfxtrx -from homeassistant.components.light import ATTR_BRIGHTNESS, Light +from homeassistant.components.light import (ATTR_BRIGHTNESS, + SUPPORT_BRIGHTNESS, Light) DEPENDENCIES = ['rfxtrx'] @@ -15,6 +16,8 @@ _LOGGER = logging.getLogger(__name__) PLATFORM_SCHEMA = rfxtrx.DEFAULT_SCHEMA +SUPPORT_RFXTRX = SUPPORT_BRIGHTNESS + def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Setup the RFXtrx platform.""" @@ -48,6 +51,11 @@ class RfxtrxLight(rfxtrx.RfxtrxDevice, Light): """Return the brightness of this light between 0..255.""" return self._brightness + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_RFXTRX + def turn_on(self, **kwargs): """Turn the light on.""" brightness = kwargs.get(ATTR_BRIGHTNESS) diff --git a/homeassistant/components/light/tellstick.py b/homeassistant/components/light/tellstick.py index 67d8c243ebc..3f9364a4cd5 100644 --- a/homeassistant/components/light/tellstick.py +++ b/homeassistant/components/light/tellstick.py @@ -7,13 +7,16 @@ https://home-assistant.io/components/light.tellstick/ import voluptuous as vol from homeassistant.components import tellstick -from homeassistant.components.light import ATTR_BRIGHTNESS, Light +from homeassistant.components.light import (ATTR_BRIGHTNESS, + SUPPORT_BRIGHTNESS, Light) from homeassistant.components.tellstick import (DEFAULT_SIGNAL_REPETITIONS, ATTR_DISCOVER_DEVICES, ATTR_DISCOVER_CONFIG) PLATFORM_SCHEMA = vol.Schema({vol.Required("platform"): tellstick.DOMAIN}) +SUPPORT_TELLSTICK = SUPPORT_BRIGHTNESS + # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): @@ -51,6 +54,11 @@ class TellstickLight(tellstick.TellstickDevice, Light): """Return the brightness of this light between 0..255.""" return self._brightness + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_TELLSTICK + def set_tellstick_state(self, last_command_sent, last_data_sent): """Update the internal representation of the switch.""" from tellcore.constants import TELLSTICK_TURNON, TELLSTICK_DIM diff --git a/homeassistant/components/light/vera.py b/homeassistant/components/light/vera.py index 142ccbc5b22..9e169ce4412 100644 --- a/homeassistant/components/light/vera.py +++ b/homeassistant/components/light/vera.py @@ -6,7 +6,8 @@ https://home-assistant.io/components/light.vera/ """ import logging -from homeassistant.components.light import ATTR_BRIGHTNESS, Light +from homeassistant.components.light import (ATTR_BRIGHTNESS, + SUPPORT_BRIGHTNESS, Light) from homeassistant.const import ( STATE_OFF, STATE_ON) from homeassistant.components.vera import ( @@ -16,6 +17,8 @@ DEPENDENCIES = ['vera'] _LOGGER = logging.getLogger(__name__) +SUPPORT_VERA = SUPPORT_BRIGHTNESS + # pylint: disable=unused-argument def setup_platform(hass, config, add_devices_callback, discovery_info=None): @@ -38,6 +41,11 @@ class VeraLight(VeraDevice, Light): if self.vera_device.is_dimmable: return self.vera_device.get_brightness() + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_VERA + def turn_on(self, **kwargs): """Turn the light on.""" if ATTR_BRIGHTNESS in kwargs and self.vera_device.is_dimmable: diff --git a/homeassistant/components/light/wemo.py b/homeassistant/components/light/wemo.py index a4aa6686a17..e1f741f4c73 100644 --- a/homeassistant/components/light/wemo.py +++ b/homeassistant/components/light/wemo.py @@ -11,7 +11,8 @@ import homeassistant.util as util import homeassistant.util.color as color_util from homeassistant.components.light import ( Light, ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_RGB_COLOR, ATTR_TRANSITION, - ATTR_XY_COLOR) + ATTR_XY_COLOR, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR, + SUPPORT_TRANSITION, SUPPORT_XY_COLOR) DEPENDENCIES = ['wemo'] @@ -20,6 +21,9 @@ MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) _LOGGER = logging.getLogger(__name__) +SUPPORT_WEMO = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_RGB_COLOR | + SUPPORT_TRANSITION | SUPPORT_XY_COLOR) + def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Setup WeMo bridges and register connected lights.""" @@ -96,6 +100,11 @@ class WemoLight(Light): """True if device is on.""" return self.device.state['onoff'] != 0 + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_WEMO + def turn_on(self, **kwargs): """Turn the light on.""" transitiontime = int(kwargs.get(ATTR_TRANSITION, 0)) diff --git a/homeassistant/components/light/wink.py b/homeassistant/components/light/wink.py index c4a4e1bee1b..957c3a4e116 100644 --- a/homeassistant/components/light/wink.py +++ b/homeassistant/components/light/wink.py @@ -7,14 +7,17 @@ https://home-assistant.io/components/light.wink/ import logging from homeassistant.components.light import ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, \ - Light, ATTR_RGB_COLOR + ATTR_RGB_COLOR, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, \ + SUPPORT_RGB_COLOR, Light from homeassistant.components.wink import WinkDevice from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.util import color as color_util from homeassistant.util.color import \ color_temperature_mired_to_kelvin as mired_to_kelvin -REQUIREMENTS = ['python-wink==0.7.11', 'pubnub==3.8.2'] +REQUIREMENTS = ['python-wink==0.7.13', 'pubnub==3.8.2'] + +SUPPORT_WINK = SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_RGB_COLOR def setup_platform(hass, config, add_devices_callback, discovery_info=None): @@ -68,6 +71,11 @@ class WinkLight(WinkDevice, Light): return color_util.color_temperature_kelvin_to_mired( self.wink.color_temperature_kelvin()) + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_WINK + # pylint: disable=too-few-public-methods def turn_on(self, **kwargs): """Turn the switch on.""" diff --git a/homeassistant/components/light/x10.py b/homeassistant/components/light/x10.py index a689d7604ba..40f5dfa6b73 100644 --- a/homeassistant/components/light/x10.py +++ b/homeassistant/components/light/x10.py @@ -6,10 +6,13 @@ https://home-assistant.io/components/light.x10/ """ import logging from subprocess import check_output, CalledProcessError, STDOUT -from homeassistant.components.light import ATTR_BRIGHTNESS, Light +from homeassistant.components.light import (ATTR_BRIGHTNESS, + SUPPORT_BRIGHTNESS, Light) _LOGGER = logging.getLogger(__name__) +SUPPORT_X10 = SUPPORT_BRIGHTNESS + def x10_command(command): """Execute X10 command and check output.""" @@ -64,6 +67,11 @@ class X10Light(Light): """Return true if light is on.""" return self._is_on + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_X10 + def turn_on(self, **kwargs): """Instruct the light to turn on.""" x10_command("on " + self._id) diff --git a/homeassistant/components/light/zwave.py b/homeassistant/components/light/zwave.py index 1be5aba1cda..49c4b5f8dd9 100644 --- a/homeassistant/components/light/zwave.py +++ b/homeassistant/components/light/zwave.py @@ -10,7 +10,8 @@ import logging # pylint: disable=import-error from threading import Timer from homeassistant.components.light import ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, \ - ATTR_RGB_COLOR, DOMAIN, Light + ATTR_RGB_COLOR, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, \ + SUPPORT_RGB_COLOR, DOMAIN, Light from homeassistant.components import zwave from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.util.color import HASS_COLOR_MAX, HASS_COLOR_MIN, \ @@ -41,6 +42,8 @@ TEMP_MID_HASS = (HASS_COLOR_MAX - HASS_COLOR_MIN) / 2 + HASS_COLOR_MIN TEMP_WARM_HASS = (HASS_COLOR_MAX - HASS_COLOR_MIN) / 3 * 2 + HASS_COLOR_MIN TEMP_COLD_HASS = (HASS_COLOR_MAX - HASS_COLOR_MIN) / 3 + HASS_COLOR_MIN +SUPPORT_ZWAVE = SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_RGB_COLOR + def setup_platform(hass, config, add_devices, discovery_info=None): """Find and add Z-Wave lights.""" @@ -137,6 +140,11 @@ class ZwaveDimmer(zwave.ZWaveDeviceEntity, Light): """Return true if device is on.""" return self._state == STATE_ON + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_ZWAVE + def turn_on(self, **kwargs): """Turn the device on.""" if ATTR_BRIGHTNESS in kwargs: diff --git a/homeassistant/components/lock/demo.py b/homeassistant/components/lock/demo.py index 06366429e6c..55929227039 100644 --- a/homeassistant/components/lock/demo.py +++ b/homeassistant/components/lock/demo.py @@ -5,20 +5,20 @@ For more details about this platform, please refer to the documentation https://home-assistant.io/components/demo/ """ from homeassistant.components.lock import LockDevice -from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED +from homeassistant.const import (STATE_LOCKED, STATE_UNLOCKED) # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices_callback, discovery_info=None): - """Setup the demo lock platform.""" - add_devices_callback([ +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Demo lock platform.""" + add_devices([ DemoLock('Front Door', STATE_LOCKED), DemoLock('Kitchen Door', STATE_UNLOCKED) ]) class DemoLock(LockDevice): - """Representation of a demo lock.""" + """Representation of a Demo lock.""" def __init__(self, name, state): """Initialize the lock.""" diff --git a/homeassistant/components/lock/mqtt.py b/homeassistant/components/lock/mqtt.py index b188de21edc..81ab179efd4 100644 --- a/homeassistant/components/lock/mqtt.py +++ b/homeassistant/components/lock/mqtt.py @@ -23,9 +23,9 @@ DEPENDENCIES = ['mqtt'] CONF_PAYLOAD_LOCK = 'payload_lock' CONF_PAYLOAD_UNLOCK = 'payload_unlock' -DEFAULT_NAME = "MQTT Lock" -DEFAULT_PAYLOAD_LOCK = "LOCK" -DEFAULT_PAYLOAD_UNLOCK = "UNLOCK" +DEFAULT_NAME = 'MQTT Lock' +DEFAULT_PAYLOAD_LOCK = 'LOCK' +DEFAULT_PAYLOAD_UNLOCK = 'UNLOCK' DEFAULT_OPTIMISTIC = False PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ @@ -39,9 +39,9 @@ PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices_callback, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the MQTT lock.""" - add_devices_callback([MqttLock( + add_devices([MqttLock( hass, config[CONF_NAME], config.get(CONF_STATE_TOPIC), diff --git a/homeassistant/components/lock/vera.py b/homeassistant/components/lock/vera.py index 6ac8fb2c315..0307bbf4312 100644 --- a/homeassistant/components/lock/vera.py +++ b/homeassistant/components/lock/vera.py @@ -7,19 +7,18 @@ https://home-assistant.io/components/lock.vera/ import logging from homeassistant.components.lock import LockDevice -from homeassistant.const import ( - STATE_LOCKED, STATE_UNLOCKED) +from homeassistant.const import (STATE_LOCKED, STATE_UNLOCKED) from homeassistant.components.vera import ( VeraDevice, VERA_DEVICES, VERA_CONTROLLER) -DEPENDENCIES = ['vera'] - _LOGGER = logging.getLogger(__name__) +DEPENDENCIES = ['vera'] -def setup_platform(hass, config, add_devices_callback, discovery_info=None): + +def setup_platform(hass, config, add_devices, discovery_info=None): """Find and return Vera locks.""" - add_devices_callback( + add_devices( VeraLock(device, VERA_CONTROLLER) for device in VERA_DEVICES['lock']) diff --git a/homeassistant/components/lock/wink.py b/homeassistant/components/lock/wink.py index c85655cbf35..1a09414f8c3 100644 --- a/homeassistant/components/lock/wink.py +++ b/homeassistant/components/lock/wink.py @@ -10,7 +10,7 @@ from homeassistant.components.lock import LockDevice from homeassistant.components.wink import WinkDevice from homeassistant.const import CONF_ACCESS_TOKEN -REQUIREMENTS = ['python-wink==0.7.11', 'pubnub==3.8.2'] +REQUIREMENTS = ['python-wink==0.7.13', 'pubnub==3.8.2'] def setup_platform(hass, config, add_devices, discovery_info=None): diff --git a/homeassistant/components/logentries.py b/homeassistant/components/logentries.py index 5aaaf2df562..ef79b033922 100644 --- a/homeassistant/components/logentries.py +++ b/homeassistant/components/logentries.py @@ -7,29 +7,31 @@ https://home-assistant.io/components/logentries/ import json import logging import requests -import homeassistant.util as util -from homeassistant.const import EVENT_STATE_CHANGED + +import voluptuous as vol + +from homeassistant.const import (CONF_TOKEN, EVENT_STATE_CHANGED) from homeassistant.helpers import state as state_helper -from homeassistant.helpers import validate_config +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -DOMAIN = "logentries" -DEPENDENCIES = [] +DOMAIN = 'logentries' DEFAULT_HOST = 'https://webhook.logentries.com/noformat/logs/' -CONF_TOKEN = 'token' +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_TOKEN): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) def setup(hass, config): """Setup the Logentries component.""" - if not validate_config(config, {DOMAIN: ['token']}, _LOGGER): - _LOGGER.error("Logentries token not present") - return False conf = config[DOMAIN] - token = util.convert(conf.get(CONF_TOKEN), str) - le_wh = DEFAULT_HOST + token + token = conf.get(CONF_TOKEN) + le_wh = '{}{}'.format(DEFAULT_HOST, token) def logentries_event_listener(event): """Listen for new messages on the bus and sends them to Logentries.""" diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 38e476c5d8c..5448671b14d 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -4,6 +4,7 @@ Component to interface with various media players. For more details about this component, please refer to the documentation at https://home-assistant.io/components/media_player/ """ +import hashlib import logging import os import requests @@ -32,7 +33,7 @@ SCAN_INTERVAL = 10 ENTITY_ID_FORMAT = DOMAIN + '.{}' -ENTITY_IMAGE_URL = '/api/media_player_proxy/{0}?token={1}' +ENTITY_IMAGE_URL = '/api/media_player_proxy/{0}?token={1}&cache={2}' SERVICE_PLAY_MEDIA = 'play_media' SERVICE_SELECT_SOURCE = 'select_source' @@ -645,8 +646,17 @@ class MediaPlayerDevice(Entity): @property def entity_picture(self): """Return image of the media playing.""" - return None if self.state == STATE_OFF else \ - ENTITY_IMAGE_URL.format(self.entity_id, self.access_token) + if self.state == STATE_OFF: + return None + + url = self.media_image_url + + if url is None: + return None + + return ENTITY_IMAGE_URL.format( + self.entity_id, self.access_token, + hashlib.md5(url.encode('utf-8')).hexdigest()[:5]) @property def state_attributes(self): diff --git a/homeassistant/components/media_player/gpmdp.py b/homeassistant/components/media_player/gpmdp.py index eb6e15379d8..4fcdff872e2 100644 --- a/homeassistant/components/media_player/gpmdp.py +++ b/homeassistant/components/media_player/gpmdp.py @@ -6,31 +6,146 @@ https://home-assistant.io/components/media_player.gpmdp/ """ import logging import json +import os import socket from homeassistant.components.media_player import ( MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PREVIOUS_TRACK, - SUPPORT_PAUSE, MediaPlayerDevice) + SUPPORT_PAUSE, SUPPORT_VOLUME_SET, SUPPORT_SEEK, MediaPlayerDevice) from homeassistant.const import ( STATE_PLAYING, STATE_PAUSED, STATE_OFF) +from homeassistant.loader import get_component _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['websocket-client==0.37.0'] -SUPPORT_GPMDP = SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK +SUPPORT_GPMDP = SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \ + SUPPORT_SEEK | SUPPORT_VOLUME_SET +GPMDP_CONFIG_FILE = 'gpmpd.conf' +_CONFIGURING = {} + +PLAYBACK_DICT = {'0': STATE_PAUSED, # Stopped + '1': STATE_PAUSED, + '2': STATE_PLAYING} -def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup the GPMDP platform.""" +def request_configuration(hass, config, url, add_devices_callback): + """Request configuration steps from the user.""" + configurator = get_component('configurator') + if 'gpmdp' in _CONFIGURING: + configurator.notify_errors( + _CONFIGURING['gpmdp'], "Failed to register, please try again.") + + return from websocket import create_connection + websocket = create_connection((url), timeout=1) + websocket.send(json.dumps({'namespace': 'connect', + 'method': 'connect', + 'arguments': ['Home Assistant']})) + # pylint: disable=unused-argument + def gpmdp_configuration_callback(callback_data): + """The actions to do when our configuration callback is called.""" + while True: + from websocket import _exceptions + try: + msg = json.loads(websocket.recv()) + except _exceptions.WebSocketConnectionClosedException: + continue + if msg['channel'] != 'connect': + continue + if msg['payload'] != "CODE_REQUIRED": + continue + pin = callback_data.get('pin') + websocket.send(json.dumps({'namespace': 'connect', + 'method': 'connect', + 'arguments': ['Home Assistant', pin]})) + tmpmsg = json.loads(websocket.recv()) + if tmpmsg['channel'] == 'time': + _LOGGER.error('Error setting up GPMDP. Please pause' + ' the desktop player and try again.') + break + code = tmpmsg['payload'] + if code == 'CODE_REQUIRED': + continue + setup_gpmdp(hass, config, code, + add_devices_callback) + _save_config(hass.config.path(GPMDP_CONFIG_FILE), + {"CODE": code}) + websocket.send(json.dumps({'namespace': 'connect', + 'method': 'connect', + 'arguments': ['Home Assistant', code]})) + websocket.close() + break + + _CONFIGURING['gpmdp'] = configurator.request_config( + hass, "GPM Desktop Player", gpmdp_configuration_callback, + description=( + 'Enter the pin that is displayed in the ' + 'Google Play Music Desktop Player.'), + submit_caption="Submit", + fields=[{'id': 'pin', 'name': 'Pin Code', 'type': 'number'}] + ) + + +def setup_gpmdp(hass, config, code, add_devices_callback): + """Setup gpmdp.""" name = config.get("name", "GPM Desktop Player") address = config.get("address") + url = "ws://" + address + ":5672" - if address is None: - _LOGGER.error("Missing address in config") + if not code: + request_configuration(hass, config, url, add_devices_callback) + return + + if 'gpmdp' in _CONFIGURING: + configurator = get_component('configurator') + configurator.request_done(_CONFIGURING.pop('gpmdp')) + + add_devices_callback([GPMDP(name, url, code)]) + + +def _load_config(filename): + """Load configuration.""" + if not os.path.isfile(filename): + return {} + + try: + with open(filename, "r") as fdesc: + inp = fdesc.read() + + # In case empty file + if not inp: + return {} + + return json.loads(inp) + except (IOError, ValueError) as error: + _LOGGER.error("Reading config file %s failed: %s", filename, error) + return None + + +def _save_config(filename, config): + """Save configuration.""" + try: + with open(filename, "w") as fdesc: + fdesc.write(json.dumps(config, indent=4, sort_keys=True)) + except (IOError, TypeError) as error: + _LOGGER.error("Saving config file failed: %s", error) return False + return True - add_devices([GPMDP(name, address, create_connection)]) + +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """Setup the GPMDP platform.""" + codeconfig = _load_config(hass.config.path(GPMDP_CONFIG_FILE)) + if len(codeconfig): + code = codeconfig.get("CODE") + elif discovery_info is not None: + if 'gpmdp' in _CONFIGURING: + return + code = None + else: + code = None + setup_gpmdp(hass, config, code, add_devices_callback) class GPMDP(MediaPlayerDevice): @@ -38,57 +153,83 @@ class GPMDP(MediaPlayerDevice): # pylint: disable=too-many-public-methods, abstract-method # pylint: disable=too-many-instance-attributes - def __init__(self, name, address, create_connection): + def __init__(self, name, url, code): """Initialize the media player.""" + from websocket import create_connection self._connection = create_connection - self._address = address + self._url = url + self._authorization_code = code self._name = name self._status = STATE_OFF self._ws = None self._title = None self._artist = None self._albumart = None + self._seek_position = None + self._duration = None + self._volume = None + self._request_id = 0 self.update() def get_ws(self): """Check if the websocket is setup and connected.""" if self._ws is None: try: - self._ws = self._connection(("ws://" + self._address + - ":5672"), timeout=1) - except (socket.timeout, ConnectionRefusedError, - ConnectionResetError): - self._ws = None - elif self._ws.connected is True: - self._ws.close() - try: - self._ws = self._connection(("ws://" + self._address + - ":5672"), timeout=1) + self._ws = self._connection((self._url), timeout=1) + msg = json.dumps({'namespace': 'connect', + 'method': 'connect', + 'arguments': ['Home Assistant', + self._authorization_code]}) + self._ws.send(msg) except (socket.timeout, ConnectionRefusedError, ConnectionResetError): self._ws = None return self._ws + def send_gpmdp_msg(self, namespace, method, with_id=True): + """Send ws messages to GPMDP and verify request id in response.""" + from websocket import _exceptions + try: + websocket = self.get_ws() + if websocket is None: + self._status = STATE_OFF + return + self._request_id += 1 + websocket.send(json.dumps({'namespace': namespace, + 'method': method, + 'requestID': self._request_id})) + if not with_id: + return + while True: + msg = json.loads(websocket.recv()) + if 'requestID' in msg: + if msg['requestID'] == self._request_id: + return msg + except (ConnectionRefusedError, ConnectionResetError, + _exceptions.WebSocketTimeoutException, + _exceptions.WebSocketProtocolException, + _exceptions.WebSocketPayloadException, + _exceptions.WebSocketConnectionClosedException): + self._ws = None + def update(self): """Get the latest details from the player.""" - websocket = self.get_ws() - if websocket is None: - self._status = STATE_OFF + playstate = self.send_gpmdp_msg('playback', 'getPlaybackState') + if playstate is None: return - else: - state = websocket.recv() - state = ((json.loads(state))['payload']) - if state is True: - websocket.recv() - websocket.recv() - song = websocket.recv() - song = json.loads(song) - self._title = (song['payload']['title']) - self._artist = (song['payload']['artist']) - self._albumart = (song['payload']['albumArt']) - self._status = STATE_PLAYING - elif state is False: - self._status = STATE_PAUSED + self._status = PLAYBACK_DICT[str(playstate['value'])] + time_data = self.send_gpmdp_msg('playback', 'getCurrentTime') + if time_data is not None: + self._seek_position = int(time_data['value'] / 1000) + track_data = self.send_gpmdp_msg('playback', 'getCurrentTrack') + if track_data is not None: + self._title = track_data['value']['title'] + self._artist = track_data['value']['artist'] + self._albumart = track_data['value']['albumArt'] + self._duration = int(track_data['value']['duration'] / 1000) + volume_data = self.send_gpmdp_msg('volume', 'getVolume') + if volume_data is not None: + self._volume = volume_data['value'] / 100 @property def media_content_type(self): @@ -115,6 +256,21 @@ class GPMDP(MediaPlayerDevice): """Image url of current playing media.""" return self._albumart + @property + def media_seek_position(self): + """Time in seconds of current seek positon.""" + return self._seek_position + + @property + def media_duration(self): + """Time in seconds of current song duration.""" + return self._duration + + @property + def volume_level(self): + """Volume level of the media player (0..1).""" + return self._volume + @property def name(self): """Return the name of the device.""" @@ -127,32 +283,56 @@ class GPMDP(MediaPlayerDevice): def media_next_track(self): """Send media_next command to media player.""" - websocket = self.get_ws() - if websocket is None: - return - websocket.send('{"namespace": "playback", "method": "forward"}') + self.send_gpmdp_msg('playback', 'forward', False) def media_previous_track(self): """Send media_previous command to media player.""" - websocket = self.get_ws() - if websocket is None: - return - websocket.send('{"namespace": "playback", "method": "rewind"}') + self.send_gpmdp_msg('playback', 'rewind', False) def media_play(self): """Send media_play command to media player.""" - websocket = self.get_ws() - if websocket is None: - return - websocket.send('{"namespace": "playback", "method": "playPause"}') - self._status = STATE_PAUSED + self.send_gpmdp_msg('playback', 'playPause', False) + self._status = STATE_PLAYING self.update_ha_state() def media_pause(self): """Send media_pause command to media player.""" + self.send_gpmdp_msg('playback', 'playPause', False) + self._status = STATE_PAUSED + self.update_ha_state() + + def media_seek(self, position): + """Send media_seek command to media player.""" websocket = self.get_ws() if websocket is None: return - websocket.send('{"namespace": "playback", "method": "playPause"}') - self._status = STATE_PAUSED + websocket.send(json.dumps({"namespace": "playback", + "method": "setCurrentTime", + "arguments": [position*1000]})) + self.update_ha_state() + + def volume_up(self): + """Send volume_up command to media player.""" + websocket = self.get_ws() + if websocket is None: + return + websocket.send('{"namespace": "volume", "method": "increaseVolume"}') + self.update_ha_state() + + def volume_down(self): + """Send volume_down command to media player.""" + websocket = self.get_ws() + if websocket is None: + return + websocket.send('{"namespace": "volume", "method": "decreaseVolume"}') + self.update_ha_state() + + def set_volume_level(self, volume): + """Set volume on media player, range(0..1).""" + websocket = self.get_ws() + if websocket is None: + return + websocket.send(json.dumps({"namespace": "volume", + "method": "setVolume", + "arguments": [volume*100]})) self.update_ha_state() diff --git a/homeassistant/components/media_player/webostv.py b/homeassistant/components/media_player/webostv.py index 4d3bb701586..a0ba2237391 100644 --- a/homeassistant/components/media_player/webostv.py +++ b/homeassistant/components/media_player/webostv.py @@ -15,7 +15,8 @@ from homeassistant.components.media_player import ( SUPPORT_SELECT_SOURCE, SUPPORT_PLAY_MEDIA, MEDIA_TYPE_CHANNEL, MediaPlayerDevice) from homeassistant.const import ( - CONF_HOST, STATE_OFF, STATE_PLAYING, STATE_PAUSED, STATE_UNKNOWN) + CONF_HOST, CONF_CUSTOMIZE, STATE_OFF, STATE_PLAYING, STATE_PAUSED, + STATE_UNKNOWN) from homeassistant.loader import get_component _CONFIGURING = {} @@ -33,6 +34,16 @@ SUPPORT_WEBOSTV = SUPPORT_PAUSE | SUPPORT_VOLUME_STEP | \ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1) +WEBOS_APP_LIVETV = 'com.webos.app.livetv' +WEBOS_APP_YOUTUBE = 'youtube.leanback.v4' +WEBOS_APP_MAKO = 'makotv' + +WEBOS_APPS_SHORT = { + 'livetv': WEBOS_APP_LIVETV, + 'youtube': WEBOS_APP_YOUTUBE, + 'makotv': WEBOS_APP_MAKO +} + # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): @@ -50,10 +61,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if host in _CONFIGURING: return - setup_tv(host, hass, add_devices) + customize = config.get(CONF_CUSTOMIZE, {}) + setup_tv(host, customize, hass, add_devices) -def setup_tv(host, hass, add_devices): +def setup_tv(host, customize, hass, add_devices): """Setup a phue bridge based on host parameter.""" from pylgtv import WebOsClient from pylgtv import PyLGTVPairException @@ -75,7 +87,7 @@ def setup_tv(host, hass, add_devices): else: # Not registered, request configuration. _LOGGER.warning('LG WebOS TV at %s needs to be paired.', host) - request_configuration(host, hass, add_devices) + request_configuration(host, customize, hass, add_devices) return # If we came here and configuring this host, mark as done. @@ -84,10 +96,10 @@ def setup_tv(host, hass, add_devices): configurator = get_component('configurator') configurator.request_done(request_id) - add_devices([LgWebOSDevice(host)]) + add_devices([LgWebOSDevice(host, customize)]) -def request_configuration(host, hass, add_devices): +def request_configuration(host, customize, hass, add_devices): """Request configuration steps from the user.""" configurator = get_component('configurator') @@ -100,7 +112,7 @@ def request_configuration(host, hass, add_devices): # pylint: disable=unused-argument def lgtv_configuration_callback(data): """The actions to do when our configuration callback is called.""" - setup_tv(host, hass, add_devices) + setup_tv(host, customize, hass, add_devices) _CONFIGURING[host] = configurator.request_config( hass, 'LG WebOS TV', lgtv_configuration_callback, @@ -116,10 +128,11 @@ class LgWebOSDevice(MediaPlayerDevice): """Representation of a LG WebOS TV.""" # pylint: disable=too-many-public-methods - def __init__(self, host): + def __init__(self, host, customize): """Initialize the webos device.""" from pylgtv import WebOsClient self._client = WebOsClient(host) + self._customize = customize self._name = 'LG WebOS TV Remote' # Assume that the TV is not muted @@ -130,7 +143,6 @@ class LgWebOSDevice(MediaPlayerDevice): self._current_source = None self._current_source_id = None self._source_list = None - self._source_label_list = None self._state = STATE_UNKNOWN self._app_list = None @@ -144,19 +156,30 @@ class LgWebOSDevice(MediaPlayerDevice): self._muted = self._client.get_muted() self._volume = self._client.get_volume() self._current_source_id = self._client.get_input() - self._source_list = {} - self._source_label_list = [] self._app_list = {} + + custom_sources = [] + for source in self._customize.get('sources', []): + app_id = WEBOS_APPS_SHORT.get(source, None) + if app_id: + custom_sources.append(app_id) + else: + custom_sources.append(source) + for app in self._client.get_apps(): self._app_list[app['id']] = app + if app['id'] == self._current_source_id: + self._current_source = app['title'] + self._source_list[app['title']] = app + if app['id'] in custom_sources: + self._source_list[app['title']] = app for source in self._client.get_inputs(): - self._source_list[source['label']] = source - self._app_list[source['appId']] = source - self._source_label_list.append(source['label']) - if source['appId'] == self._current_source_id: - self._current_source = source['label'] + if not source['connected']: + continue + app = self._app_list[source['appId']] + self._source_list[app['title']] = app except OSError: self._state = STATE_OFF @@ -189,7 +212,7 @@ class LgWebOSDevice(MediaPlayerDevice): @property def source_list(self): """List of available input sources.""" - return self._source_label_list + return sorted(self._source_list.keys()) @property def media_content_type(self): @@ -199,7 +222,9 @@ class LgWebOSDevice(MediaPlayerDevice): @property def media_image_url(self): """Image url of current playing media.""" - return self._app_list[self._current_source_id]['icon'] + if self._current_source_id in self._app_list: + return self._app_list[self._current_source_id]['largeIcon'] + return None @property def supported_media_commands(self): @@ -238,9 +263,9 @@ class LgWebOSDevice(MediaPlayerDevice): def select_source(self, source): """Select input source.""" - self._current_source_id = self._source_list[source]['appId'] - self._current_source = self._source_list[source]['label'] - self._client.set_input(self._source_list[source]['id']) + self._current_source_id = self._source_list[source]['id'] + self._current_source = self._source_list[source]['title'] + self._client.launch_app(self._source_list[source]['id']) def media_play(self): """Send play command.""" diff --git a/homeassistant/components/modbus.py b/homeassistant/components/modbus.py index 01f4e72ca0d..1d6ad0e3abc 100644 --- a/homeassistant/components/modbus.py +++ b/homeassistant/components/modbus.py @@ -31,6 +31,12 @@ IP_PORT = "port" _LOGGER = logging.getLogger(__name__) +SERVICE_WRITE_REGISTER = "write_register" + +ATTR_ADDRESS = "address" +ATTR_UNIT = "unit" +ATTR_VALUE = "value" + NETWORK = None TYPE = None @@ -74,6 +80,16 @@ def setup(hass, config): NETWORK.connect() hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_modbus) + # Register services for modbus + hass.services.register(DOMAIN, SERVICE_WRITE_REGISTER, write_register) + + def write_register(service): + """Write modbus register.""" + unit = int(float(service.data.get(ATTR_UNIT))) + address = int(float(service.data.get(ATTR_ADDRESS))) + value = int(float(service.data.get(ATTR_VALUE))) + NETWORK.write_register(address, value, unit=unit) + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_modbus) return True diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 6db231f6bd7..e06f60b6e1a 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -203,14 +203,14 @@ def setup(hass, config): broker_config = _setup_server(hass, config) - broker_in_conf = True if CONF_BROKER in conf else False + broker_in_conf = CONF_BROKER in conf # Only auto config if no server config was passed in if broker_config and CONF_EMBEDDED not in conf: broker, port, username, password, certificate, protocol = broker_config # Embedded broker doesn't have some ssl variables client_key, client_cert, tls_insecure = None, None, None - elif not broker_config and CONF_BROKER not in conf: + elif not broker_config and not broker_in_conf: _LOGGER.error('Unable to start broker and auto-configure MQTT.') return False diff --git a/homeassistant/components/mysensors.py b/homeassistant/components/mysensors.py index b950ec39dd8..4b9b54d61db 100644 --- a/homeassistant/components/mysensors.py +++ b/homeassistant/components/mysensors.py @@ -7,74 +7,116 @@ https://home-assistant.io/components/sensor.mysensors/ import logging import socket +import voluptuous as vol + +from homeassistant.bootstrap import setup_component +import homeassistant.helpers.config_validation as cv from homeassistant.const import (ATTR_BATTERY_LEVEL, CONF_OPTIMISTIC, EVENT_HOMEASSISTANT_START, - EVENT_HOMEASSISTANT_STOP, - STATE_OFF, STATE_ON) -from homeassistant.helpers import validate_config, discovery + EVENT_HOMEASSISTANT_STOP, STATE_OFF, STATE_ON) +from homeassistant.helpers import discovery +from homeassistant.loader import get_component -CONF_GATEWAYS = 'gateways' -CONF_DEVICE = 'device' -CONF_DEBUG = 'debug' -CONF_PERSISTENCE = 'persistence' -CONF_PERSISTENCE_FILE = 'persistence_file' -CONF_VERSION = 'version' -CONF_BAUD_RATE = 'baud_rate' -CONF_TCP_PORT = 'tcp_port' -DEFAULT_VERSION = '1.4' -DEFAULT_BAUD_RATE = 115200 -DEFAULT_TCP_PORT = 5003 - -DOMAIN = 'mysensors' -DEPENDENCIES = [] -REQUIREMENTS = [ - 'https://github.com/theolind/pymysensors/archive/' - 'cc5d0b325e13c2b623fa934f69eea7cd4555f110.zip#pymysensors==0.6'] _LOGGER = logging.getLogger(__name__) + ATTR_NODE_ID = 'node_id' ATTR_CHILD_ID = 'child_id' +ATTR_DESCRIPTION = 'description' ATTR_DEVICE = 'device' - +CONF_BAUD_RATE = 'baud_rate' +CONF_DEVICE = 'device' +CONF_DEBUG = 'debug' +CONF_GATEWAYS = 'gateways' +CONF_PERSISTENCE = 'persistence' +CONF_PERSISTENCE_FILE = 'persistence_file' +CONF_TCP_PORT = 'tcp_port' +CONF_TOPIC_IN_PREFIX = 'topic_in_prefix' +CONF_TOPIC_OUT_PREFIX = 'topic_out_prefix' +CONF_RETAIN = 'retain' +CONF_VERSION = 'version' +DEFAULT_VERSION = 1.4 +DEFAULT_BAUD_RATE = 115200 +DEFAULT_TCP_PORT = 5003 +DOMAIN = 'mysensors' GATEWAYS = None +MQTT_COMPONENT = 'mqtt' +REQUIREMENTS = [ + 'https://github.com/theolind/pymysensors/archive/' + '8ce98b7fb56f7921a808eb66845ce8b2c455c81e.zip#pymysensors==0.7.1'] + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_GATEWAYS): vol.All(cv.ensure_list, [ + { + vol.Required(CONF_DEVICE): cv.string, + vol.Optional(CONF_PERSISTENCE_FILE): cv.string, + vol.Optional( + CONF_BAUD_RATE, + default=DEFAULT_BAUD_RATE): cv.positive_int, + vol.Optional( + CONF_TCP_PORT, + default=DEFAULT_TCP_PORT): cv.port, + vol.Optional(CONF_TOPIC_IN_PREFIX, default=''): cv.string, + vol.Optional(CONF_TOPIC_OUT_PREFIX, default=''): cv.string, + }, + ]), + vol.Optional(CONF_DEBUG, default=False): cv.boolean, + vol.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, + vol.Optional(CONF_PERSISTENCE, default=True): cv.boolean, + vol.Optional(CONF_RETAIN, default=True): cv.boolean, + vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): vol.Coerce(float), + }) +}, extra=vol.ALLOW_EXTRA) def setup(hass, config): # pylint: disable=too-many-locals """Setup the MySensors component.""" - if not validate_config(config, - {DOMAIN: [CONF_GATEWAYS]}, - _LOGGER): - return False - if not all(CONF_DEVICE in gateway - for gateway in config[DOMAIN][CONF_GATEWAYS]): - _LOGGER.error('Missing required configuration items ' - 'in %s: %s', DOMAIN, CONF_DEVICE) - return False - import mysensors.mysensors as mysensors - version = str(config[DOMAIN].get(CONF_VERSION, DEFAULT_VERSION)) - is_metric = hass.config.units.is_metric - persistence = config[DOMAIN].get(CONF_PERSISTENCE, True) + version = config[DOMAIN].get(CONF_VERSION) + persistence = config[DOMAIN].get(CONF_PERSISTENCE) - def setup_gateway(device, persistence_file, baud_rate, tcp_port): + def setup_gateway(device, persistence_file, baud_rate, tcp_port, in_prefix, + out_prefix): """Return gateway after setup of the gateway.""" - try: - socket.inet_aton(device) - # valid ip address - gateway = mysensors.TCPGateway( - device, event_callback=None, persistence=persistence, - persistence_file=persistence_file, protocol_version=version, - port=tcp_port) - except OSError: - # invalid ip address - gateway = mysensors.SerialGateway( - device, event_callback=None, persistence=persistence, - persistence_file=persistence_file, protocol_version=version, - baud=baud_rate) - gateway.metric = is_metric - gateway.debug = config[DOMAIN].get(CONF_DEBUG, False) - optimistic = config[DOMAIN].get(CONF_OPTIMISTIC, False) - gateway = GatewayWrapper(gateway, version, optimistic) + # pylint: disable=too-many-arguments + if device == MQTT_COMPONENT: + if not setup_component(hass, MQTT_COMPONENT, config): + return + mqtt = get_component(MQTT_COMPONENT) + retain = config[DOMAIN].get(CONF_RETAIN) + + def pub_callback(topic, payload, qos, retain): + """Call mqtt publish function.""" + mqtt.publish(hass, topic, payload, qos, retain) + + def sub_callback(topic, callback, qos): + """Call mqtt subscribe function.""" + mqtt.subscribe(hass, topic, callback, qos) + gateway = mysensors.MQTTGateway( + pub_callback, sub_callback, + event_callback=None, persistence=persistence, + persistence_file=persistence_file, + protocol_version=version, in_prefix=in_prefix, + out_prefix=out_prefix, retain=retain) + else: + try: + socket.inet_aton(device) + # valid ip address + gateway = mysensors.TCPGateway( + device, event_callback=None, persistence=persistence, + persistence_file=persistence_file, + protocol_version=version, port=tcp_port) + except OSError: + # invalid ip address + gateway = mysensors.SerialGateway( + device, event_callback=None, persistence=persistence, + persistence_file=persistence_file, + protocol_version=version, baud=baud_rate) + gateway.metric = hass.config.units.is_metric + gateway.debug = config[DOMAIN].get(CONF_DEBUG) + optimistic = config[DOMAIN].get(CONF_OPTIMISTIC) + gateway = GatewayWrapper(gateway, optimistic, device) # pylint: disable=attribute-defined-outside-init gateway.event_callback = gateway.callback_factory() @@ -95,18 +137,26 @@ def setup(hass, config): # pylint: disable=too-many-locals global GATEWAYS GATEWAYS = {} conf_gateways = config[DOMAIN][CONF_GATEWAYS] - if isinstance(conf_gateways, dict): - conf_gateways = [conf_gateways] for index, gway in enumerate(conf_gateways): device = gway[CONF_DEVICE] persistence_file = gway.get( CONF_PERSISTENCE_FILE, hass.config.path('mysensors{}.pickle'.format(index + 1))) - baud_rate = gway.get(CONF_BAUD_RATE, DEFAULT_BAUD_RATE) - tcp_port = gway.get(CONF_TCP_PORT, DEFAULT_TCP_PORT) + baud_rate = gway.get(CONF_BAUD_RATE) + tcp_port = gway.get(CONF_TCP_PORT) + in_prefix = gway.get(CONF_TOPIC_IN_PREFIX) + out_prefix = gway.get(CONF_TOPIC_OUT_PREFIX) GATEWAYS[device] = setup_gateway( - device, persistence_file, baud_rate, tcp_port) + device, persistence_file, baud_rate, tcp_port, in_prefix, + out_prefix) + if GATEWAYS[device] is None: + GATEWAYS.pop(device) + + if not GATEWAYS: + _LOGGER.error( + 'No devices could be setup as gateways, check your configuration') + return False for component in 'sensor', 'switch', 'light', 'binary_sensor': discovery.load_platform(hass, component, DOMAIN, {}, config) @@ -152,25 +202,25 @@ class GatewayWrapper(object): # pylint: disable=too-few-public-methods - def __init__(self, gateway, version, optimistic): + def __init__(self, gateway, optimistic, device): """Setup class attributes on instantiation. Args: gateway (mysensors.SerialGateway): Gateway to wrap. - version (str): Version of mysensors API. optimistic (bool): Send values to actuators without feedback state. + device (str): Path to serial port, ip adress or mqtt. Attributes: _wrapped_gateway (mysensors.SerialGateway): Wrapped gateway. - version (str): Version of mysensors API. platform_callbacks (list): Callback functions, one per platform. optimistic (bool): Send values to actuators without feedback state. + device (str): Device configured as gateway. __initialised (bool): True if GatewayWrapper is initialised. """ self._wrapped_gateway = gateway - self.version = version self.platform_callbacks = [] self.optimistic = optimistic + self.device = device self.__initialised = True def __getattr__(self, name): @@ -195,7 +245,7 @@ class GatewayWrapper(object): """Return a new callback function.""" def node_update(update_type, node_id): """Callback for node updates from the MySensors gateway.""" - _LOGGER.debug('update %s: node %s', update_type, node_id) + _LOGGER.debug('Update %s: node %s', update_type, node_id) for callback in self.platform_callbacks: callback(self, node_id) @@ -205,7 +255,7 @@ class GatewayWrapper(object): class MySensorsDeviceEntity(object): """Represent a MySensors entity.""" - # pylint: disable=too-many-arguments,too-many-instance-attributes + # pylint: disable=too-many-arguments def __init__( self, gateway, node_id, child_id, name, value_type, child_type): @@ -237,7 +287,6 @@ class MySensorsDeviceEntity(object): self._name = name self.value_type = value_type self.child_type = child_type - self.battery_level = 0 self._values = {} @property @@ -253,16 +302,14 @@ class MySensorsDeviceEntity(object): @property def device_state_attributes(self): """Return device specific state attributes.""" - address = getattr(self.gateway, 'server_address', None) - if address: - device = '{}:{}'.format(address[0], address[1]) - else: - device = self.gateway.port + node = self.gateway.sensors[self.node_id] + child = node.children[self.child_id] attr = { - ATTR_DEVICE: device, - ATTR_NODE_ID: self.node_id, + ATTR_BATTERY_LEVEL: node.battery_level, ATTR_CHILD_ID: self.child_id, - ATTR_BATTERY_LEVEL: self.battery_level, + ATTR_DESCRIPTION: child.description, + ATTR_DEVICE: self.gateway.device, + ATTR_NODE_ID: self.node_id, } set_req = self.gateway.const.SetReq @@ -271,9 +318,9 @@ class MySensorsDeviceEntity(object): try: attr[set_req(value_type).name] = value except ValueError: - _LOGGER.error('value_type %s is not valid for mysensors ' + _LOGGER.error('Value_type %s is not valid for mysensors ' 'version %s', value_type, - self.gateway.version) + self.gateway.protocol_version) return attr @property @@ -285,7 +332,6 @@ class MySensorsDeviceEntity(object): """Update the controller with the latest value from a sensor.""" node = self.gateway.sensors[self.node_id] child = node.children[self.child_id] - self.battery_level = node.battery_level set_req = self.gateway.const.SetReq for value_type, value in child.values.items(): _LOGGER.debug( diff --git a/homeassistant/components/nest.py b/homeassistant/components/nest.py index afccc043223..430b9baa956 100644 --- a/homeassistant/components/nest.py +++ b/homeassistant/components/nest.py @@ -2,24 +2,28 @@ Support for Nest thermostats and protect smoke alarms. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/thermostat.nest/ +https://home-assistant.io/components/climate.nest/ """ import logging import socket import voluptuous as vol +import homeassistant.helpers.config_validation as cv -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, CONF_STRUCTURE REQUIREMENTS = ['python-nest==2.9.2'] DOMAIN = 'nest' NEST = None +STRUCTURES_TO_INCLUDE = None + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_STRUCTURE): vol.All(cv.ensure_list, cv.string) }) }, extra=vol.ALLOW_EXTRA) @@ -30,8 +34,12 @@ def devices(): """Generator returning list of devices and their location.""" try: for structure in NEST.structures: - for device in structure.devices: - yield (structure, device) + if structure.name in STRUCTURES_TO_INCLUDE: + for device in structure.devices: + yield (structure, device) + else: + _LOGGER.debug("Ignoring structure %s, not in %s", + structure.name, STRUCTURES_TO_INCLUDE) except socket.error: _LOGGER.error("Connection error logging into the nest web service.") @@ -40,8 +48,12 @@ def protect_devices(): """Generator returning list of protect devices.""" try: for structure in NEST.structures: - for device in structure.protectdevices: - yield(structure, device) + if structure.name in STRUCTURES_TO_INCLUDE: + for device in structure.protectdevices: + yield(structure, device) + else: + _LOGGER.info("Ignoring structure %s, not in %s", + structure.name, STRUCTURES_TO_INCLUDE) except socket.error: _LOGGER.error("Connection error logging into the nest web service.") @@ -50,6 +62,7 @@ def protect_devices(): def setup(hass, config): """Setup the Nest thermostat component.""" global NEST + global STRUCTURES_TO_INCLUDE conf = config[DOMAIN] username = conf[CONF_USERNAME] @@ -59,4 +72,10 @@ def setup(hass, config): NEST = nest.Nest(username, password) + if CONF_STRUCTURE not in conf: + STRUCTURES_TO_INCLUDE = [s.name for s in NEST.structures] + else: + STRUCTURES_TO_INCLUDE = conf[CONF_STRUCTURE] + + _LOGGER.debug("Structures to include: %s", STRUCTURES_TO_INCLUDE) return True diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index 4b73c46b198..a28a50d766f 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -13,9 +13,9 @@ import voluptuous as vol import homeassistant.bootstrap as bootstrap from homeassistant.config import load_yaml_config_file from homeassistant.helpers import config_per_platform, template -from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa import homeassistant.helpers.config_validation as cv -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_NAME, CONF_PLATFORM +from homeassistant.util import slugify DOMAIN = "notify" @@ -34,6 +34,11 @@ ATTR_DATA = 'data' SERVICE_NOTIFY = "notify" +PLATFORM_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): cv.string, + vol.Optional(CONF_NAME): cv.string, +}, extra=vol.ALLOW_EXTRA) + NOTIFY_SERVICE_SCHEMA = vol.Schema({ vol.Required(ATTR_MESSAGE): cv.template, vol.Optional(ATTR_TITLE, default=ATTR_TITLE_DEFAULT): cv.string, @@ -59,6 +64,7 @@ def send_message(hass, message, title=None, data=None): hass.services.call(DOMAIN, SERVICE_NOTIFY, info) +# pylint: disable=too-many-locals def setup(hass, config): """Setup the notify services.""" success = False @@ -66,6 +72,8 @@ def setup(hass, config): descriptions = load_yaml_config_file( os.path.join(os.path.dirname(__file__), 'services.yaml')) + targets = {} + for platform, p_config in config_per_platform(config, DOMAIN): notify_implementation = bootstrap.prepare_setup_platform( hass, config, DOMAIN, platform) @@ -87,7 +95,10 @@ def setup(hass, config): title = template.render( hass, call.data.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)) - target = call.data.get(ATTR_TARGET) + if targets.get(call.service) is not None: + target = targets[call.service] + else: + target = call.data.get(ATTR_TARGET) message = template.render(hass, message) data = call.data.get(ATTR_DATA) @@ -95,8 +106,22 @@ def setup(hass, config): data=data) service_call_handler = partial(notify_message, notify_service) - service_notify = p_config.get(CONF_NAME, SERVICE_NOTIFY) - hass.services.register(DOMAIN, service_notify, service_call_handler, + + if hasattr(notify_service, 'targets'): + platform_name = (p_config.get(CONF_NAME) or platform) + for target in notify_service.targets: + target_name = slugify("{}_{}".format(platform_name, target)) + targets[target_name] = target + hass.services.register(DOMAIN, target_name, + service_call_handler, + descriptions.get(SERVICE_NOTIFY), + schema=NOTIFY_SERVICE_SCHEMA) + + platform_name = (p_config.get(CONF_NAME) or SERVICE_NOTIFY) + platform_name_slug = slugify(platform_name) + + hass.services.register(DOMAIN, platform_name_slug, + service_call_handler, descriptions.get(SERVICE_NOTIFY), schema=NOTIFY_SERVICE_SCHEMA) success = True diff --git a/homeassistant/components/notify/demo.py b/homeassistant/components/notify/demo.py index 4685b90e880..8ad74b1ac8e 100644 --- a/homeassistant/components/notify/demo.py +++ b/homeassistant/components/notify/demo.py @@ -22,6 +22,11 @@ class DemoNotificationService(BaseNotificationService): """Initialize the service.""" self.hass = hass + @property + def targets(self): + """Return a dictionary of registered targets.""" + return ["test target"] + def send_message(self, message="", **kwargs): """Send a message to a user.""" kwargs['message'] = message diff --git a/homeassistant/components/notify/group.py b/homeassistant/components/notify/group.py new file mode 100644 index 00000000000..522b231d8cf --- /dev/null +++ b/homeassistant/components/notify/group.py @@ -0,0 +1,65 @@ +""" +Group platform for notify component. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.group/ +""" +import collections +import logging +import voluptuous as vol + +from homeassistant.const import (CONF_PLATFORM, CONF_NAME, ATTR_SERVICE) +from homeassistant.components.notify import (DOMAIN, ATTR_MESSAGE, ATTR_DATA, + BaseNotificationService) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_SERVICES = "services" + +PLATFORM_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): "group", + vol.Required(CONF_NAME): vol.Coerce(str), + vol.Required(CONF_SERVICES): vol.All(cv.ensure_list, [{ + vol.Required(ATTR_SERVICE): cv.slug, + vol.Optional(ATTR_DATA): dict, + }]) +}) + + +def update(input_dict, update_source): + """Deep update a dictionary.""" + for key, val in update_source.items(): + if isinstance(val, collections.Mapping): + recurse = update(input_dict.get(key, {}), val) + input_dict[key] = recurse + else: + input_dict[key] = update_source[key] + return input_dict + + +def get_service(hass, config): + """Get the Group notification service.""" + return GroupNotifyPlatform(hass, config.get(CONF_SERVICES)) + + +# pylint: disable=too-few-public-methods +class GroupNotifyPlatform(BaseNotificationService): + """Implement the notification service for the group notify playform.""" + + def __init__(self, hass, entities): + """Initialize the service.""" + self.hass = hass + self.entities = entities + + def send_message(self, message="", **kwargs): + """Send message to all entities in the group.""" + payload = {ATTR_MESSAGE: message} + payload.update({key: val for key, val in kwargs.items() if val}) + + for entity in self.entities: + sending_payload = payload.copy() + if entity.get(ATTR_DATA) is not None: + update(sending_payload, entity.get(ATTR_DATA)) + self.hass.services.call(DOMAIN, entity.get(ATTR_SERVICE), + sending_payload) diff --git a/homeassistant/components/notify/html5.py b/homeassistant/components/notify/html5.py new file mode 100644 index 00000000000..54727a60d3f --- /dev/null +++ b/homeassistant/components/notify/html5.py @@ -0,0 +1,379 @@ +""" +HTML5 Push Messaging notification service. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.html5/ +""" +import os +import logging +import json +import time +import datetime +import uuid + +import voluptuous as vol +from voluptuous.humanize import humanize_error + +from homeassistant.const import (HTTP_BAD_REQUEST, HTTP_INTERNAL_SERVER_ERROR, + HTTP_UNAUTHORIZED, URL_ROOT) +from homeassistant.util import ensure_unique_string +from homeassistant.components.notify import ( + ATTR_TARGET, ATTR_TITLE, ATTR_DATA, BaseNotificationService, + PLATFORM_SCHEMA) +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.frontend import add_manifest_json_key +from homeassistant.helpers import config_validation as cv + +REQUIREMENTS = ['https://github.com/web-push-libs/pywebpush/archive/' + 'e743dc92558fc62178d255c0018920d74fa778ed.zip#' + 'pywebpush==0.5.0', 'PyJWT==1.4.2'] + +DEPENDENCIES = ['frontend'] + +_LOGGER = logging.getLogger(__name__) + +REGISTRATIONS_FILE = 'html5_push_registrations.conf' + +ATTR_GCM_SENDER_ID = 'gcm_sender_id' +ATTR_GCM_API_KEY = 'gcm_api_key' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(ATTR_GCM_SENDER_ID): cv.string, + vol.Optional(ATTR_GCM_API_KEY): cv.string, +}) + +ATTR_SUBSCRIPTION = 'subscription' +ATTR_BROWSER = 'browser' + +ATTR_ENDPOINT = 'endpoint' +ATTR_KEYS = 'keys' +ATTR_AUTH = 'auth' +ATTR_P256DH = 'p256dh' + +ATTR_TAG = 'tag' +ATTR_ACTION = 'action' +ATTR_ACTIONS = 'actions' +ATTR_TYPE = 'type' +ATTR_URL = 'url' + +ATTR_JWT = 'jwt' + +# The number of days after the moment a notification is sent that a JWT +# is valid. +JWT_VALID_DAYS = 7 + +KEYS_SCHEMA = vol.All(dict, + vol.Schema({ + vol.Required(ATTR_AUTH): cv.string, + vol.Required(ATTR_P256DH): cv.string + })) + +SUBSCRIPTION_SCHEMA = vol.All(dict, + vol.Schema({ + # pylint: disable=no-value-for-parameter + vol.Required(ATTR_ENDPOINT): vol.Url(), + vol.Required(ATTR_KEYS): KEYS_SCHEMA + })) + +REGISTER_SCHEMA = vol.Schema({ + vol.Required(ATTR_SUBSCRIPTION): SUBSCRIPTION_SCHEMA, + vol.Required(ATTR_BROWSER): vol.In(['chrome', 'firefox']) +}) + +CALLBACK_EVENT_PAYLOAD_SCHEMA = vol.Schema({ + vol.Required(ATTR_TAG): cv.string, + vol.Required(ATTR_TYPE): vol.In(['received', 'clicked', 'closed']), + vol.Required(ATTR_TARGET): cv.string, + vol.Optional(ATTR_ACTION): cv.string, + vol.Optional(ATTR_DATA): dict, +}) + +NOTIFY_CALLBACK_EVENT = 'html5_notification' + +# badge and timestamp are Chrome specific (not in official spec) + +HTML5_SHOWNOTIFICATION_PARAMETERS = ('actions', 'badge', 'body', 'dir', + 'icon', 'lang', 'renotify', + 'requireInteraction', 'tag', 'timestamp', + 'vibrate') + + +def get_service(hass, config): + """Get the HTML5 push notification service.""" + json_path = hass.config.path(REGISTRATIONS_FILE) + + registrations = _load_config(json_path) + + if registrations is None: + return None + + hass.wsgi.register_view( + HTML5PushRegistrationView(hass, registrations, json_path)) + hass.wsgi.register_view(HTML5PushCallbackView(hass, registrations)) + + gcm_api_key = config.get(ATTR_GCM_API_KEY) + gcm_sender_id = config.get(ATTR_GCM_SENDER_ID) + + if gcm_sender_id is not None: + add_manifest_json_key(ATTR_GCM_SENDER_ID, + config.get(ATTR_GCM_SENDER_ID)) + + return HTML5NotificationService(gcm_api_key, registrations) + + +def _load_config(filename): + """Load configuration.""" + if not os.path.isfile(filename): + return {} + + try: + with open(filename, 'r') as fdesc: + inp = fdesc.read() + + # In case empty file + if not inp: + return {} + + return json.loads(inp) + except (IOError, ValueError) as error: + _LOGGER.error('Reading config file %s failed: %s', filename, error) + return None + + +def _save_config(filename, config): + """Save configuration.""" + try: + with open(filename, 'w') as fdesc: + fdesc.write(json.dumps(config, indent=4, sort_keys=True)) + except (IOError, TypeError) as error: + _LOGGER.error('Saving config file failed: %s', error) + return False + return True + + +class HTML5PushRegistrationView(HomeAssistantView): + """Accepts push registrations from a browser.""" + + url = '/api/notify.html5' + name = 'api:notify.html5' + + def __init__(self, hass, registrations, json_path): + """Init HTML5PushRegistrationView.""" + super().__init__(hass) + self.registrations = registrations + self.json_path = json_path + + def post(self, request): + """Accept the POST request for push registrations from a browser.""" + try: + data = REGISTER_SCHEMA(request.json) + except vol.Invalid as ex: + return self.json_message(humanize_error(request.json, ex), + HTTP_BAD_REQUEST) + + name = ensure_unique_string('unnamed device', + self.registrations.keys()) + + self.registrations[name] = data + + if not _save_config(self.json_path, self.registrations): + return self.json_message('Error saving registration.', + HTTP_INTERNAL_SERVER_ERROR) + + return self.json_message('Push notification subscriber registered.') + + def delete(self, request): + """Delete a registration.""" + subscription = request.json.get(ATTR_SUBSCRIPTION) + + found = None + + for key, registration in self.registrations.items(): + if registration.get(ATTR_SUBSCRIPTION) == subscription: + found = key + break + + if not found: + # If not found, unregistering was already done. Return 200 + return self.json_message('Registration not found.') + + reg = self.registrations.pop(found) + + if not _save_config(self.json_path, self.registrations): + self.registrations[found] = reg + return self.json_message('Error saving registration.', + HTTP_INTERNAL_SERVER_ERROR) + + return self.json_message('Push notification subscriber unregistered.') + + +class HTML5PushCallbackView(HomeAssistantView): + """Accepts push registrations from a browser.""" + + requires_auth = False + url = '/api/notify.html5/callback' + name = 'api:notify.html5/callback' + + def __init__(self, hass, registrations): + """Init HTML5PushCallbackView.""" + super().__init__(hass) + self.registrations = registrations + + def decode_jwt(self, token): + """Find the registration that signed this JWT and return it.""" + import jwt + + # 1. Check claims w/o verifying to see if a target is in there. + # 2. If target in claims, attempt to verify against the given name. + # 2a. If decode is successful, return the payload. + # 2b. If decode is unsuccessful, return a 401. + + target_check = jwt.decode(token, verify=False) + if target_check[ATTR_TARGET] in self.registrations: + possible_target = self.registrations[target_check[ATTR_TARGET]] + key = possible_target[ATTR_SUBSCRIPTION][ATTR_KEYS][ATTR_AUTH] + try: + return jwt.decode(token, key) + except jwt.exceptions.DecodeError: + pass + + return self.json_message('No target found in JWT', + status_code=HTTP_UNAUTHORIZED) + + # The following is based on code from Auth0 + # https://auth0.com/docs/quickstart/backend/python + # pylint: disable=too-many-return-statements + def check_authorization_header(self, request): + """Check the authorization header.""" + import jwt + auth = request.headers.get('Authorization', None) + if not auth: + return self.json_message('Authorization header is expected', + status_code=HTTP_UNAUTHORIZED) + + parts = auth.split() + + if parts[0].lower() != 'bearer': + return self.json_message('Authorization header must ' + 'start with Bearer', + status_code=HTTP_UNAUTHORIZED) + elif len(parts) != 2: + return self.json_message('Authorization header must ' + 'be Bearer token', + status_code=HTTP_UNAUTHORIZED) + + token = parts[1] + try: + payload = self.decode_jwt(token) + except jwt.exceptions.InvalidTokenError: + return self.json_message('token is invalid', + status_code=HTTP_UNAUTHORIZED) + return payload + + def post(self, request): + """Accept the POST request for push registrations event callback.""" + auth_check = self.check_authorization_header(request) + if not isinstance(auth_check, dict): + return auth_check + + event_payload = { + ATTR_TAG: request.json.get(ATTR_TAG), + ATTR_TYPE: request.json[ATTR_TYPE], + ATTR_TARGET: auth_check[ATTR_TARGET], + } + + if request.json.get(ATTR_ACTION) is not None: + event_payload[ATTR_ACTION] = request.json.get(ATTR_ACTION) + + if request.json.get(ATTR_DATA) is not None: + event_payload[ATTR_DATA] = request.json.get(ATTR_DATA) + + try: + event_payload = CALLBACK_EVENT_PAYLOAD_SCHEMA(event_payload) + except vol.Invalid as ex: + _LOGGER.warning('Callback event payload is not valid! %s', + humanize_error(event_payload, ex)) + + event_name = '{}.{}'.format(NOTIFY_CALLBACK_EVENT, + event_payload[ATTR_TYPE]) + self.hass.bus.fire(event_name, event_payload) + return self.json({'status': 'ok', + 'event': event_payload[ATTR_TYPE]}) + + +# pylint: disable=too-few-public-methods +class HTML5NotificationService(BaseNotificationService): + """Implement the notification service for HTML5.""" + + # pylint: disable=too-many-arguments + def __init__(self, gcm_key, registrations): + """Initialize the service.""" + self._gcm_key = gcm_key + self.registrations = registrations + + @property + def targets(self): + """Return a dictionary of registered targets.""" + return self.registrations.keys() + + # pylint: disable=too-many-locals + def send_message(self, message="", **kwargs): + """Send a message to a user.""" + import jwt + from pywebpush import WebPusher + + timestamp = int(time.time()) + tag = str(uuid.uuid4()) + + payload = { + 'badge': '/static/images/notification-badge.png', + 'body': message, + ATTR_DATA: {}, + 'icon': '/static/icons/favicon-192x192.png', + ATTR_TAG: tag, + 'timestamp': (timestamp*1000), # Javascript ms since epoch + ATTR_TITLE: kwargs.get(ATTR_TITLE) + } + + data = kwargs.get(ATTR_DATA) + + if data: + # Pick out fields that should go into the notification directly vs + # into the notification data dictionary. + + for key, val in data.copy().items(): + if key in HTML5_SHOWNOTIFICATION_PARAMETERS: + payload[key] = val + del data[key] + + payload[ATTR_DATA] = data + + if (payload[ATTR_DATA].get(ATTR_URL) is None and + payload.get(ATTR_ACTIONS) is None): + payload[ATTR_DATA][ATTR_URL] = URL_ROOT + + targets = kwargs.get(ATTR_TARGET) + + if not targets: + targets = self.registrations.keys() + elif not isinstance(targets, list): + targets = [targets] + + for target in targets: + info = self.registrations.get(target) + if info is None: + _LOGGER.error('%s is not a valid HTML5 push notification' + ' target!', target) + continue + + jwt_exp = (datetime.datetime.fromtimestamp(timestamp) + + datetime.timedelta(days=JWT_VALID_DAYS)) + jwt_secret = info[ATTR_SUBSCRIPTION][ATTR_KEYS][ATTR_AUTH] + jwt_claims = {'exp': jwt_exp, 'nbf': timestamp, + 'iat': timestamp, ATTR_TARGET: target, + ATTR_TAG: payload[ATTR_TAG]} + jwt_token = jwt.encode(jwt_claims, jwt_secret).decode('utf-8') + payload[ATTR_DATA][ATTR_JWT] = jwt_token + + WebPusher(info[ATTR_SUBSCRIPTION]).send( + json.dumps(payload), gcm_key=self._gcm_key, ttl='86400') diff --git a/homeassistant/components/notify/llamalab_automate.py b/homeassistant/components/notify/llamalab_automate.py new file mode 100644 index 00000000000..7a00b5ba237 --- /dev/null +++ b/homeassistant/components/notify/llamalab_automate.py @@ -0,0 +1,60 @@ +""" +LlamaLab Automate notification service. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.llamalab_automate/ +""" +import logging +import requests +import voluptuous as vol + +from homeassistant.components.notify import (BaseNotificationService, + PLATFORM_SCHEMA) +from homeassistant.const import CONF_API_KEY +from homeassistant.helpers import config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_TO = 'to' +CONF_DEVICE = 'device' +_RESOURCE = 'https://llamalab.com/automate/cloud/message' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_TO): cv.string, + vol.Optional(CONF_DEVICE): cv.string, +}) + + +def get_service(hass, config): + """Get the LlamaLab Automate notification service.""" + secret = config.get(CONF_API_KEY) + recipient = config.get(CONF_TO) + device = config.get(CONF_DEVICE) + + return AutomateNotificationService(secret, recipient, device) + + +# pylint: disable=too-few-public-methods +class AutomateNotificationService(BaseNotificationService): + """Implement the notification service for LlamaLab Automate.""" + + def __init__(self, secret, recipient, device=None): + """Initialize the service.""" + self._secret = secret + self._recipient = recipient + self._device = device + + def send_message(self, message="", **kwargs): + """Send a message to a user.""" + _LOGGER.debug("Sending to: %s, %s", self._recipient, str(self._device)) + data = { + "secret": self._secret, + "to": self._recipient, + "device": self._device, + "payload": message, + } + + response = requests.post(_RESOURCE, json=data) + if response.status_code != 200: + _LOGGER.error("Error sending message: " + str(response)) diff --git a/homeassistant/components/notify/pushover.py b/homeassistant/components/notify/pushover.py index a8bc3cf5179..5ded1ebe778 100644 --- a/homeassistant/components/notify/pushover.py +++ b/homeassistant/components/notify/pushover.py @@ -6,23 +6,26 @@ https://home-assistant.io/components/notify.pushover/ """ import logging +import voluptuous as vol + from homeassistant.components.notify import ( - ATTR_TITLE, ATTR_TARGET, ATTR_DATA, DOMAIN, BaseNotificationService) + ATTR_TITLE, ATTR_TARGET, ATTR_DATA, BaseNotificationService) from homeassistant.const import CONF_API_KEY -from homeassistant.helpers import validate_config +import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['python-pushover==0.2'] _LOGGER = logging.getLogger(__name__) +PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({ + vol.Required('user_key'): cv.string, + vol.Required(CONF_API_KEY): cv.string, +}) + + # pylint: disable=unused-variable def get_service(hass, config): """Get the Pushover notification service.""" - if not validate_config({DOMAIN: config}, - {DOMAIN: ['user_key', CONF_API_KEY]}, - _LOGGER): - return None - from pushover import InitError try: @@ -30,8 +33,7 @@ def get_service(hass, config): config[CONF_API_KEY]) except InitError: _LOGGER.error( - "Wrong API key supplied. " - "Get it at https://pushover.net") + 'Wrong API key supplied. Get it at https://pushover.net') return None @@ -47,7 +49,7 @@ class PushoverNotificationService(BaseNotificationService): self.pushover = Client( self._user_key, api_token=self._api_token) - def send_message(self, message="", **kwargs): + def send_message(self, message='', **kwargs): """Send a message to a user.""" from pushover import RequestError @@ -65,4 +67,4 @@ class PushoverNotificationService(BaseNotificationService): except ValueError as val_err: _LOGGER.error(str(val_err)) except RequestError: - _LOGGER.exception("Could not send pushover notification") + _LOGGER.exception('Could not send pushover notification') diff --git a/homeassistant/components/notify/rest.py b/homeassistant/components/notify/rest.py index 6c913f4f639..5cc556a1957 100644 --- a/homeassistant/components/notify/rest.py +++ b/homeassistant/components/notify/rest.py @@ -1,5 +1,5 @@ """ -REST platform for notify component. +RESTful platform for notify component. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/notify.rest/ @@ -7,45 +7,56 @@ https://home-assistant.io/components/notify.rest/ import logging import requests +import voluptuous as vol from homeassistant.components.notify import ( - ATTR_TARGET, ATTR_TITLE, DOMAIN, BaseNotificationService) -from homeassistant.helpers import validate_config + ATTR_TARGET, ATTR_TITLE, BaseNotificationService, PLATFORM_SCHEMA) +from homeassistant.const import (CONF_RESOURCE, CONF_METHOD, CONF_NAME) +import homeassistant.helpers.config_validation as cv + +CONF_MESSAGE_PARAMETER_NAME = 'message_param_name' +CONF_TARGET_PARAMETER_NAME = 'target_param_name' +CONF_TITLE_PARAMETER_NAME = 'title_param_name' +DEFAULT_MESSAGE_PARAM_NAME = 'message' +DEFAULT_METHOD = 'GET' +DEFAULT_TARGET_PARAM_NAME = None +DEFAULT_TITLE_PARAM_NAME = None + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_RESOURCE): cv.url, + vol.Optional(CONF_MESSAGE_PARAMETER_NAME, + default=DEFAULT_MESSAGE_PARAM_NAME): cv.string, + vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): + vol.In(['POST', 'GET', 'POST_JSON']), + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_TARGET_PARAMETER_NAME, + default=DEFAULT_TARGET_PARAM_NAME): cv.string, + vol.Optional(CONF_TITLE_PARAMETER_NAME, + default=DEFAULT_TITLE_PARAM_NAME): cv.string, +}) _LOGGER = logging.getLogger(__name__) -DEFAULT_METHOD = 'GET' -DEFAULT_MESSAGE_PARAM_NAME = 'message' -DEFAULT_TITLE_PARAM_NAME = None -DEFAULT_TARGET_PARAM_NAME = None - def get_service(hass, config): - """Get the REST notification service.""" - if not validate_config({DOMAIN: config}, - {DOMAIN: ['resource', ]}, - _LOGGER): - return None + """Get the RESTful notification service.""" + resource = config.get(CONF_RESOURCE) + method = config.get(CONF_METHOD) + message_param_name = config.get(CONF_MESSAGE_PARAMETER_NAME) + title_param_name = config.get(CONF_TITLE_PARAMETER_NAME) + target_param_name = config.get(CONF_TARGET_PARAMETER_NAME) - method = config.get('method', DEFAULT_METHOD) - message_param_name = config.get('message_param_name', - DEFAULT_MESSAGE_PARAM_NAME) - title_param_name = config.get('title_param_name', - DEFAULT_TITLE_PARAM_NAME) - target_param_name = config.get('target_param_name', - DEFAULT_TARGET_PARAM_NAME) - - return RestNotificationService(config['resource'], method, - message_param_name, title_param_name, - target_param_name) + return RestNotificationService( + resource, method, message_param_name, title_param_name, + target_param_name) # pylint: disable=too-few-public-methods, too-many-arguments class RestNotificationService(BaseNotificationService): - """Implement the notification service for REST.""" + """Implementation of a notification service for REST.""" - def __init__(self, resource, method, message_param_name, - title_param_name, target_param_name): + def __init__(self, resource, method, message_param_name, title_param_name, + target_param_name): """Initialize the service.""" self._resource = resource self._method = method.upper() diff --git a/homeassistant/components/notify/slack.py b/homeassistant/components/notify/slack.py index 141cf6887e9..39ca0197d0f 100644 --- a/homeassistant/components/notify/slack.py +++ b/homeassistant/components/notify/slack.py @@ -51,7 +51,15 @@ class SlackNotificationService(BaseNotificationService): import slacker channel = kwargs.get('target') or self._default_channel + data = kwargs.get('data') + if data: + attachments = data.get('attachments') + else: + attachments = None + try: - self.slack.chat.post_message(channel, message) + self.slack.chat.post_message(channel, message, + as_user=True, + attachments=attachments) except slacker.Error: _LOGGER.exception("Could not send slack notification") diff --git a/homeassistant/components/notify/telegram.py b/homeassistant/components/notify/telegram.py index 5e0cee9a441..8da916eb1f3 100644 --- a/homeassistant/components/notify/telegram.py +++ b/homeassistant/components/notify/telegram.py @@ -21,6 +21,7 @@ _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['python-telegram-bot==5.0.0'] ATTR_PHOTO = "photo" +ATTR_DOCUMENT = "document" ATTR_CAPTION = "caption" CONF_CHAT_ID = 'chat_id' @@ -102,6 +103,8 @@ class TelegramNotificationService(BaseNotificationService): return elif data is not None and ATTR_LOCATION in data: return self.send_location(data.get(ATTR_LOCATION)) + elif data is not None and ATTR_DOCUMENT in data: + return self.send_document(data.get(ATTR_DOCUMENT)) # send message try: @@ -125,6 +128,20 @@ class TelegramNotificationService(BaseNotificationService): _LOGGER.exception("Error sending photo.") return + def send_document(self, data): + """Send a document.""" + import telegram + caption = data.pop(ATTR_CAPTION, None) + + # send photo + try: + document = load_data(**data) + self.bot.sendDocument(chat_id=self._chat_id, + document=document, caption=caption) + except telegram.error.TelegramError: + _LOGGER.exception("Error sending document.") + return + def send_location(self, gps): """Send a location.""" import telegram diff --git a/homeassistant/components/qwikswitch.py b/homeassistant/components/qwikswitch.py index a93328bc723..0519ac6f40d 100644 --- a/homeassistant/components/qwikswitch.py +++ b/homeassistant/components/qwikswitch.py @@ -10,7 +10,8 @@ import voluptuous as vol from homeassistant.const import (EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers.discovery import load_platform -from homeassistant.components.light import ATTR_BRIGHTNESS, Light +from homeassistant.components.light import (ATTR_BRIGHTNESS, + SUPPORT_BRIGHTNESS, Light) from homeassistant.components.switch import SwitchDevice DOMAIN = 'qwikswitch' @@ -29,6 +30,8 @@ CONFIG_SCHEMA = vol.Schema({ QSUSB = {} +SUPPORT_QWIKSWITCH = SUPPORT_BRIGHTNESS + class QSToggleEntity(object): """Representation of a Qwikswitch Entity. @@ -108,7 +111,10 @@ class QSSwitch(QSToggleEntity, SwitchDevice): class QSLight(QSToggleEntity, Light): """Light based on a Qwikswitch relay/dimmer module.""" - pass + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_QWIKSWITCH # pylint: disable=too-many-locals diff --git a/homeassistant/components/rfxtrx.py b/homeassistant/components/rfxtrx.py index f15bd703ca1..3871058a27e 100644 --- a/homeassistant/components/rfxtrx.py +++ b/homeassistant/components/rfxtrx.py @@ -14,7 +14,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.helpers.entity import Entity from homeassistant.const import (ATTR_ENTITY_ID, TEMP_CELSIUS) -REQUIREMENTS = ['pyRFXtrx==0.10.1'] +REQUIREMENTS = ['pyRFXtrx==0.11.0'] DOMAIN = "rfxtrx" @@ -42,7 +42,7 @@ DATA_TYPES = OrderedDict([ ('Total usage', 'W'), ('Sound', ''), ('Sensor Status', ''), - ('Unknown', '')]) + ('Counter value', '')]) RECEIVED_EVT_SUBSCRIBERS = [] RFX_DEVICES = {} diff --git a/homeassistant/components/rollershutter/__init__.py b/homeassistant/components/rollershutter/__init__.py index c5fcb594f31..3928eb384d8 100644 --- a/homeassistant/components/rollershutter/__init__.py +++ b/homeassistant/components/rollershutter/__init__.py @@ -16,7 +16,7 @@ from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa import homeassistant.helpers.config_validation as cv from homeassistant.components import group from homeassistant.const import ( - SERVICE_MOVE_UP, SERVICE_MOVE_DOWN, SERVICE_STOP, + SERVICE_MOVE_UP, SERVICE_MOVE_DOWN, SERVICE_MOVE_POSITION, SERVICE_STOP, STATE_OPEN, STATE_CLOSED, STATE_UNKNOWN, ATTR_ENTITY_ID) @@ -32,11 +32,17 @@ ENTITY_ID_FORMAT = DOMAIN + '.{}' _LOGGER = logging.getLogger(__name__) ATTR_CURRENT_POSITION = 'current_position' +ATTR_POSITION = 'position' ROLLERSHUTTER_SERVICE_SCHEMA = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, }) +ROLLERSHUTTER_MOVE_POSITION_SCHEMA = ROLLERSHUTTER_SERVICE_SCHEMA.extend({ + vol.Required(ATTR_POSITION): + vol.All(vol.Coerce(int), vol.Range(min=0, max=100)), +}) + def is_open(hass, entity_id=None): """Return if the roller shutter is open based on the statemachine.""" @@ -56,6 +62,13 @@ def move_down(hass, entity_id=None): hass.services.call(DOMAIN, SERVICE_MOVE_DOWN, data) +def move_position(hass, position, entity_id=None): + """Move to specific position all or specified roller shutter.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + data[ATTR_POSITION] = position + hass.services.call(DOMAIN, SERVICE_MOVE_POSITION, data) + + def stop(hass, entity_id=None): """Stop all or specified roller shutter.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else None @@ -64,6 +77,9 @@ def stop(hass, entity_id=None): def setup(hass, config): """Track states and offer events for roller shutters.""" + _LOGGER.warning('This component has been deprecated in favour of the ' + '"cover" component and will be removed in the future.' + ' Please upgrade.') component = EntityComponent( _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_ROLLERSHUTTERS) component.setup(config) @@ -77,6 +93,8 @@ def setup(hass, config): rollershutter.move_up() elif service.service == SERVICE_MOVE_DOWN: rollershutter.move_down() + elif service.service == SERVICE_MOVE_POSITION: + rollershutter.move_position(service.data[ATTR_POSITION]) elif service.service == SERVICE_STOP: rollershutter.stop() @@ -94,6 +112,10 @@ def setup(hass, config): handle_rollershutter_service, descriptions.get(SERVICE_MOVE_DOWN), schema=ROLLERSHUTTER_SERVICE_SCHEMA) + hass.services.register(DOMAIN, SERVICE_MOVE_POSITION, + handle_rollershutter_service, + descriptions.get(SERVICE_MOVE_POSITION), + schema=ROLLERSHUTTER_MOVE_POSITION_SCHEMA) hass.services.register(DOMAIN, SERVICE_STOP, handle_rollershutter_service, descriptions.get(SERVICE_STOP), @@ -143,6 +165,10 @@ class RollershutterDevice(Entity): """Move the roller shutter up.""" raise NotImplementedError() + def move_position(self, **kwargs): + """Move the roller shutter to a specific position.""" + raise NotImplementedError() + def stop(self, **kwargs): """Stop the roller shutter.""" raise NotImplementedError() diff --git a/homeassistant/components/rollershutter/command_line.py b/homeassistant/components/rollershutter/command_line.py index c90a8be9410..976992e0061 100644 --- a/homeassistant/components/rollershutter/command_line.py +++ b/homeassistant/components/rollershutter/command_line.py @@ -32,6 +32,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): add_devices_callback(devices) +# pylint: disable=abstract-method # pylint: disable=too-many-arguments, too-many-instance-attributes class CommandRollershutter(RollershutterDevice): """Representation a command line roller shutter.""" diff --git a/homeassistant/components/rollershutter/demo.py b/homeassistant/components/rollershutter/demo.py index ebdc3907a59..31915019c5e 100644 --- a/homeassistant/components/rollershutter/demo.py +++ b/homeassistant/components/rollershutter/demo.py @@ -60,6 +60,14 @@ class DemoRollershutter(RollershutterDevice): self._listen() self._moving_up = False + def move_position(self, position, **kwargs): + """Move the roller shutter to a specific position.""" + if self._position == position: + return + + self._listen() + self._moving_up = position < self._position + def stop(self, **kwargs): """Stop the roller shutter.""" if self._listener is not None: diff --git a/homeassistant/components/rollershutter/homematic.py b/homeassistant/components/rollershutter/homematic.py index 9bdad7ee68c..613d7884919 100644 --- a/homeassistant/components/rollershutter/homematic.py +++ b/homeassistant/components/rollershutter/homematic.py @@ -30,6 +30,7 @@ def setup_platform(hass, config, add_callback_devices, discovery_info=None): add_callback_devices) +# pylint: disable=abstract-method class HMRollershutter(homematic.HMDevice, RollershutterDevice): """Represents a Homematic Rollershutter in Home Assistant.""" @@ -44,8 +45,8 @@ class HMRollershutter(homematic.HMDevice, RollershutterDevice): return int((1 - self._hm_get_state()) * 100) return None - def position(self, **kwargs): - """Move to a defined position: 0 (closed) and 100 (open).""" + def move_position(self, **kwargs): + """Move the roller shutter to a specific position.""" if self.available: if ATTR_CURRENT_POSITION in kwargs: position = float(kwargs[ATTR_CURRENT_POSITION]) diff --git a/homeassistant/components/rollershutter/mqtt.py b/homeassistant/components/rollershutter/mqtt.py index 6465c02dca2..d0183da7a0f 100644 --- a/homeassistant/components/rollershutter/mqtt.py +++ b/homeassistant/components/rollershutter/mqtt.py @@ -52,6 +52,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): )]) +# pylint: disable=abstract-method # pylint: disable=too-many-arguments, too-many-instance-attributes class MqttRollershutter(RollershutterDevice): """Representation of a roller shutter that can be controlled using MQTT.""" diff --git a/homeassistant/components/rollershutter/rfxtrx.py b/homeassistant/components/rollershutter/rfxtrx.py index 18a2844b19c..19bcea4e892 100644 --- a/homeassistant/components/rollershutter/rfxtrx.py +++ b/homeassistant/components/rollershutter/rfxtrx.py @@ -40,6 +40,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): rfxtrx.RECEIVED_EVT_SUBSCRIBERS.append(rollershutter_update) +# pylint: disable=abstract-method class RfxtrxRollershutter(rfxtrx.RfxtrxDevice, RollershutterDevice): """Representation of an rfxtrx roller shutter.""" diff --git a/homeassistant/components/rollershutter/scsgate.py b/homeassistant/components/rollershutter/scsgate.py index 078173e1924..e67395d054c 100644 --- a/homeassistant/components/rollershutter/scsgate.py +++ b/homeassistant/components/rollershutter/scsgate.py @@ -37,6 +37,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): add_devices_callback(rollershutters) +# pylint: disable=abstract-method # pylint: disable=too-many-arguments, too-many-instance-attributes class SCSGateRollerShutter(RollershutterDevice): """Representation of SCSGate rollershutter.""" diff --git a/homeassistant/components/rollershutter/services.yaml b/homeassistant/components/rollershutter/services.yaml index b7ef0a17643..2991693961b 100644 --- a/homeassistant/components/rollershutter/services.yaml +++ b/homeassistant/components/rollershutter/services.yaml @@ -14,6 +14,14 @@ move_down: description: Name(s) of roller shutter(s) to move down example: 'rollershutter.living_room' +move_position: + description: Move to specific position all or specified roller shutter + + fields: + position: + description: Position of the rollershutter (0 to 100) + example: 30 + stop: description: Stop all or specified roller shutter diff --git a/homeassistant/components/rollershutter/wink.py b/homeassistant/components/rollershutter/wink.py index 8a791ea9b97..18ed193060b 100644 --- a/homeassistant/components/rollershutter/wink.py +++ b/homeassistant/components/rollershutter/wink.py @@ -10,7 +10,7 @@ from homeassistant.components.rollershutter import RollershutterDevice from homeassistant.components.wink import WinkDevice from homeassistant.const import CONF_ACCESS_TOKEN -REQUIREMENTS = ['python-wink==0.7.11', 'pubnub==3.8.2'] +REQUIREMENTS = ['python-wink==0.7.13', 'pubnub==3.8.2'] def setup_platform(hass, config, add_devices, discovery_info=None): @@ -32,6 +32,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): pywink.get_shades()) +# pylint: disable=abstract-method class WinkRollershutterDevice(WinkDevice, RollershutterDevice): """Representation of a Wink rollershutter (shades).""" diff --git a/homeassistant/components/rollershutter/zwave.py b/homeassistant/components/rollershutter/zwave.py index 01d6980a795..1e193830005 100644 --- a/homeassistant/components/rollershutter/zwave.py +++ b/homeassistant/components/rollershutter/zwave.py @@ -15,6 +15,15 @@ from homeassistant.components.rollershutter import RollershutterDevice COMMAND_CLASS_SWITCH_MULTILEVEL = 0x26 # 38 COMMAND_CLASS_SWITCH_BINARY = 0x25 # 37 +SOMFY = 0x47 +SOMFY_ZRTSI = 0x5a52 +SOMFY_ZRTSI_CONTROLLER = (SOMFY, SOMFY_ZRTSI) +WORKAROUND = 'workaround' + +DEVICE_MAPPINGS = { + SOMFY_ZRTSI_CONTROLLER: WORKAROUND +} + _LOGGER = logging.getLogger(__name__) @@ -48,8 +57,18 @@ class ZwaveRollershutter(zwave.ZWaveDeviceEntity, RollershutterDevice): self._lozwmgr.create() self._node = value.node self._current_position = None + self._workaround = None dispatcher.connect( self.value_changed, ZWaveNetwork.SIGNAL_VALUE_CHANGED) + if (value.node.manufacturer_id.strip() and + value.node.product_id.strip()): + specific_sensor_key = (int(value.node.manufacturer_id, 16), + int(value.node.product_type, 16)) + + if specific_sensor_key in DEVICE_MAPPINGS: + if DEVICE_MAPPINGS[specific_sensor_key] == WORKAROUND: + _LOGGER.debug("Controller without positioning feedback") + self._workaround = 1 def value_changed(self, value): """Called when a value has changed on the network.""" @@ -71,20 +90,23 @@ class ZwaveRollershutter(zwave.ZWaveDeviceEntity, RollershutterDevice): @property def current_position(self): """Return the current position of Zwave roller shutter.""" - if self._current_position is not None: - if self._current_position <= 5: - return 100 - elif self._current_position >= 95: - return 0 - else: - return 100 - self._current_position + if not self._workaround: + if self._current_position is not None: + if self._current_position <= 5: + return 100 + elif self._current_position >= 95: + return 0 + else: + return 100 - self._current_position def move_up(self, **kwargs): """Move the roller shutter up.""" for value in self._node.get_values( class_id=COMMAND_CLASS_SWITCH_MULTILEVEL).values(): if value.command_class == zwave.COMMAND_CLASS_SWITCH_MULTILEVEL \ - and value.label == 'Open': + and value.label == 'Open' or \ + value.command_class == zwave.COMMAND_CLASS_SWITCH_MULTILEVEL \ + and value.label == 'Down': self._lozwmgr.pressButton(value.value_id) break @@ -93,15 +115,23 @@ class ZwaveRollershutter(zwave.ZWaveDeviceEntity, RollershutterDevice): for value in self._node.get_values( class_id=COMMAND_CLASS_SWITCH_MULTILEVEL).values(): if value.command_class == zwave.COMMAND_CLASS_SWITCH_MULTILEVEL \ + and value.label == 'Up' or \ + value.command_class == zwave.COMMAND_CLASS_SWITCH_MULTILEVEL \ and value.label == 'Close': self._lozwmgr.pressButton(value.value_id) break + def move_position(self, position, **kwargs): + """Move the roller shutter to a specific position.""" + self._node.set_dimmer(self._value.value_id, 100 - position) + def stop(self, **kwargs): """Stop the roller shutter.""" for value in self._node.get_values( class_id=COMMAND_CLASS_SWITCH_MULTILEVEL).values(): if value.command_class == zwave.COMMAND_CLASS_SWITCH_MULTILEVEL \ - and value.label == 'Open': + and value.label == 'Open' or \ + value.command_class == zwave.COMMAND_CLASS_SWITCH_MULTILEVEL \ + and value.label == 'Down': self._lozwmgr.releaseButton(value.value_id) break diff --git a/homeassistant/components/sensor/bitcoin.py b/homeassistant/components/sensor/bitcoin.py index 777571c84c0..0d859314bb4 100644 --- a/homeassistant/components/sensor/bitcoin.py +++ b/homeassistant/components/sensor/bitcoin.py @@ -7,11 +7,16 @@ https://home-assistant.io/components/sensor.bitcoin/ import logging from datetime import timedelta +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_DISPLAY_OPTIONS +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle REQUIREMENTS = ['blockchain==1.3.3'] -_LOGGER = logging.getLogger(__name__) + OPTION_TYPES = { 'exchangerate': ['Exchange rate (1 BTC)', None], 'trade_volume_btc': ['Trade volume', 'BTC'], @@ -35,17 +40,27 @@ OPTION_TYPES = { 'miners_revenue_btc': ['Miners revenue', 'BTC'], 'market_price_usd': ['Market price', 'USD'] } + ICON = 'mdi:currency-btc' +CONF_CURRENCY = 'currency' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_DISPLAY_OPTIONS, default=[]): + [vol.In(OPTION_TYPES)], + vol.Optional(CONF_CURRENCY, default='USD'): cv.string, +}) + +_LOGGER = logging.getLogger(__name__) # Return cached results if last scan was less then this time ago. -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Bitcoin sensors.""" from blockchain import exchangerates - currency = config.get('currency', 'USD') + currency = config.get(CONF_CURRENCY) if currency not in exchangerates.get_ticker(): _LOGGER.error('Currency "%s" is not available. Using "USD"', currency) @@ -53,11 +68,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): data = BitcoinData() dev = [] - for variable in config['display_options']: - if variable not in OPTION_TYPES: - _LOGGER.error('Option type: "%s" does not exist', variable) - else: - dev.append(BitcoinSensor(data, variable, currency)) + for variable in config[CONF_DISPLAY_OPTIONS]: + dev.append(BitcoinSensor(data, variable, currency)) add_devices(dev) diff --git a/homeassistant/components/sensor/cpuspeed.py b/homeassistant/components/sensor/cpuspeed.py index f3bd2dac9e2..51a9226e1b0 100644 --- a/homeassistant/components/sensor/cpuspeed.py +++ b/homeassistant/components/sensor/cpuspeed.py @@ -6,27 +6,39 @@ https://home-assistant.io/components/sensor.cpuspeed/ """ import logging +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity REQUIREMENTS = ['py-cpuinfo==0.2.3'] _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = "CPU speed" -ATTR_VENDOR = 'Vendor ID' ATTR_BRAND = 'Brand' ATTR_HZ = 'GHz Advertised' +ATTR_VENDOR = 'Vendor ID' + +DEFAULT_NAME = 'CPU speed' ICON = 'mdi:pulse' +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + # pylint: disable=unused-variable def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the CPU speed sensor.""" - add_devices([CpuSpeedSensor(config.get('name', DEFAULT_NAME))]) + name = config.get(CONF_NAME) + + add_devices([CpuSpeedSensor(name)]) class CpuSpeedSensor(Entity): - """Representation a CPU sensor.""" + """Representation of a CPU sensor.""" def __init__(self, name): """Initialize the sensor.""" diff --git a/homeassistant/components/sensor/deutsche_bahn.py b/homeassistant/components/sensor/deutsche_bahn.py index 2dc589271e9..17c14bb5df1 100644 --- a/homeassistant/components/sensor/deutsche_bahn.py +++ b/homeassistant/components/sensor/deutsche_bahn.py @@ -9,7 +9,7 @@ from datetime import timedelta import voluptuous as vol -from homeassistant.const import (CONF_PLATFORM) +from homeassistant.components.sensor import PLATFORM_SCHEMA import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle from homeassistant.helpers.entity import Entity @@ -17,21 +17,20 @@ import homeassistant.util.dt as dt_util REQUIREMENTS = ['schiene==0.17'] -CONF_START = 'from' -CONF_DESTINATION = 'to' -ICON = 'mdi:train' - _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): 'deutsche_bahn', - vol.Required(CONF_START): cv.string, - vol.Required(CONF_DESTINATION): cv.string, -}) +CONF_DESTINATION = 'to' +CONF_START = 'from' + +ICON = 'mdi:train' -# Return cached results if last scan was less then this time ago. MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_DESTINATION): cv.string, + vol.Required(CONF_START): cv.string, +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Deutsche Bahn Sensor.""" @@ -47,7 +46,7 @@ class DeutscheBahnSensor(Entity): def __init__(self, start, goal): """Initialize the sensor.""" - self._name = start + ' to ' + goal + self._name = '{} to {}'.format(start, goal) self.data = SchieneData(start, goal) self.update() diff --git a/homeassistant/components/sensor/dht.py b/homeassistant/components/sensor/dht.py index ec33b1e4042..109e539c599 100644 --- a/homeassistant/components/sensor/dht.py +++ b/homeassistant/components/sensor/dht.py @@ -13,14 +13,18 @@ from homeassistant.util import Throttle from homeassistant.util.temperature import celsius_to_fahrenheit # Update this requirement to upstream as soon as it supports Python 3. -REQUIREMENTS = ['http://github.com/mala-zaba/Adafruit_Python_DHT/archive/' - '4101340de8d2457dd194bca1e8d11cbfc237e919.zip' - '#Adafruit_DHT==1.1.0'] +REQUIREMENTS = ['http://github.com/adafruit/Adafruit_Python_DHT/archive/' + '310c59b0293354d07d94375f1365f7b9b9110c7d.zip' + '#Adafruit_DHT==1.3.0'] _LOGGER = logging.getLogger(__name__) +CONF_PIN = 'pin' +CONF_SENSOR = 'sensor' +SENSOR_TEMPERATURE = 'temperature' +SENSOR_HUMIDITY = 'humidity' SENSOR_TYPES = { - 'temperature': ['Temperature', None], - 'humidity': ['Humidity', '%'] + SENSOR_TEMPERATURE: ['Temperature', None], + SENSOR_HUMIDITY: ['Humidity', '%'] } DEFAULT_NAME = "DHT Sensor" # Return cached results if last scan was less then this time ago @@ -33,15 +37,14 @@ def setup_platform(hass, config, add_devices, discovery_info=None): # pylint: disable=import-error import Adafruit_DHT - SENSOR_TYPES['temperature'][1] = hass.config.units.temperature_unit + SENSOR_TYPES[SENSOR_TEMPERATURE][1] = hass.config.units.temperature_unit available_sensors = { "DHT11": Adafruit_DHT.DHT11, "DHT22": Adafruit_DHT.DHT22, "AM2302": Adafruit_DHT.AM2302 } - sensor = available_sensors[config['sensor']] - - pin = config['pin'] + sensor = available_sensors.get(config.get(CONF_SENSOR)) + pin = config.get(CONF_PIN) if not sensor or not pin: _LOGGER.error( @@ -101,13 +104,17 @@ class DHTSensor(Entity): self.dht_client.update() data = self.dht_client.data - if self.type == 'temperature': - self._state = round(data['temperature'], 1) - if self.temp_unit == TEMP_FAHRENHEIT: - self._state = round(celsius_to_fahrenheit(data['temperature']), - 1) - elif self.type == 'humidity': - self._state = round(data['humidity'], 1) + if self.type == SENSOR_TEMPERATURE: + temperature = round(data[SENSOR_TEMPERATURE], 1) + if (temperature >= -20) and (temperature < 80): + self._state = temperature + if self.temp_unit == TEMP_FAHRENHEIT: + self._state = round(celsius_to_fahrenheit(temperature), + 1) + elif self.type == SENSOR_HUMIDITY: + humidity = round(data[SENSOR_HUMIDITY], 1) + if (humidity >= 0) and (humidity <= 100): + self._state = humidity class DHTClient(object): @@ -126,6 +133,6 @@ class DHTClient(object): humidity, temperature = self.adafruit_dht.read_retry(self.sensor, self.pin) if temperature: - self.data['temperature'] = temperature + self.data[SENSOR_TEMPERATURE] = temperature if humidity: - self.data['humidity'] = humidity + self.data[SENSOR_HUMIDITY] = humidity diff --git a/homeassistant/components/sensor/dte_energy_bridge.py b/homeassistant/components/sensor/dte_energy_bridge.py index deb04e12128..90b484f46dc 100644 --- a/homeassistant/components/sensor/dte_energy_bridge.py +++ b/homeassistant/components/sensor/dte_energy_bridge.py @@ -1,33 +1,48 @@ -"""Support for monitoring energy usage using the DTE energy bridge.""" +""" +Support for monitoring energy usage using the DTE energy bridge. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.dte_energy_bridge/ +""" import logging +import voluptuous as vol + from homeassistant.helpers.entity import Entity +from homeassistant.components.sensor import PLATFORM_SCHEMA +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_NAME _LOGGER = logging.getLogger(__name__) +CONF_IP_ADDRESS = 'ip' + +DEFAULT_NAME = 'Current Energy Usage' + ICON = 'mdi:flash' +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_IP_ADDRESS): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the DTE energy bridge sensor.""" - ip_address = config.get('ip') - if not ip_address: - _LOGGER.error( - "Configuration Error" - "'ip' of the DTE energy bridge is required") - return None - dev = [DteEnergyBridgeSensor(ip_address)] - add_devices(dev) + name = config.get(CONF_NAME) + ip_address = config.get(CONF_IP_ADDRESS) + + add_devices([DteEnergyBridgeSensor(ip_address, name)]) # pylint: disable=too-many-instance-attributes class DteEnergyBridgeSensor(Entity): - """Implementation of an DTE Energy Bridge sensor.""" + """Implementation of a DTE Energy Bridge sensor.""" - def __init__(self, ip_address): + def __init__(self, ip_address, name): """Initialize the sensor.""" self._url = "http://{}/instantaneousdemand".format(ip_address) - self._name = "Current Energy Usage" + self._name = name self._unit_of_measurement = "kW" self._state = None diff --git a/homeassistant/components/sensor/dweet.py b/homeassistant/components/sensor/dweet.py index 9b078e9df74..8d731dc4084 100644 --- a/homeassistant/components/sensor/dweet.py +++ b/homeassistant/components/sensor/dweet.py @@ -8,33 +8,43 @@ import json import logging from datetime import timedelta -from homeassistant.const import CONF_VALUE_TEMPLATE, STATE_UNKNOWN +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_NAME, CONF_VALUE_TEMPLATE, STATE_UNKNOWN, CONF_UNIT_OF_MEASUREMENT) +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers import template from homeassistant.util import Throttle -_LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['dweepy==0.2.0'] -DEFAULT_NAME = 'Dweet.io Sensor' +_LOGGER = logging.getLogger(__name__) + CONF_DEVICE = 'device' -# Return cached results if last scan was less then this time ago. +DEFAULT_NAME = 'Dweet.io Sensor' + MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_DEVICE): cv.string, + vol.Required(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, +}) + # pylint: disable=unused-variable, too-many-function-args def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Dweet sensor.""" import dweepy - device = config.get('device') + name = config.get(CONF_NAME) + device = config.get(CONF_DEVICE) value_template = config.get(CONF_VALUE_TEMPLATE) - - if None in (device, value_template): - _LOGGER.error('Not all required config keys present: %s', - ', '.join(CONF_DEVICE, CONF_VALUE_TEMPLATE)) - return False + unit = config.get(CONF_UNIT_OF_MEASUREMENT) try: content = json.dumps(dweepy.get_latest_dweet_for(device)[0]['content']) @@ -50,11 +60,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): dweet = DweetData(device) - add_devices([DweetSensor(hass, - dweet, - config.get('name', DEFAULT_NAME), - value_template, - config.get('unit_of_measurement'))]) + add_devices([DweetSensor(hass, dweet, name, value_template, unit)]) # pylint: disable=too-many-arguments diff --git a/homeassistant/components/sensor/ecobee.py b/homeassistant/components/sensor/ecobee.py index 61ce2b6770c..961fb9aabe3 100644 --- a/homeassistant/components/sensor/ecobee.py +++ b/homeassistant/components/sensor/ecobee.py @@ -4,8 +4,6 @@ Support for Ecobee sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.ecobee/ """ -import logging - from homeassistant.components import ecobee from homeassistant.const import TEMP_FAHRENHEIT from homeassistant.helpers.entity import Entity @@ -13,11 +11,9 @@ from homeassistant.helpers.entity import Entity DEPENDENCIES = ['ecobee'] SENSOR_TYPES = { 'temperature': ['Temperature', TEMP_FAHRENHEIT], - 'humidity': ['Humidity', '%'], - 'occupancy': ['Occupancy', None] + 'humidity': ['Humidity', '%'] } -_LOGGER = logging.getLogger(__name__) ECOBEE_CONFIG_FILE = 'ecobee.conf' @@ -30,8 +26,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): for index in range(len(data.ecobee.thermostats)): for sensor in data.ecobee.get_remote_sensors(index): for item in sensor['capability']: - if item['type'] not in ('temperature', - 'humidity', 'occupancy'): + if item['type'] not in ('temperature', 'humidity'): continue dev.append(EcobeeSensor(sensor['name'], item['type'], index)) diff --git a/homeassistant/components/sensor/efergy.py b/homeassistant/components/sensor/efergy.py index 5650214da27..3a1bcfbf5a4 100644 --- a/homeassistant/components/sensor/efergy.py +++ b/homeassistant/components/sensor/efergy.py @@ -5,40 +5,60 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.efergy/ """ import logging +import voluptuous as vol from requests import RequestException, get +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) _RESOURCE = 'https://engage.efergy.com/mobile_proxy/' + +CONF_APPTOKEN = 'app_token' +CONF_UTC_OFFSET = 'utc_offset' +CONF_MONITORED_VARIABLES = 'monitored_variables' +CONF_SENSOR_TYPE = 'type' + +CONF_CURRENCY = 'currency' +CONF_PERIOD = 'period' + +CONF_INSTANT = 'instant_readings' +CONF_BUDGET = 'budget' +CONF_COST = 'cost' + SENSOR_TYPES = { - 'instant_readings': ['Energy Usage', 'kW'], - 'budget': ['Energy Budget', None], - 'cost': ['Energy Cost', None], + CONF_INSTANT: ['Energy Usage', 'kW'], + CONF_BUDGET: ['Energy Budget', None], + CONF_COST: ['Energy Cost', None], } +TYPES_SCHEMA = vol.In( + [CONF_INSTANT, CONF_BUDGET, CONF_COST]) + +SENSORS_SCHEMA = vol.Schema({ + vol.Required(CONF_SENSOR_TYPE): TYPES_SCHEMA, + vol.Optional(CONF_CURRENCY, default=''): cv.string, + vol.Optional(CONF_PERIOD, default='year'): cv.string, +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_APPTOKEN): cv.string, + vol.Optional(CONF_UTC_OFFSET): cv.string, + vol.Required(CONF_MONITORED_VARIABLES): [SENSORS_SCHEMA] +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Efergy sensor.""" - app_token = config.get("app_token") - if not app_token: - _LOGGER.error( - "Configuration Error" - "Please make sure you have configured your app token") - return None - utc_offset = str(config.get("utc_offset")) + app_token = config.get(CONF_APPTOKEN) + utc_offset = str(config.get(CONF_UTC_OFFSET)) dev = [] - for variable in config['monitored_variables']: - if 'period' not in variable: - variable['period'] = '' - if 'currency' not in variable: - variable['currency'] = '' - if variable['type'] not in SENSOR_TYPES: - _LOGGER.error('Sensor type: "%s" does not exist', variable) - else: - dev.append(EfergySensor(variable['type'], app_token, utc_offset, - variable['period'], variable['currency'])) + for variable in config[CONF_MONITORED_VARIABLES]: + dev.append(EfergySensor( + variable[CONF_SENSOR_TYPE], app_token, utc_offset, + variable[CONF_PERIOD], variable[CONF_CURRENCY])) add_devices(dev) diff --git a/homeassistant/components/sensor/enocean.py b/homeassistant/components/sensor/enocean.py index 23a59fb5ece..e998b5c9c46 100644 --- a/homeassistant/components/sensor/enocean.py +++ b/homeassistant/components/sensor/enocean.py @@ -4,20 +4,32 @@ Support for EnOcean sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.enocean/ """ +import logging -from homeassistant.const import CONF_NAME +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (CONF_NAME, CONF_ID) from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv from homeassistant.components import enocean -DEPENDENCIES = ["enocean"] +_LOGGER = logging.getLogger(__name__) -CONF_ID = "id" +DEFAULT_NAME = 'EnOcean sensor' +DEPENDENCIES = ['enocean'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ID): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) def setup_platform(hass, config, add_devices, discovery_info=None): """Setup an EnOcean sensor device.""" - dev_id = config.get(CONF_ID, None) - devname = config.get(CONF_NAME, None) + dev_id = config.get(CONF_ID) + devname = config.get(CONF_NAME) + add_devices([EnOceanSensor(dev_id, devname)]) diff --git a/homeassistant/components/sensor/fastdotcom.py b/homeassistant/components/sensor/fastdotcom.py index fbdf6220c91..95d91d42efc 100644 --- a/homeassistant/components/sensor/fastdotcom.py +++ b/homeassistant/components/sensor/fastdotcom.py @@ -5,10 +5,12 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.fastdotcom/ """ import logging +import voluptuous as vol import homeassistant.util.dt as dt_util +import homeassistant.helpers.config_validation as cv from homeassistant.components import recorder -from homeassistant.components.sensor import DOMAIN +from homeassistant.components.sensor import (DOMAIN, PLATFORM_SCHEMA) from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import track_time_change @@ -22,6 +24,17 @@ CONF_MINUTE = 'minute' CONF_HOUR = 'hour' CONF_DAY = 'day' +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_SECOND, default=[0]): + vol.All(cv.ensure_list, [vol.All(vol.Coerce(int), vol.Range(0, 59))]), + vol.Optional(CONF_MINUTE, default=[0]): + vol.All(cv.ensure_list, [vol.All(vol.Coerce(int), vol.Range(0, 59))]), + vol.Optional(CONF_HOUR): + vol.All(cv.ensure_list, [vol.All(vol.Coerce(int), vol.Range(0, 23))]), + vol.Optional(CONF_DAY): + vol.All(cv.ensure_list, [vol.All(vol.Coerce(int), vol.Range(1, 31))]), +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Fast.com sensor.""" @@ -43,10 +56,10 @@ class SpeedtestSensor(Entity): def __init__(self, speedtest_data): """Initialize the sensor.""" - self._name = 'Fast.com Speedtest' + self._name = 'Fast.com Download' self.speedtest_client = speedtest_data self._state = None - self._unit_of_measurement = 'Mbps' + self._unit_of_measurement = 'Mbit/s' @property def name(self): @@ -78,6 +91,8 @@ class SpeedtestSensor(Entity): ).order_by(states.state_id.desc()).limit(1)) except TypeError: return + except RuntimeError: + return if not last_state: return self._state = last_state[0].state @@ -92,10 +107,10 @@ class SpeedtestData(object): """Initialize the data object.""" self.data = None track_time_change(hass, self.update, - second=config.get(CONF_SECOND, 0), - minute=config.get(CONF_MINUTE, 0), - hour=config.get(CONF_HOUR, None), - day=config.get(CONF_DAY, None)) + second=config.get(CONF_SECOND), + minute=config.get(CONF_MINUTE), + hour=config.get(CONF_HOUR), + day=config.get(CONF_DAY)) def update(self, now): """Get the latest data from fast.com.""" diff --git a/homeassistant/components/sensor/fixer.py b/homeassistant/components/sensor/fixer.py index 05f6003039e..8aa5002fbfa 100644 --- a/homeassistant/components/sensor/fixer.py +++ b/homeassistant/components/sensor/fixer.py @@ -9,7 +9,8 @@ from datetime import timedelta import voluptuous as vol -from homeassistant.const import (CONF_PLATFORM, CONF_NAME) +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv @@ -18,33 +19,33 @@ REQUIREMENTS = ['fixerio==0.1.1'] _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = "Exchange rate" -ICON = 'mdi:currency' - CONF_BASE = 'base' CONF_TARGET = 'target' -STATE_ATTR_BASE = 'Base currency' -STATE_ATTR_TARGET = 'Target currency' -STATE_ATTR_EXCHANGE_RATE = 'Exchange rate' +DEFAULT_BASE = 'USD' +DEFAULT_NAME = 'Exchange rate' -PLATFORM_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): 'fixer', - vol.Optional(CONF_BASE): cv.string, - vol.Optional(CONF_NAME): cv.string, - vol.Required(CONF_TARGET): cv.string, -}) +ICON = 'mdi:currency' -# Return cached results if last scan was less then this time ago. MIN_TIME_BETWEEN_UPDATES = timedelta(days=1) +STATE_ATTR_BASE = 'Base currency' +STATE_ATTR_EXCHANGE_RATE = 'Exchange rate' +STATE_ATTR_TARGET = 'Target currency' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_TARGET): cv.string, + vol.Optional(CONF_BASE, default=DEFAULT_BASE): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Fixer.io sensor.""" from fixerio import (Fixerio, exceptions) - name = config.get(CONF_NAME, DEFAULT_NAME) - base = config.get(CONF_BASE, 'USD') + name = config.get(CONF_NAME) + base = config.get(CONF_BASE) target = config.get(CONF_TARGET) try: diff --git a/homeassistant/components/sensor/forecast.py b/homeassistant/components/sensor/forecast.py index 1a569d3d4c3..4f3b2cd17c7 100644 --- a/homeassistant/components/sensor/forecast.py +++ b/homeassistant/components/sensor/forecast.py @@ -6,12 +6,14 @@ https://home-assistant.io/components/sensor.forecast/ """ import logging from datetime import timedelta +import voluptuous as vol from requests.exceptions import ConnectionError as ConnectError, \ HTTPError, Timeout -from homeassistant.components.sensor import DOMAIN -from homeassistant.const import CONF_API_KEY -from homeassistant.helpers import validate_config +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (CONF_API_KEY, CONF_NAME, + CONF_MONITORED_CONDITIONS) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -44,7 +46,28 @@ SENSOR_TYPES = { 'pressure': ['Pressure', 'mbar', 'mbar', 'mbar', 'mbar', 'mbar'], 'visibility': ['Visibility', 'km', 'm', 'km', 'km', 'm'], 'ozone': ['Ozone', 'DU', 'DU', 'DU', 'DU', 'DU'], + 'apparent_temperature_max': ['Daily High Apparent Temperature', + '°C', '°F', '°C', '°C', '°C'], + 'apparent_temperature_min': ['Daily Low Apparent Temperature', + '°C', '°F', '°C', '°C', '°C'], + 'temperature_max': ['Daily High Temperature', + '°C', '°F', '°C', '°C', '°C'], + 'temperature_min': ['Daily Low Temperature', + '°C', '°F', '°C', '°C', '°C'], + 'precip_intensity_max': ['Daily Max Precip Intensity', + 'mm', 'in', 'mm', 'mm', 'mm'], } +DEFAULT_NAME = "Forecast.io" +CONF_UNITS = 'units' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_MONITORED_CONDITIONS): + vol.All(cv.ensure_list, [vol.In(list(SENSOR_TYPES))]), + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UNITS): vol.In(['auto', 'si', 'us', 'ca', 'uk', 'uk2']) +}) + # Return cached results if last scan was less then this time ago. MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) @@ -56,12 +79,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if None in (hass.config.latitude, hass.config.longitude): _LOGGER.error("Latitude or longitude not set in Home Assistant config") return False - elif not validate_config({DOMAIN: config}, - {DOMAIN: [CONF_API_KEY]}, _LOGGER): - return False - if 'units' in config: - units = config['units'] + if CONF_UNITS in config: + units = config[CONF_UNITS] elif hass.config.units.is_metric: units = 'si' else: @@ -78,13 +98,12 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.error(error) return False + name = config.get(CONF_NAME) + # Initialize and add all of the sensors. sensors = [] - for variable in config['monitored_conditions']: - if variable in SENSOR_TYPES: - sensors.append(ForeCastSensor(forecast_data, variable)) - else: - _LOGGER.error('Sensor type: "%s" does not exist', variable) + for variable in config[CONF_MONITORED_CONDITIONS]: + sensors.append(ForeCastSensor(forecast_data, variable, name)) add_devices(sensors) @@ -93,9 +112,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class ForeCastSensor(Entity): """Implementation of a Forecast.io sensor.""" - def __init__(self, forecast_data, sensor_type): + def __init__(self, forecast_data, sensor_type, name): """Initialize the sensor.""" - self.client_name = 'Weather' + self.client_name = name self._name = SENSOR_TYPES[sensor_type][0] self.forecast_data = forecast_data self.type = sensor_type @@ -152,16 +171,26 @@ class ForeCastSensor(Entity): self.forecast_data.update_hourly() hourly = self.forecast_data.data_hourly self._state = getattr(hourly, 'summary', '') - elif self.type == 'daily_summary': + elif self.type in ['daily_summary', + 'temperature_min', 'temperature_max', + 'apparent_temperature_min', + 'apparent_temperature_max', + 'precip_intensity_max']: self.forecast_data.update_daily() daily = self.forecast_data.data_daily - self._state = getattr(daily, 'summary', '') + if self.type == 'daily_summary': + self._state = getattr(daily, 'summary', '') + else: + if hasattr(daily, 'data'): + self._state = self.get_state(daily.data[0]) + else: + self._state = 0 else: self.forecast_data.update_currently() currently = self.forecast_data.data_currently - self._state = self.get_currently_state(currently) + self._state = self.get_state(currently) - def get_currently_state(self, data): + def get_state(self, data): """ Helper function that returns a new state based on the type. @@ -175,6 +204,9 @@ class ForeCastSensor(Entity): if self.type in ['precip_probability', 'cloud_cover', 'humidity']: return round(state * 100, 1) elif (self.type in ['dew_point', 'temperature', 'apparent_temperature', + 'temperature_min', 'temperature_max', + 'apparent_temperature_min', + 'apparent_temperature_max', 'pressure', 'ozone']): return round(state, 1) return state diff --git a/homeassistant/components/sensor/fritzbox_callmonitor.py b/homeassistant/components/sensor/fritzbox_callmonitor.py new file mode 100644 index 00000000000..7525e5fcc81 --- /dev/null +++ b/homeassistant/components/sensor/fritzbox_callmonitor.py @@ -0,0 +1,160 @@ +""" +A sensor to monitor incoming and outgoing phone calls on a Fritz!Box router. + +To activate the call monitor on your Fritz!Box, dial #96*5* from any phone +connected to it. +""" +import logging +import socket +import threading +import datetime +import time +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) +DEFAULT_NAME = 'Phone' +DEFAULT_HOST = '169.254.1.1' # IP valid for all Fritz!Box routers +DEFAULT_PORT = 1012 +# sensor values +VALUE_DEFAULT = 'idle' # initial value +VALUE_RING = 'ringing' +VALUE_CALL = 'dialing' +VALUE_CONNECT = 'talking' +VALUE_DISCONNECT = 'idle' +INTERVAL_RECONNECT = 60 + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup Fritz!Box call monitor sensor platform.""" + host = config.get('host', DEFAULT_HOST) + port = config.get('port', DEFAULT_PORT) + + sensor = FritzBoxCallSensor(name=config.get('name', DEFAULT_NAME)) + + add_devices([sensor]) + + monitor = FritzBoxCallMonitor(host=host, port=port, sensor=sensor) + monitor.connect() + + if monitor.sock is None: + return False + else: + return True + + +# pylint: disable=too-few-public-methods +class FritzBoxCallSensor(Entity): + """Implementation of a Fritz!Box call monitor.""" + + def __init__(self, name): + """Initialize the sensor.""" + self._state = VALUE_DEFAULT + self._attributes = {} + self._name = name + + def set_state(self, state): + """Set the state.""" + self._state = state + + def set_attributes(self, attributes): + """Set the state attributes.""" + self._attributes = attributes + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._attributes + + +# pylint: disable=too-few-public-methods +class FritzBoxCallMonitor(object): + """Event listener to monitor calls on the Fritz!Box.""" + + def __init__(self, host, port, sensor): + """Initialize Fritz!Box monitor instance.""" + self.host = host + self.port = port + self.sock = None + self._sensor = sensor + + def connect(self): + """Connect to the Fritz!Box.""" + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.sock.settimeout(10) + try: + self.sock.connect((self.host, self.port)) + threading.Thread(target=self._listen, daemon=True).start() + except socket.error as err: + self.sock = None + _LOGGER.error("Cannot connect to %s on port %s: %s", + self.host, self.port, err) + + def _listen(self): + """Listen to incoming or outgoing calls.""" + while True: + try: + response = self.sock.recv(2048) + except socket.timeout: + # if no response after 10 seconds, just recv again + continue + response = str(response, "utf-8") + + if not response: + # if the response is empty, the connection has been lost. + # try to reconnect + self.sock = None + while self.sock is None: + self.connect() + time.sleep(INTERVAL_RECONNECT) + else: + line = response.split("\n", 1)[0] + self._parse(line) + time.sleep(1) + return + + def _parse(self, line): + """Parse the call information and set the sensor states.""" + line = line.split(";") + df_in = "%d.%m.%y %H:%M:%S" + df_out = "%Y-%m-%dT%H:%M:%S" + isotime = datetime.datetime.strptime(line[0], df_in).strftime(df_out) + if line[1] == "RING": + self._sensor.set_state(VALUE_RING) + att = {"type": "incoming", + "from": line[3], + "to": line[4], + "device": line[5], + "initiated": isotime} + self._sensor.set_attributes(att) + elif line[1] == "CALL": + self._sensor.set_state(VALUE_CALL) + att = {"type": "outgoing", + "from": line[4], + "to": line[5], + "device": line[6], + "initiated": isotime} + self._sensor.set_attributes(att) + elif line[1] == "CONNECT": + self._sensor.set_state(VALUE_CONNECT) + att = {"with": line[4], "device": [3], "accepted": isotime} + self._sensor.set_attributes(att) + elif line[1] == "DISCONNECT": + self._sensor.set_state(VALUE_DISCONNECT) + att = {"duration": line[3], "closed": isotime} + self._sensor.set_attributes(att) + self._sensor.update_ha_state() diff --git a/homeassistant/components/sensor/glances.py b/homeassistant/components/sensor/glances.py index 4ebe8d797a5..51a8ac4d46f 100644 --- a/homeassistant/components/sensor/glances.py +++ b/homeassistant/components/sensor/glances.py @@ -8,17 +8,22 @@ import logging from datetime import timedelta import requests +import voluptuous as vol -from homeassistant.const import STATE_UNKNOWN +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_HOST, CONF_PORT, STATE_UNKNOWN, CONF_NAME, CONF_RESOURCES) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) +_RESOURCE = 'api/2/all' + +DEFAULT_HOST = 'localhost' +DEFAULT_NAME = 'Glances' +DEFAULT_PORT = '61208' -_RESOURCE = '/api/2/all' -CONF_HOST = 'host' -CONF_PORT = '61208' -CONF_RESOURCES = 'resources' SENSOR_TYPES = { 'disk_use_percent': ['Disk Use', '%'], 'disk_use': ['Disk Use', 'GiB'], @@ -36,7 +41,15 @@ SENSOR_TYPES = { 'process_sleeping': ['Sleeping', None] } -_LOGGER = logging.getLogger(__name__) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_RESOURCES, default=['disk_use']): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), +}) + + # Return cached results if last scan was less then this time ago. MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) @@ -44,25 +57,17 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) # pylint: disable=unused-variable def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Glances sensor.""" + name = config.get(CONF_NAME) host = config.get(CONF_HOST) - port = config.get('port', CONF_PORT) - url = 'http://{}:{}{}'.format(host, port, _RESOURCE) + port = config.get(CONF_PORT) + url = 'http://{}:{}/{}'.format(host, port, _RESOURCE) var_conf = config.get(CONF_RESOURCES) - if None in (host, var_conf): - _LOGGER.error('Not all required config keys present: %s', - ', '.join((CONF_HOST, CONF_RESOURCES))) - return False - try: response = requests.get(url, timeout=10) if not response.ok: _LOGGER.error('Response status is "%s"', response.status_code) return False - except requests.exceptions.MissingSchema: - _LOGGER.error("Missing resource or schema in configuration. " - "Please check the details in the configuration file") - return False except requests.exceptions.ConnectionError: _LOGGER.error("No route to resource/endpoint: %s", url) return False @@ -71,10 +76,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): dev = [] for resource in var_conf: - if resource not in SENSOR_TYPES: - _LOGGER.error('Sensor type: "%s" does not exist', resource) - else: - dev.append(GlancesSensor(rest, config.get('name'), resource)) + dev.append(GlancesSensor(rest, name, resource)) add_devices(dev) diff --git a/homeassistant/components/sensor/google_travel_time.py b/homeassistant/components/sensor/google_travel_time.py index 378e9c9c124..98cfb469faa 100644 --- a/homeassistant/components/sensor/google_travel_time.py +++ b/homeassistant/components/sensor/google_travel_time.py @@ -7,30 +7,32 @@ https://home-assistant.io/components/sensor.google_travel_time/ from datetime import datetime from datetime import timedelta import logging + import voluptuous as vol +from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.helpers.entity import Entity from homeassistant.const import ( - CONF_API_KEY, EVENT_HOMEASSISTANT_START, ATTR_LATITUDE, ATTR_LONGITUDE) - + CONF_API_KEY, CONF_NAME, EVENT_HOMEASSISTANT_START, ATTR_LATITUDE, + ATTR_LONGITUDE) from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv import homeassistant.helpers.location as location import homeassistant.util.dt as dt_util -_LOGGER = logging.getLogger(__name__) - REQUIREMENTS = ['googlemaps==2.4.4'] -# Return cached results if last update was less then this time ago -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) +_LOGGER = logging.getLogger(__name__) -CONF_ORIGIN = 'origin' CONF_DESTINATION = 'destination' -CONF_TRAVEL_MODE = 'travel_mode' -CONF_OPTIONS = 'options' CONF_MODE = 'mode' -CONF_NAME = 'name' +CONF_OPTIONS = 'options' +CONF_ORIGIN = 'origin' +CONF_TRAVEL_MODE = 'travel_mode' + +DEFAULT_NAME = 'Google Travel Time' + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) ALL_LANGUAGES = ['ar', 'bg', 'bn', 'ca', 'cs', 'da', 'de', 'el', 'en', 'es', 'eu', 'fa', 'fi', 'fr', 'gl', 'gu', 'hi', 'hr', 'hu', 'id', @@ -39,36 +41,34 @@ ALL_LANGUAGES = ['ar', 'bg', 'bn', 'ca', 'cs', 'da', 'de', 'el', 'en', 'es', 'sr', 'sv', 'ta', 'te', 'th', 'tl', 'tr', 'uk', 'vi', 'zh-CN', 'zh-TW'] +AVOID = ['tolls', 'highways', 'ferries', 'indoor'] TRANSIT_PREFS = ['less_walking', 'fewer_transfers'] +TRANSPORT_TYPE = ['bus', 'subway', 'train', 'tram', 'rail'] +TRAVEL_MODE = ['driving', 'walking', 'bicycling', 'transit'] +TRAVEL_MODEL = ['best_guess', 'pessimistic', 'optimistic'] +UNITS = ['metric', 'imperial'] -PLATFORM_SCHEMA = vol.Schema({ - vol.Required('platform'): 'google_travel_time', - vol.Optional(CONF_NAME): vol.Coerce(str), - vol.Required(CONF_API_KEY): vol.Coerce(str), - vol.Required(CONF_ORIGIN): vol.Coerce(str), - vol.Required(CONF_DESTINATION): vol.Coerce(str), - vol.Optional(CONF_TRAVEL_MODE): - vol.In(["driving", "walking", "bicycling", "transit"]), +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_DESTINATION): cv.string, + vol.Required(CONF_ORIGIN): cv.string, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_TRAVEL_MODE): vol.In(TRAVEL_MODE), vol.Optional(CONF_OPTIONS, default={CONF_MODE: 'driving'}): vol.All( dict, vol.Schema({ - vol.Optional(CONF_MODE, default='driving'): - vol.In(["driving", "walking", "bicycling", "transit"]), + vol.Optional(CONF_MODE, default='driving'): vol.In(TRAVEL_MODE), vol.Optional('language'): vol.In(ALL_LANGUAGES), - vol.Optional('avoid'): vol.In(['tolls', 'highways', - 'ferries', 'indoor']), - vol.Optional('units'): vol.In(['metric', 'imperial']), + vol.Optional('avoid'): vol.In(AVOID), + vol.Optional('units'): vol.In(UNITS), vol.Exclusive('arrival_time', 'time'): cv.string, vol.Exclusive('departure_time', 'time'): cv.string, - vol.Optional('traffic_model'): vol.In(['best_guess', - 'pessimistic', - 'optimistic']), - vol.Optional('transit_mode'): vol.In(['bus', 'subway', 'train', - 'tram', 'rail']), + vol.Optional('traffic_model'): vol.In(TRAVEL_MODEL), + vol.Optional('transit_mode'): vol.In(TRANSPORT_TYPE), vol.Optional('transit_routing_preference'): vol.In(TRANSIT_PREFS) })) }) -TRACKABLE_DOMAINS = ["device_tracker", "sensor", "zone"] +TRACKABLE_DOMAINS = ['device_tracker', 'sensor', 'zone'] def convert_time_to_utc(timestr): @@ -81,10 +81,10 @@ def convert_time_to_utc(timestr): def setup_platform(hass, config, add_devices_callback, discovery_info=None): - """Setup the travel time platform.""" + """Setup the Google travel time platform.""" # pylint: disable=too-many-locals def run_setup(event): - """Delay the setup until home assistant is fully initialized. + """Delay the setup until Home Assistant is fully initialized. This allows any entities to be created already """ @@ -104,7 +104,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): options[CONF_MODE] = travel_mode titled_mode = options.get(CONF_MODE).title() - formatted_name = "Google Travel Time - {}".format(titled_mode) + formatted_name = "{} - {}".format(DEFAULT_NAME, titled_mode) name = config.get(CONF_NAME, formatted_name) api_key = config.get(CONF_API_KEY) origin = config.get(CONF_ORIGIN) @@ -122,7 +122,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): # pylint: disable=too-many-instance-attributes class GoogleTravelTimeSensor(Entity): - """Representation of a tavel time sensor.""" + """Representation of a Google travel time sensor.""" # pylint: disable=too-many-arguments def __init__(self, hass, name, api_key, origin, destination, options): @@ -130,6 +130,7 @@ class GoogleTravelTimeSensor(Entity): self._hass = hass self._name = name self._options = options + self._unit_of_measurement = 'min' self._matrix = None self.valid_api_connection = True @@ -192,7 +193,7 @@ class GoogleTravelTimeSensor(Entity): @property def unit_of_measurement(self): """Return the unit this state is expressed in.""" - return "min" + return self._unit_of_measurement @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): diff --git a/homeassistant/components/sensor/gpsd.py b/homeassistant/components/sensor/gpsd.py index a466ff32f7d..a9f8245b738 100644 --- a/homeassistant/components/sensor/gpsd.py +++ b/homeassistant/components/sensor/gpsd.py @@ -8,39 +8,39 @@ import logging import voluptuous as vol +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_LATITUDE, ATTR_LONGITUDE, STATE_UNKNOWN, CONF_HOST, CONF_PORT, + CONF_NAME) from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv -from homeassistant.const import (ATTR_LATITUDE, ATTR_LONGITUDE, STATE_UNKNOWN, - CONF_HOST, CONF_PORT, CONF_PLATFORM, - CONF_NAME) REQUIREMENTS = ['gps3==0.33.2'] -DEFAULT_NAME = 'GPS' +_LOGGER = logging.getLogger(__name__) + +ATTR_CLIMB = 'climb' +ATTR_ELEVATION = 'elevation' +ATTR_GPS_TIME = 'gps_time' +ATTR_MODE = 'mode' +ATTR_SPEED = 'speed' + DEFAULT_HOST = 'localhost' +DEFAULT_NAME = 'GPS' DEFAULT_PORT = 2947 -ATTR_GPS_TIME = 'gps_time' -ATTR_ELEVATION = 'elevation' -ATTR_SPEED = 'speed' -ATTR_CLIMB = 'climb' -ATTR_MODE = 'mode' - -PLATFORM_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): 'gpsd', - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_HOST): cv.string, - vol.Optional(CONF_PORT): cv.string, +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, }) -_LOGGER = logging.getLogger(__name__) - def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the GPSD component.""" - name = config.get(CONF_NAME, DEFAULT_NAME) - host = config.get(CONF_HOST, DEFAULT_HOST) - port = config.get(CONF_PORT, DEFAULT_PORT) + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) # Will hopefully be possible with the next gps3 update # https://github.com/wadda/gps3/issues/11 diff --git a/homeassistant/components/sensor/homematic.py b/homeassistant/components/sensor/homematic.py index 66526408567..35cc4aea42b 100644 --- a/homeassistant/components/sensor/homematic.py +++ b/homeassistant/components/sensor/homematic.py @@ -31,7 +31,13 @@ HM_UNIT_HA_CAST = { "ENERGY_COUNTER": "Wh", "GAS_POWER": "m3", "GAS_ENERGY_COUNTER": "m3", - "LUX": "lux" + "LUX": "lux", + "RAIN_COUNTER": "mm", + "WIND_SPEED": "km/h", + "WIND_DIRECTION": "°", + "WIND_DIRECTION_RANGE": "°", + "SUNSHINEDURATION": "#", + "AIR_PRESSURE": "hPa", } diff --git a/homeassistant/components/sensor/hp_ilo.py b/homeassistant/components/sensor/hp_ilo.py new file mode 100644 index 00000000000..bc7afe1bf3c --- /dev/null +++ b/homeassistant/components/sensor/hp_ilo.py @@ -0,0 +1,171 @@ +""" +Support for information from HP ILO sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.sensor.hp_ilo/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.const import ( + CONF_HOST, CONF_PORT, CONF_USERNAME, CONF_PASSWORD, CONF_NAME, + CONF_MONITORED_VARIABLES, STATE_ON, STATE_OFF) +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['python-hpilo==3.8'] + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'HP ILO' +DEFAULT_PORT = 443 + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=300) + +# Each sensor is defined as follows: 'Descriptive name', 'python-ilo function' +SENSOR_TYPES = { + 'server_name': ['Server Name', 'get_server_name'], + 'server_fqdn': ['Server FQDN', 'get_server_fqdn'], + 'server_host_data': ['Server Host Data', 'get_host_data'], + 'server_oa_info': ['Server Onboard Administrator Info', 'get_oa_info'], + 'server_power_status': ['Server Power state', 'get_host_power_status'], + 'server_power_readings': ['Server Power readings', 'get_power_readings'], + 'server_power_on_time': ['Server Power On time', + 'get_server_power_on_time'], + 'server_asset_tag': ['Server Asset Tag', 'get_asset_tag'], + 'server_uid_status': ['Server UID light', 'get_uid_status'], + 'server_health': ['Server Health', 'get_embedded_health'], + 'network_settings': ['Network Settings', 'get_network_settings'] +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_MONITORED_VARIABLES, default=['server_name']): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, +}) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the HP ILO sensor.""" + hostname = config.get(CONF_HOST) + port = config.get(CONF_PORT) + login = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + monitored_variables = config.get(CONF_MONITORED_VARIABLES) + name = config.get(CONF_NAME) + + # Create a data fetcher to support all of the configured sensors. Then make + # the first call to init the data and confirm we can connect. + try: + hp_ilo_data = HpIloData(hostname, port, login, password) + except ValueError as error: + _LOGGER.error(error) + return False + + # Initialize and add all of the sensors. + devices = [] + for ilo_type in monitored_variables: + new_device = HpIloSensor(hp_ilo_data=hp_ilo_data, + sensor_type=SENSOR_TYPES.get(ilo_type), + client_name=name) + devices.append(new_device) + + add_devices(devices) + + +class HpIloSensor(Entity): + """Representation a HP ILO sensor.""" + + def __init__(self, hp_ilo_data, sensor_type, client_name): + """Initialize the sensor.""" + self._name = '{} {}'.format(client_name, sensor_type[0]) + self._ilo_function = sensor_type[1] + self.client_name = client_name + self.hp_ilo_data = hp_ilo_data + + self._state = None + self._data = None + + self.update() + + _LOGGER.debug("Created HP ILO sensor %r", self) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def state_attributes(self): + """Return the state attributes.""" + return self._data + + def update(self): + """Get the latest data from HP ILO and updates the states.""" + # Call the API for new data. Each sensor will re-trigger this + # same exact call, but that's fine. Results should be cached for + # a short period of time to prevent hitting API limits. + self.hp_ilo_data.update() + ilo_data = getattr(self.hp_ilo_data.data, self._ilo_function)() + + # Store the data received from the ILO API + if isinstance(ilo_data, dict): + self._data = ilo_data + else: + self._data = {'value': ilo_data} + + # If the data received is an integer or string, store it as + # the sensor state + if isinstance(ilo_data, (str, bytes)): + states = [STATE_ON, STATE_OFF] + try: + index_element = states.index(str(ilo_data).lower()) + self._state = states[index_element] + except ValueError: + self._state = ilo_data + elif isinstance(ilo_data, (int, float)): + self._state = ilo_data + + +# pylint: disable=too-few-public-methods +class HpIloData(object): + """Gets the latest data from HP ILO.""" + + def __init__(self, host, port, login, password): + """Initialize the data object.""" + self._host = host + self._port = port + self._login = login + self._password = password + + self.data = None + + self.update() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from HP ILO.""" + import hpilo + + try: + self.data = hpilo.Ilo(hostname=self._host, + login=self._login, + password=self._password, + port=self._port) + except (hpilo.IloError, hpilo.IloCommunicationError, + hpilo.IloLoginFailed) as error: + raise ValueError("Unable to init HP ILO, %s", error) diff --git a/homeassistant/components/sensor/imap.py b/homeassistant/components/sensor/imap.py index c458799215f..47a85cd582f 100644 --- a/homeassistant/components/sensor/imap.py +++ b/homeassistant/components/sensor/imap.py @@ -5,41 +5,38 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.imap/ """ import logging + import voluptuous as vol from homeassistant.helpers.entity import Entity +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_NAME, CONF_PORT, CONF_USERNAME, CONF_PASSWORD) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -ICON = 'mdi:email-outline' - -CONF_USER = "user" -CONF_PASSWORD = "password" CONF_SERVER = "server" -CONF_PORT = "port" -CONF_NAME = "name" DEFAULT_PORT = 993 +ICON = 'mdi:email-outline' -PLATFORM_SCHEMA = vol.Schema({ - vol.Required('platform'): 'imap', +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME): cv.string, - vol.Required(CONF_USER): cv.string, + vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_SERVER): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): - vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)), + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, }) def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the IMAP platform.""" sensor = ImapSensor(config.get(CONF_NAME, None), - config.get(CONF_USER), + config.get(CONF_USERNAME), config.get(CONF_PASSWORD), config.get(CONF_SERVER), - config.get(CONF_PORT, DEFAULT_PORT)) + config.get(CONF_PORT)) if sensor.connection: add_devices([sensor]) diff --git a/homeassistant/components/sensor/loopenergy.py b/homeassistant/components/sensor/loopenergy.py index 3394e69da8d..04a1d946d45 100644 --- a/homeassistant/components/sensor/loopenergy.py +++ b/homeassistant/components/sensor/loopenergy.py @@ -6,56 +6,67 @@ https://home-assistant.io/components/sensor.loop_energy/ """ import logging +import voluptuous as vol +import homeassistant.helpers.config_validation as cv + from homeassistant.helpers.entity import Entity +from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.util import convert _LOGGER = logging.getLogger(__name__) -DOMAIN = "loopenergy" - REQUIREMENTS = ['pyloopenergy==0.0.14'] +CONF_ELEC = 'electricity' +CONF_GAS = 'gas' + +CONF_ELEC_SERIAL = 'electricity_serial' +CONF_ELEC_SECRET = 'electricity_secret' + +CONF_GAS_SERIAL = 'gas_serial' +CONF_GAS_SECRET = 'gas_secret' +CONF_GAS_CALORIFIC = 'gas_calorific' + +CONF_GAS_TYPE = 'gas_type' + +ELEC_SCHEMA = vol.Schema({ + vol.Required(CONF_ELEC_SERIAL): cv.string, + vol.Required(CONF_ELEC_SECRET): cv.string, +}) + +GAS_TYPE_SCHEMA = vol.In(['imperial', 'metric']) + +GAS_SCHEMA = vol.Schema({ + vol.Required(CONF_GAS_SERIAL): cv.string, + vol.Required(CONF_GAS_SECRET): cv.string, + vol.Optional(CONF_GAS_TYPE, default='metric'): + GAS_TYPE_SCHEMA, + vol.Optional(CONF_GAS_CALORIFIC, default=39.11): vol.Coerce(float) +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ELEC): vol.All( + dict, ELEC_SCHEMA), + vol.Optional(CONF_GAS, default={}): vol.All( + dict, GAS_SCHEMA) +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Loop Energy sensors.""" import pyloopenergy - elec_serial = config.get('electricity_serial') - elec_secret = config.get('electricity_secret') - gas_serial = config.get('gas_serial') - gas_secret = config.get('gas_secret') - gas_type = config.get('gas_type', 'metric') - gas_calorific = convert(config.get('gas_calorific'), float, 39.11) - - if not (elec_serial and elec_secret): - _LOGGER.error( - "Configuration Error, " - "please make sure you have configured electricity " - "serial and secret tokens") - return None - - if (gas_serial or gas_secret) and not (gas_serial and gas_secret): - _LOGGER.error( - "Configuration Error, " - "please make sure you have configured gas " - "serial and secret tokens") - return None - - if gas_type not in ['imperial', 'metric']: - _LOGGER.error( - "Configuration Error, 'gas_type' " - "can only be 'imperial' or 'metric' ") - return None + elec_config = config.get(CONF_ELEC) + gas_config = config.get(CONF_GAS) # pylint: disable=too-many-function-args controller = pyloopenergy.LoopEnergy( - elec_serial, - elec_secret, - gas_serial, - gas_secret, - gas_type, - gas_calorific + elec_config.get(CONF_ELEC_SERIAL), + elec_config.get(CONF_ELEC_SECRET), + gas_config.get(CONF_GAS_SERIAL), + gas_config.get(CONF_GAS_SECRET), + gas_config.get(CONF_GAS_TYPE), + gas_config.get(CONF_GAS_CALORIFIC) ) def stop_loopenergy(event): @@ -67,7 +78,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): sensors = [LoopEnergyElec(controller)] - if gas_serial: + if gas_config.get(CONF_GAS_SERIAL): sensors.append(LoopEnergyGas(controller)) add_devices(sensors) diff --git a/homeassistant/components/sensor/mfi.py b/homeassistant/components/sensor/mfi.py index 0af020de5f2..90d07811304 100644 --- a/homeassistant/components/sensor/mfi.py +++ b/homeassistant/components/sensor/mfi.py @@ -9,7 +9,8 @@ import logging import requests from homeassistant.components.sensor import DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, TEMP_CELSIUS +from homeassistant.const import ( + CONF_PASSWORD, CONF_USERNAME, TEMP_CELSIUS, STATE_ON, STATE_OFF, CONF_HOST) from homeassistant.helpers import validate_config from homeassistant.helpers.entity import Entity @@ -17,8 +18,6 @@ REQUIREMENTS = ['mficlient==0.3.0'] _LOGGER = logging.getLogger(__name__) -STATE_ON = 'on' -STATE_OFF = 'off' DIGITS = { 'volts': 1, 'amps': 1, @@ -40,14 +39,14 @@ CONF_VERIFY_TLS = 'verify_tls' def setup_platform(hass, config, add_devices, discovery_info=None): """Setup mFi sensors.""" if not validate_config({DOMAIN: config}, - {DOMAIN: ['host', + {DOMAIN: [CONF_HOST, CONF_USERNAME, CONF_PASSWORD]}, _LOGGER): _LOGGER.error('A host, username, and password are required') return False - host = config.get('host') + host = config.get(CONF_HOST) username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) use_tls = bool(config.get(CONF_TLS, True)) diff --git a/homeassistant/components/sensor/mhz19.py b/homeassistant/components/sensor/mhz19.py new file mode 100644 index 00000000000..c811a193335 --- /dev/null +++ b/homeassistant/components/sensor/mhz19.py @@ -0,0 +1,85 @@ +""" +Support for CO2 sensor connected to a serial port. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.mhz19/ +""" +import logging +import voluptuous as vol + +from homeassistant.const import CONF_NAME +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA + +REQUIREMENTS = ['pmsensor==0.3'] + + +_LOGGER = logging.getLogger(__name__) + +CONF_SERIAL_DEVICE = "serial_device" +DEFAULT_NAME = 'CO2 Sensor' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_SERIAL_DEVICE): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the available CO2 sensors.""" + from pmsensor import co2sensor + + try: + co2sensor.read_mh_z19(config.get(CONF_SERIAL_DEVICE)) + except OSError as err: + _LOGGER.error("Could not open serial connection to %s (%s)", + config.get(CONF_SERIAL_DEVICE), err) + return False + + dev = MHZ19Sensor(config.get(CONF_SERIAL_DEVICE), config.get(CONF_NAME)) + add_devices([dev]) + + +class MHZ19Sensor(Entity): + """Representation of an CO2 sensor.""" + + def __init__(self, serial_device, name): + """Initialize a new PM sensor.""" + self._name = name + self._state = None + self._serial = serial_device + + @property + def name(self): + """Return the name of the sensor.""" + 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 of measurement of this entity, if any.""" + return "ppm" + + def update(self): + """Read from sensor and update the state.""" + from pmsensor import co2sensor + + _LOGGER.debug("Reading data from CO2 sensor") + try: + ppm = co2sensor.read_mh_z19(self._serial) + # values from sensor can only between 0 and 5000 + if (ppm >= 0) & (ppm <= 5000): + self._state = ppm + except OSError as err: + _LOGGER.error("Could not open serial connection to %s (%s)", + self._serial, err) + return + + def should_poll(self): + """Sensor needs polling.""" + return True diff --git a/homeassistant/components/sensor/modbus.py b/homeassistant/components/sensor/modbus.py index db024651b86..d6c85993162 100644 --- a/homeassistant/components/sensor/modbus.py +++ b/homeassistant/components/sensor/modbus.py @@ -26,11 +26,15 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if registers: for regnum, register in registers.items(): if register.get("name"): - sensors.append(ModbusSensor(register.get("name"), - slave, - regnum, - None, - register.get("unit"))) + sensors.append( + ModbusSensor(register.get("name"), + slave, + regnum, + None, + register.get("unit"), + scale=register.get("scale", 1), + offset=register.get("offset", 0), + precision=register.get("precision", 0))) if register.get("bits"): bits = register.get("bits") for bitnum, bit in bits.items(): @@ -53,8 +57,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class ModbusSensor(Entity): """Representation of a Modbus Sensor.""" - # pylint: disable=too-many-arguments - def __init__(self, name, slave, register, bit=None, unit=None, coil=False): + # pylint: disable=too-many-arguments, too-many-instance-attributes + def __init__(self, name, slave, register, bit=None, unit=None, coil=False, + scale=1, offset=0, precision=0): """Initialize the sensor.""" self._name = name self.slave = int(slave) if slave else 1 @@ -63,6 +68,9 @@ class ModbusSensor(Entity): self._value = None self._unit = unit self._coil = coil + self._scale = scale + self._offset = offset + self._precision = precision def __str__(self): """Return the name and the state of the sensor.""" @@ -118,4 +126,6 @@ class ModbusSensor(Entity): if self.bit: self._value = val & (0x0001 << self.bit) else: - self._value = val + self._value = format( + self._scale * val + self._offset, + ".{}f".format(self._precision)) diff --git a/homeassistant/components/sensor/mqtt.py b/homeassistant/components/sensor/mqtt.py index eaa856b010e..c3d4910b527 100644 --- a/homeassistant/components/sensor/mqtt.py +++ b/homeassistant/components/sensor/mqtt.py @@ -9,7 +9,8 @@ import logging import voluptuous as vol import homeassistant.components.mqtt as mqtt -from homeassistant.const import CONF_NAME, CONF_VALUE_TEMPLATE, STATE_UNKNOWN +from homeassistant.const import ( + CONF_NAME, CONF_VALUE_TEMPLATE, STATE_UNKNOWN, CONF_UNIT_OF_MEASUREMENT) from homeassistant.components.mqtt import CONF_STATE_TOPIC, CONF_QOS import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -19,8 +20,6 @@ _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['mqtt'] -CONF_UNIT_OF_MEASUREMENT = 'unit_of_measurement' - DEFAULT_NAME = "MQTT Sensor" PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend({ @@ -30,9 +29,9 @@ PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend({ # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices_callback, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Setup MQTT Sensor.""" - add_devices_callback([MqttSensor( + add_devices([MqttSensor( hass, config[CONF_NAME], config[CONF_STATE_TOPIC], diff --git a/homeassistant/components/sensor/mqtt_room.py b/homeassistant/components/sensor/mqtt_room.py new file mode 100644 index 00000000000..6980b7e6f7b --- /dev/null +++ b/homeassistant/components/sensor/mqtt_room.py @@ -0,0 +1,139 @@ +""" +Support for MQTT room presence detection. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.mqtt_room/ +""" +import logging +import json + +import voluptuous as vol + +import homeassistant.components.mqtt as mqtt +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME, STATE_UNKNOWN +from homeassistant.components.mqtt import CONF_STATE_TOPIC +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import dt, slugify + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['mqtt'] + +CONF_DEVICE_ID = 'device_id' +CONF_TIMEOUT = 'timeout' + +DEFAULT_TOPIC = 'room_presence' +DEFAULT_TIMEOUT = 5 +DEFAULT_NAME = 'Room Sensor' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_DEVICE_ID): cv.string, + vol.Required(CONF_STATE_TOPIC, default=DEFAULT_TOPIC): cv.string, + vol.Required(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string +}) + +MQTT_PAYLOAD = vol.Schema(vol.All(json.loads, vol.Schema({ + vol.Required('id'): cv.string, + vol.Required('distance'): vol.Coerce(float) +}, extra=vol.ALLOW_EXTRA))) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """Setup MQTT Sensor.""" + add_devices_callback([MQTTRoomSensor( + hass, + config.get(CONF_NAME), + config.get(CONF_STATE_TOPIC), + config.get(CONF_DEVICE_ID), + config.get(CONF_TIMEOUT) + )]) + + +# pylint: disable=too-many-arguments, too-many-instance-attributes +class MQTTRoomSensor(Entity): + """Representation of a room sensor that is updated via MQTT.""" + + def __init__(self, hass, name, state_topic, device_id, timeout): + """Initialize the sensor.""" + self._state = STATE_UNKNOWN + self._hass = hass + self._name = name + self._state_topic = state_topic + '/+' + self._device_id = slugify(device_id).upper() + self._timeout = timeout + self._distance = None + self._updated = None + + def update_state(device_id, room, distance): + """Update the sensor state.""" + self._state = room + self._distance = distance + self._updated = dt.utcnow() + + self.update_ha_state() + + def message_received(topic, payload, qos): + """A new MQTT message has been received.""" + try: + data = MQTT_PAYLOAD(payload) + except vol.MultipleInvalid as error: + _LOGGER.debug('skipping update because of malformatted ' + 'data: %s', error) + return + + device = _parse_update_data(topic, data) + if device.get('device_id') == self._device_id: + if self._distance is None or self._updated is None: + update_state(**device) + else: + # update if: + # device is in the same room OR + # device is closer to another room OR + # last update from other room was too long ago + timediff = dt.utcnow() - self._updated + if device.get('room') == self._state \ + or device.get('distance') < self._distance \ + or timediff.seconds >= self._timeout: + update_state(**device) + + mqtt.subscribe(hass, self._state_topic, message_received, 1) + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + 'distance': self._distance + } + + @property + def state(self): + """Return the current room of the entity.""" + return self._state + + +def _parse_update_data(topic, data): + """Parse the room presence update.""" + parts = topic.split('/') + room = parts[-1] + device_id = slugify(data.get('id')).upper() + distance = data.get('distance') + parsed_data = { + 'device_id': device_id, + 'room': room, + 'distance': distance + } + return parsed_data diff --git a/homeassistant/components/sensor/mysensors.py b/homeassistant/components/sensor/mysensors.py index b58a375755c..eff67a1f9e2 100644 --- a/homeassistant/components/sensor/mysensors.py +++ b/homeassistant/components/sensor/mysensors.py @@ -47,12 +47,12 @@ def setup_platform(hass, config, add_devices, discovery_info=None): pres.S_SCENE_CONTROLLER: [set_req.V_SCENE_ON, set_req.V_SCENE_OFF], } - if float(gateway.version) < 1.5: + if float(gateway.protocol_version) < 1.5: map_sv_types.update({ pres.S_AIR_QUALITY: [set_req.V_DUST_LEVEL], pres.S_DUST: [set_req.V_DUST_LEVEL], }) - if float(gateway.version) >= 1.5: + if float(gateway.protocol_version) >= 1.5: map_sv_types.update({ pres.S_COLOR_SENSOR: [set_req.V_RGB], pres.S_MULTIMETER: [set_req.V_VOLTAGE, @@ -99,7 +99,7 @@ class MySensorsSensor(mysensors.MySensorsDeviceEntity, Entity): set_req.V_VOLTAGE: 'V', set_req.V_CURRENT: 'A', } - if float(self.gateway.version) >= 1.5: + if float(self.gateway.protocol_version) >= 1.5: if set_req.V_UNIT_PREFIX in self._values: return self._values[ set_req.V_UNIT_PREFIX] diff --git a/homeassistant/components/sensor/nzbget.py b/homeassistant/components/sensor/nzbget.py index 874005cebca..f7a13645c59 100644 --- a/homeassistant/components/sensor/nzbget.py +++ b/homeassistant/components/sensor/nzbget.py @@ -1,48 +1,64 @@ """ -Support for monitoring NZBGet nzb client. +Support for monitoring NZBGet NZB client. -Uses NZBGet's JSON-RPC API to query for monitored variables. +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.nzbget/ """ import logging from datetime import timedelta -import requests +import requests +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_NAME, CONF_PORT, + CONTENT_TYPE_JSON, CONF_MONITORED_VARIABLES) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle - -REQUIREMENTS = [] -SENSOR_TYPES = { - "ArticleCacheMB": ("Article Cache", "MB"), - "AverageDownloadRate": ("Average Speed", "MB/s"), - "DownloadRate": ("Speed", "MB/s"), - "DownloadPaused": ("Download Paused", None), - "FreeDiskSpaceMB": ("Disk Free", "MB"), - "PostPaused": ("Post Processing Paused", None), - "RemainingSizeMB": ("Queue Size", "MB"), -} -DEFAULT_TYPES = [ - "DownloadRate", - "DownloadPaused", - "RemainingSizeMB", -] +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) +DEFAULT_NAME = 'NZBGet' +DEFAULT_PORT = 6789 -# pylint: disable=unused-argument +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5) + +SENSOR_TYPES = { + 'article_cache': ['ArticleCacheMB', 'Article Cache', 'MB'], + 'average_download_rate': ['AverageDownloadRate', 'Average Speed', 'MB/s'], + 'download_paused': ['DownloadPaused', 'Download Paused', None], + 'download_rate': ['DownloadRate', 'Speed', 'MB/s'], + 'download_size': ['DownloadedSizeMB', 'Size', 'MB'], + 'free_disk_space': ['FreeDiskSpaceMB', 'Disk Free', 'MB'], + 'post_paused': ['PostPaused', 'Post Processing Paused', None], + 'remaining_size': ['RemainingSizeMB', 'Queue Size', 'MB'], + 'uptime': ['UpTimeSec', 'Uptime', 'min'], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_MONITORED_VARIABLES, default=['download_rate']): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_USERNAME): cv.string, +}) + + +# pylint: disable=unused-argument, too-many-locals def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up nzbget sensors.""" - base_url = config.get("base_url") - name = config.get("name", "NZBGet") - username = config.get("username") - password = config.get("password") - monitored_types = config.get("monitored_variables", DEFAULT_TYPES) + """Setup the NZBGet sensors.""" + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + name = config.get(CONF_NAME) + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + monitored_types = config.get(CONF_MONITORED_VARIABLES) - if not base_url: - _LOGGER.error("Missing base_url config for NzbGet") - return False - - url = "{}/jsonrpc".format(base_url) + url = "http://{}:{}/jsonrpc".format(host, port) try: nzbgetapi = NZBGetAPI(api_url=url, @@ -51,78 +67,32 @@ def setup_platform(hass, config, add_devices, discovery_info=None): nzbgetapi.update() except (requests.exceptions.ConnectionError, requests.exceptions.HTTPError) as conn_err: - _LOGGER.error("Error setting up NZBGet API: %r", conn_err) + _LOGGER.error("Error setting up NZBGet API: %s", conn_err) return False devices = [] for ng_type in monitored_types: - if ng_type in SENSOR_TYPES: - new_sensor = NZBGetSensor(api=nzbgetapi, - sensor_type=ng_type, - client_name=name) - devices.append(new_sensor) - else: - _LOGGER.error("Unknown nzbget sensor type: %s", ng_type) + new_sensor = NZBGetSensor(api=nzbgetapi, + sensor_type=SENSOR_TYPES.get(ng_type), + client_name=name) + devices.append(new_sensor) + add_devices(devices) -class NZBGetAPI(object): - """Simple json-rpc wrapper for nzbget's api.""" - - def __init__(self, api_url, username=None, password=None): - """Initialize NZBGet API and set headers needed later.""" - self.api_url = api_url - self.status = None - self.headers = {'content-type': 'application/json'} - if username is not None and password is not None: - self.auth = (username, password) - else: - self.auth = None - # set the intial state - self.update() - - def post(self, method, params=None): - """Send a post request, and return the response as a dict.""" - payload = {"method": method} - if params: - payload['params'] = params - try: - response = requests.post(self.api_url, - json=payload, - auth=self.auth, - headers=self.headers, - timeout=30) - response.raise_for_status() - return response.json() - except requests.exceptions.ConnectionError as conn_exc: - _LOGGER.error("Failed to update nzbget status from %s. Error: %s", - self.api_url, conn_exc) - raise - - @Throttle(timedelta(seconds=5)) - def update(self): - """Update cached response.""" - try: - self.status = self.post('status')['result'] - except requests.exceptions.ConnectionError: - # failed to update status - exception already logged in self.post - raise - - class NZBGetSensor(Entity): - """Represents an NZBGet sensor.""" + """Representation of a NZBGet sensor.""" def __init__(self, api, sensor_type, client_name): """Initialize a new NZBGet sensor.""" - self._name = client_name + ' ' + SENSOR_TYPES[sensor_type][0] - self.type = sensor_type + self._name = '{} {}'.format(client_name, sensor_type[1]) + self.type = sensor_type[0] self.client_name = client_name self.api = api self._state = None - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - # Set initial state + self._unit_of_measurement = sensor_type[2] self.update() - _LOGGER.debug("created nzbget sensor %r", self) + _LOGGER.debug("Created NZBGet sensor: %s", self.type) @property def name(self): @@ -144,21 +114,68 @@ class NZBGetSensor(Entity): try: self.api.update() except requests.exceptions.ConnectionError: - # Error calling the api, already logged in api.update() + # Error calling the API, already logged in api.update() return if self.api.status is None: - _LOGGER.debug("update of %s requested, but no status is available", + _LOGGER.debug("Update of %s requested, but no status is available", self._name) return value = self.api.status.get(self.type) if value is None: - _LOGGER.warning("unable to locate value for %s", self.type) + _LOGGER.warning("Unable to locate value for %s", self.type) return if "DownloadRate" in self.type and value > 0: # Convert download rate from Bytes/s to MBytes/s - self._state = round(value / 1024 / 1024, 2) + self._state = round(value / 2**20, 2) + elif "UpTimeSec" in self.type and value > 0: + # Convert uptime from seconds to minutes + self._state = round(value / 60, 2) else: self._state = value + + +class NZBGetAPI(object): + """Simple JSON-RPC wrapper for NZBGet's API.""" + + def __init__(self, api_url, username=None, password=None): + """Initialize NZBGet API and set headers needed later.""" + self.api_url = api_url + self.status = None + self.headers = {'content-type': CONTENT_TYPE_JSON} + + if username is not None and password is not None: + self.auth = (username, password) + else: + self.auth = None + self.update() + + def post(self, method, params=None): + """Send a POST request and return the response as a dict.""" + payload = {"method": method} + + if params: + payload['params'] = params + try: + response = requests.post(self.api_url, + json=payload, + auth=self.auth, + headers=self.headers, + timeout=5) + response.raise_for_status() + return response.json() + except requests.exceptions.ConnectionError as conn_exc: + _LOGGER.error("Failed to update NZBGet status from %s. Error: %s", + self.api_url, conn_exc) + raise + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Update cached response.""" + try: + self.status = self.post('status')['result'] + except requests.exceptions.ConnectionError: + # failed to update status - exception already logged in self.post + raise diff --git a/homeassistant/components/sensor/ohmconnect.py b/homeassistant/components/sensor/ohmconnect.py index fcd50d8edc5..929fa607a4e 100644 --- a/homeassistant/components/sensor/ohmconnect.py +++ b/homeassistant/components/sensor/ohmconnect.py @@ -7,27 +7,37 @@ https://home-assistant.io/components/sensor.ohmconnect/ import logging from datetime import timedelta import xml.etree.ElementTree as ET -import requests +import requests +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME +import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -# Return cached results if last scan was less then this time ago. +CONF_ID = 'id' + +DEFAULT_NAME = 'OhmConnect Status' + MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ID): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup the OhmConnect sensors.""" - ohmid = config.get("id") - if ohmid is None: - _LOGGER.error("You must provide your OhmConnect ID!") - return False + """Setup the OhmConnect sensor.""" + name = config.get(CONF_NAME) + ohmid = config.get(CONF_ID) - add_devices([OhmconnectSensor(config.get("name", "OhmConnect Status"), - ohmid)]) + add_devices([OhmconnectSensor(name, ohmid)]) class OhmconnectSensor(Entity): diff --git a/homeassistant/components/sensor/openexchangerates.py b/homeassistant/components/sensor/openexchangerates.py index 920dfc46a90..b4e7033bcc0 100644 --- a/homeassistant/components/sensor/openexchangerates.py +++ b/homeassistant/components/sensor/openexchangerates.py @@ -6,47 +6,56 @@ https://home-assistant.io/components/sensor.openexchangerates/ """ from datetime import timedelta import logging + import requests +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (CONF_API_KEY, CONF_NAME, CONF_PAYLOAD) +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -from homeassistant.const import CONF_API_KEY -_RESOURCE = 'https://openexchangerates.org/api/latest.json' _LOGGER = logging.getLogger(__name__) -# Return cached results if last scan was less then this time ago. -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=100) +_RESOURCE = 'https://openexchangerates.org/api/latest.json' + CONF_BASE = 'base' CONF_QUOTE = 'quote' -CONF_NAME = 'name' + +DEFAULT_BASE = 'USD' DEFAULT_NAME = 'Exchange Rate Sensor' +MIN_TIME_BETWEEN_UPDATES = timedelta(hours=2) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_QUOTE): cv.string, + vol.Optional(CONF_BASE, default=DEFAULT_BASE): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup the Openexchangerates sensor.""" - payload = config.get('payload', None) - rest = OpenexchangeratesData( - _RESOURCE, - config.get(CONF_API_KEY), - config.get(CONF_BASE, 'USD'), - config.get(CONF_QUOTE), - payload - ) - response = requests.get(_RESOURCE, params={'base': config.get(CONF_BASE, - 'USD'), - 'app_id': - config.get(CONF_API_KEY)}, + """Setup the Open Exchange Rates sensor.""" + name = config.get(CONF_NAME) + api_key = config.get(CONF_API_KEY) + base = config.get(CONF_BASE) + quote = config.get(CONF_QUOTE) + payload = config.get(CONF_PAYLOAD) + + rest = OpenexchangeratesData(_RESOURCE, api_key, base, quote, payload) + response = requests.get(_RESOURCE, params={'base': base, + 'app_id': api_key}, timeout=10) if response.status_code != 200: - _LOGGER.error("Check your OpenExchangeRates API") + _LOGGER.error("Check your OpenExchangeRates API key") return False rest.update() - add_devices([OpenexchangeratesSensor(rest, config.get(CONF_NAME, - DEFAULT_NAME), - config.get(CONF_QUOTE))]) + add_devices([OpenexchangeratesSensor(rest, name, quote)]) class OpenexchangeratesSensor(Entity): - """Representation of an Openexchangerates sensor.""" + """Representation of an Open Exchange Rates sensor.""" def __init__(self, rest, name, quote): """Initialize the sensor.""" @@ -100,6 +109,6 @@ class OpenexchangeratesData(object): timeout=10) self.data = result.json()['rates'] except requests.exceptions.HTTPError: - _LOGGER.error("Check Openexchangerates API Key") + _LOGGER.error("Check the Openexchangerates API Key") self.data = None return False diff --git a/homeassistant/components/sensor/plex.py b/homeassistant/components/sensor/plex.py index 89ffd020bdd..7ead3175371 100644 --- a/homeassistant/components/sensor/plex.py +++ b/homeassistant/components/sensor/plex.py @@ -8,53 +8,58 @@ from datetime import timedelta import logging import voluptuous as vol -from homeassistant.const import (CONF_NAME, CONF_PLATFORM, CONF_USERNAME, - CONF_PASSWORD, CONF_HOST, CONF_PORT) +from homeassistant.components.switch import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_HOST, CONF_PORT) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['plexapi==2.0.2'] -CONF_SERVER = 'server' -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) - _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): 'plex', - vol.Optional(CONF_USERNAME): cv.string, +CONF_SERVER = 'server' + +DEFAULT_HOST = 'localhost' +DEFAULT_NAME = 'Plex' + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PORT, default=32400): cv.port, vol.Optional(CONF_SERVER): cv.string, - vol.Optional(CONF_NAME, default='Plex'): cv.string, - vol.Optional(CONF_HOST, default='localhost'): cv.string, - vol.Optional(CONF_PORT, default=32400): vol.All(vol.Coerce(int), - vol.Range(min=1, - max=65535)) + vol.Optional(CONF_USERNAME): cv.string, }) # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup the Demo sensors.""" + """Setup the Plex sensor.""" name = config.get(CONF_NAME) plex_user = config.get(CONF_USERNAME) plex_password = config.get(CONF_PASSWORD) plex_server = config.get(CONF_SERVER) plex_host = config.get(CONF_HOST) plex_port = config.get(CONF_PORT) - plex_url = 'http://' + plex_host + ':' + str(plex_port) - add_devices([PlexSensor(name, plex_url, plex_user, - plex_password, plex_server)]) + plex_url = 'http://{}:{}'.format(plex_host, plex_port) + + add_devices([PlexSensor( + name, plex_url, plex_user, plex_password, plex_server)]) class PlexSensor(Entity): - """Plex now playing sensor.""" + """Representation of a Plex now playing sensor.""" # pylint: disable=too-many-arguments def __init__(self, name, plex_url, plex_user, plex_password, plex_server): """Initialize the sensor.""" from plexapi.utils import NA + from plexapi.myplex import MyPlexAccount + from plexapi.server import PlexServer self._na_type = NA self._name = name @@ -62,12 +67,10 @@ class PlexSensor(Entity): self._now_playing = [] if plex_user and plex_password: - from plexapi.myplex import MyPlexAccount user = MyPlexAccount.signin(plex_user, plex_password) server = plex_server if plex_server else user.resources()[0].name self._server = user.resource(server).connect() else: - from plexapi.server import PlexServer self._server = PlexServer(plex_url) self.update() @@ -94,11 +97,11 @@ class PlexSensor(Entity): @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): - """Update method for plex sensor.""" + """Update method for Plex sensor.""" sessions = self._server.sessions() now_playing = [] for sess in sessions: - user = sess.user.title if sess.user is not self._na_type else "" + user = sess.username if sess.username is not self._na_type else "" title = sess.title if sess.title is not self._na_type else "" year = sess.year if sess.year is not self._na_type else "" now_playing.append((user, "{0} ({1})".format(title, year))) diff --git a/homeassistant/components/sensor/rest.py b/homeassistant/components/sensor/rest.py index 3f594c07234..022477d77a9 100644 --- a/homeassistant/components/sensor/rest.py +++ b/homeassistant/components/sensor/rest.py @@ -1,41 +1,56 @@ """ -Support for REST API sensors.. +Support for REST API sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.rest/ """ import logging +import voluptuous as vol import requests -from homeassistant.const import CONF_VALUE_TEMPLATE, STATE_UNKNOWN +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_PAYLOAD, CONF_NAME, CONF_VALUE_TEMPLATE, CONF_METHOD, CONF_RESOURCE, + CONF_UNIT_OF_MEASUREMENT, STATE_UNKNOWN) from homeassistant.helpers.entity import Entity from homeassistant.helpers import template +import homeassistant.helpers.config_validation as cv + +DEFAULT_METHOD = 'GET' +DEFAULT_NAME = 'REST Sensor' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_RESOURCE): cv.url, + vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): vol.In(['POST', 'GET']), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PAYLOAD): cv.string, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, +}) _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = 'REST Sensor' -DEFAULT_METHOD = 'GET' - # pylint: disable=unused-variable def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup the REST sensor.""" - resource = config.get('resource', None) - method = config.get('method', DEFAULT_METHOD) - payload = config.get('payload', None) + """Setup the RESTful sensor.""" + name = config.get(CONF_NAME) + resource = config.get(CONF_RESOURCE) + method = config.get(CONF_METHOD) + payload = config.get(CONF_PAYLOAD) verify_ssl = config.get('verify_ssl', True) + unit = config.get(CONF_UNIT_OF_MEASUREMENT) + value_template = config.get(CONF_VALUE_TEMPLATE) rest = RestData(method, resource, payload, verify_ssl) rest.update() if rest.data is None: - _LOGGER.error('Unable to fetch Rest data') + _LOGGER.error('Unable to fetch REST data') return False - add_devices([RestSensor( - hass, rest, config.get('name', DEFAULT_NAME), - config.get('unit_of_measurement'), config.get(CONF_VALUE_TEMPLATE))]) + add_devices([RestSensor(hass, rest, name, unit, value_template)]) # pylint: disable=too-many-arguments diff --git a/homeassistant/components/sensor/rfxtrx.py b/homeassistant/components/sensor/rfxtrx.py index 7560adbc93a..f9f7270c8e3 100644 --- a/homeassistant/components/sensor/rfxtrx.py +++ b/homeassistant/components/sensor/rfxtrx.py @@ -41,7 +41,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): sub_sensors = {} data_types = entity_info[ATTR_DATA_TYPE] if len(data_types) == 0: - data_type = "Unknown" + data_types = ["Unknown"] for data_type in DATA_TYPES: if data_type in event.values: data_types = [data_type] @@ -84,7 +84,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): pkt_id = "".join("{0:02x}".format(x) for x in event.data) _LOGGER.info("Automatic add rfxtrx.sensor: %s", - device_id) + pkt_id) data_type = "Unknown" for _data_type in DATA_TYPES: @@ -109,10 +109,8 @@ class RfxtrxSensor(Entity): self.event = event self._name = name self.should_fire_event = should_fire_event - if data_type not in DATA_TYPES: - data_type = "Unknown" self.data_type = data_type - self._unit_of_measurement = DATA_TYPES[data_type] + self._unit_of_measurement = DATA_TYPES.get(data_type, '') def __str__(self): """Return the name of the sensor.""" @@ -121,7 +119,7 @@ class RfxtrxSensor(Entity): @property def state(self): """Return the state of the sensor.""" - if self.event: + if self.event and self.data_type in self.event.values: return self.event.values[self.data_type] return None diff --git a/homeassistant/components/sensor/sabnzbd.py b/homeassistant/components/sensor/sabnzbd.py index 65a217930dc..a11d65d22bf 100644 --- a/homeassistant/components/sensor/sabnzbd.py +++ b/homeassistant/components/sensor/sabnzbd.py @@ -7,13 +7,27 @@ https://home-assistant.io/components/sensor.sabnzbd/ import logging from datetime import timedelta +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_HOST, CONF_API_KEY, CONF_NAME, CONF_PORT, CONF_MONITORED_VARIABLES) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle +import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['https://github.com/jamespcole/home-assistant-nzb-clients/' 'archive/616cad59154092599278661af17e2a9f2cf5e2a9.zip' '#python-sabnzbd==0.1'] +_LOGGER = logging.getLogger(__name__) +_THROTTLED_REFRESH = None + +DEFAULT_NAME = 'SABnzbd' +DEFAULT_PORT = 8080 + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1) + SENSOR_TYPES = { 'current_status': ['Status', None], 'speed': ['Speed', 'MB/s'], @@ -23,8 +37,14 @@ SENSOR_TYPES = { 'disk_free': ['Disk Free', 'GB'], } -_LOGGER = logging.getLogger(__name__) -_THROTTLED_REFRESH = None +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_MONITORED_VARIABLES, default=['current_status']): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, +}) # pylint: disable=unused-argument @@ -32,36 +52,31 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the SABnzbd sensors.""" from pysabnzbd import SabnzbdApi, SabnzbdApiException - api_key = config.get("api_key") - base_url = config.get("base_url") - name = config.get("name", "SABnzbd") - if not base_url: - _LOGGER.error('Missing config variable base_url') - return False - if not api_key: - _LOGGER.error('Missing config variable api_key') - return False + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + name = config.get(CONF_NAME) + api_key = config.get(CONF_API_KEY) + monitored_types = config.get(CONF_MONITORED_VARIABLES) + base_url = "http://{}:{}/".format(host, port) sab_api = SabnzbdApi(base_url, api_key) try: sab_api.check_available() except SabnzbdApiException: - _LOGGER.exception("Connection to SABnzbd API failed.") + _LOGGER.exception("Connection to SABnzbd API failed") return False # pylint: disable=global-statement global _THROTTLED_REFRESH - _THROTTLED_REFRESH = Throttle(timedelta(seconds=1))(sab_api.refresh_queue) + _THROTTLED_REFRESH = Throttle( + MIN_TIME_BETWEEN_UPDATES)(sab_api.refresh_queue) - dev = [] - for variable in config['monitored_variables']: - if variable['type'] not in SENSOR_TYPES: - _LOGGER.error('Sensor type: "%s" does not exist', variable['type']) - else: - dev.append(SabnzbdSensor(variable['type'], sab_api, name)) + devices = [] + for variable in monitored_types: + devices.append(SabnzbdSensor(variable, sab_api, name)) - add_devices(dev) + add_devices(devices) class SabnzbdSensor(Entity): @@ -79,7 +94,7 @@ class SabnzbdSensor(Entity): @property def name(self): """Return the name of the sensor.""" - return self.client_name + ' ' + self._name + return '{} {}'.format(self.client_name, self._name) @property def state(self): @@ -91,6 +106,7 @@ class SabnzbdSensor(Entity): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement + # pylint: disable=no-self-use def refresh_sabnzbd_data(self): """Call the throttled SABnzbd refresh method.""" if _THROTTLED_REFRESH is not None: @@ -98,13 +114,12 @@ class SabnzbdSensor(Entity): try: _THROTTLED_REFRESH() except SabnzbdApiException: - _LOGGER.exception( - self.name + " Connection to SABnzbd API failed." - ) + _LOGGER.exception("Connection to SABnzbd API failed") def update(self): """Get the latest data and updates the states.""" self.refresh_sabnzbd_data() + if self.sabnzb_client.queue: if self.type == 'current_status': self._state = self.sabnzb_client.queue.get('status') diff --git a/homeassistant/components/sensor/serial_pm.py b/homeassistant/components/sensor/serial_pm.py index a86fea74cea..b5e200eaa24 100644 --- a/homeassistant/components/sensor/serial_pm.py +++ b/homeassistant/components/sensor/serial_pm.py @@ -12,7 +12,7 @@ from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA -REQUIREMENTS = ['pmsensor==0.2'] +REQUIREMENTS = ['pmsensor==0.3'] _LOGGER = logging.getLogger(__name__) @@ -29,7 +29,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the available PM sensors.""" - from pmsensor import serial_data_collector as pm + from pmsensor import serial_pm as pm try: coll = pm.PMDataCollector(config.get(CONF_SERIAL_DEVICE), diff --git a/homeassistant/components/sensor/snmp.py b/homeassistant/components/sensor/snmp.py index 59730624a11..f9f059c8f55 100644 --- a/homeassistant/components/sensor/snmp.py +++ b/homeassistant/components/sensor/snmp.py @@ -6,36 +6,39 @@ https://home-assistant.io/components/sensor.snmp/ """ import logging from datetime import timedelta + import voluptuous as vol +from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.helpers.entity import Entity -from homeassistant.const import (CONF_HOST, CONF_PLATFORM, CONF_NAME, - CONF_PORT, ATTR_UNIT_OF_MEASUREMENT) +from homeassistant.const import ( + CONF_HOST, CONF_NAME, CONF_PORT, CONF_UNIT_OF_MEASUREMENT) +import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle REQUIREMENTS = ['pysnmp==4.3.2'] _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = "SNMP" -DEFAULT_COMMUNITY = "public" -DEFAULT_PORT = "161" -CONF_COMMUNITY = "community" -CONF_BASEOID = "baseoid" +CONF_BASEOID = 'baseoid' +CONF_COMMUNITY = 'community' -PLATFORM_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): 'snmp', - vol.Optional(CONF_NAME): vol.Coerce(str), - vol.Required(CONF_HOST): vol.Coerce(str), - vol.Optional(CONF_PORT): vol.Coerce(int), - vol.Optional(CONF_COMMUNITY): vol.Coerce(str), - vol.Required(CONF_BASEOID): vol.Coerce(str), - vol.Optional(ATTR_UNIT_OF_MEASUREMENT): vol.Coerce(str), -}) +DEFAULT_COMMUNITY = 'public' +DEFAULT_HOST = 'localhost' +DEFAULT_NAME = 'SNMP' +DEFAULT_PORT = '161' -# Return cached results if last scan was less then this time ago. MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_BASEOID): cv.string, + vol.Optional(CONF_COMMUNITY, default=DEFAULT_COMMUNITY): cv.string, + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, +}) + # pylint: disable=too-many-locals def setup_platform(hass, config, add_devices, discovery_info=None): @@ -44,10 +47,12 @@ def setup_platform(hass, config, add_devices, discovery_info=None): UdpTransportTarget, ContextData, ObjectType, ObjectIdentity) + name = config.get(CONF_NAME) host = config.get(CONF_HOST) - port = config.get(CONF_PORT, DEFAULT_PORT) - community = config.get(CONF_COMMUNITY, DEFAULT_COMMUNITY) + port = config.get(CONF_PORT) + community = config.get(CONF_COMMUNITY) baseoid = config.get(CONF_BASEOID) + unit = config.get(CONF_UNIT_OF_MEASUREMENT) errindication, _, _, _ = next( getCmd(SnmpEngine(), @@ -61,9 +66,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): return False else: data = SnmpData(host, port, community, baseoid) - add_devices([SnmpSensor(data, - config.get('name', DEFAULT_NAME), - config.get('unit_of_measurement'))]) + add_devices([SnmpSensor(data, name, unit)]) class SnmpSensor(Entity): diff --git a/homeassistant/components/sensor/speedtest.py b/homeassistant/components/sensor/speedtest.py index 445bf8a8d4b..86fd48e4d03 100644 --- a/homeassistant/components/sensor/speedtest.py +++ b/homeassistant/components/sensor/speedtest.py @@ -8,10 +8,13 @@ import logging import re import sys from subprocess import check_output, CalledProcessError +import voluptuous as vol import homeassistant.util.dt as dt_util +import homeassistant.helpers.config_validation as cv from homeassistant.components import recorder -from homeassistant.components.sensor import DOMAIN +from homeassistant.components.sensor import (DOMAIN, PLATFORM_SCHEMA) +from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import track_time_change @@ -22,7 +25,6 @@ _SPEEDTEST_REGEX = re.compile(r'Ping:\s(\d+\.\d+)\sms[\r\n]+' r'Download:\s(\d+\.\d+)\sMbit/s[\r\n]+' r'Upload:\s(\d+\.\d+)\sMbit/s[\r\n]+') -CONF_MONITORED_CONDITIONS = 'monitored_conditions' CONF_SECOND = 'second' CONF_MINUTE = 'minute' CONF_HOUR = 'hour' @@ -33,6 +35,19 @@ SENSOR_TYPES = { 'upload': ['Upload', 'Mbit/s'], } +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_MONITORED_CONDITIONS): + vol.All(cv.ensure_list, [vol.In(list(SENSOR_TYPES.keys()))]), + vol.Optional(CONF_SECOND, default=[0]): + vol.All(cv.ensure_list, [vol.All(vol.Coerce(int), vol.Range(0, 59))]), + vol.Optional(CONF_MINUTE, default=[0]): + vol.All(cv.ensure_list, [vol.All(vol.Coerce(int), vol.Range(0, 59))]), + vol.Optional(CONF_HOUR): + vol.All(cv.ensure_list, [vol.All(vol.Coerce(int), vol.Range(0, 23))]), + vol.Optional(CONF_DAY): + vol.All(cv.ensure_list, [vol.All(vol.Coerce(int), vol.Range(1, 31))]), +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Speedtest sensor.""" @@ -97,6 +112,8 @@ class SpeedtestSensor(Entity): ).order_by(states.state_id.desc()).limit(1)) except TypeError: return + except RuntimeError: + return if not last_state: return self._state = last_state[0].state @@ -115,10 +132,10 @@ class SpeedtestData(object): """Initialize the data object.""" self.data = None track_time_change(hass, self.update, - second=config.get(CONF_SECOND, 0), - minute=config.get(CONF_MINUTE, 0), - hour=config.get(CONF_HOUR, None), - day=config.get(CONF_DAY, None)) + second=config.get(CONF_SECOND), + minute=config.get(CONF_MINUTE), + hour=config.get(CONF_HOUR), + day=config.get(CONF_DAY)) def update(self, now): """Get the latest data from speedtest.net.""" diff --git a/homeassistant/components/sensor/swiss_hydrological_data.py b/homeassistant/components/sensor/swiss_hydrological_data.py index 5c224f30d37..b2e95690727 100644 --- a/homeassistant/components/sensor/swiss_hydrological_data.py +++ b/homeassistant/components/sensor/swiss_hydrological_data.py @@ -10,8 +10,8 @@ from datetime import timedelta import voluptuous as vol import requests -from homeassistant.const import (TEMP_CELSIUS, CONF_PLATFORM, CONF_NAME, - STATE_UNKNOWN) +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (TEMP_CELSIUS, CONF_NAME, STATE_UNKNOWN) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -21,8 +21,8 @@ REQUIREMENTS = ['xmltodict==0.10.2'] _LOGGER = logging.getLogger(__name__) _RESOURCE = 'http://www.hydrodata.ch/xml/SMS.xml' -DEFAULT_NAME = 'Water temperature' CONF_STATION = 'station' +DEFAULT_NAME = 'Water temperature' ICON = 'mdi:cup-water' ATTR_LOCATION = 'Location' @@ -36,10 +36,9 @@ ATTR_DISCHARGE_MAX = 'Discharge max' ATTR_WATERLEVEL_MAX = 'Level max' ATTR_TEMPERATURE_MAX = 'Temperature max' -PLATFORM_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): 'swiss_hydrological_data', - vol.Optional(CONF_NAME): cv.string, +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_STATION): vol.Coerce(int), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) # Return cached results if last scan was less then this time ago. @@ -50,8 +49,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Swiss hydrological sensor.""" import xmltodict + name = config.get(CONF_NAME) station = config.get(CONF_STATION) - name = config.get(CONF_NAME, DEFAULT_NAME) try: response = requests.get(_RESOURCE, timeout=5) diff --git a/homeassistant/components/sensor/swiss_public_transport.py b/homeassistant/components/sensor/swiss_public_transport.py index 2ca1992659b..d7d80ac2a3c 100644 --- a/homeassistant/components/sensor/swiss_public_transport.py +++ b/homeassistant/components/sensor/swiss_public_transport.py @@ -8,34 +8,48 @@ import logging from datetime import timedelta import requests +import voluptuous as vol +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME import homeassistant.util.dt as dt_util from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) _RESOURCE = 'http://transport.opendata.ch/v1/' ATTR_DEPARTURE_TIME1 = 'Next departure' ATTR_DEPARTURE_TIME2 = 'Next on departure' +ATTR_REMAINING_TIME = 'Remaining time' ATTR_START = 'Start' ATTR_TARGET = 'Destination' -ATTR_REMAINING_TIME = 'Remaining time' + +CONF_DESTINATION = 'to' +CONF_START = 'from' + +DEFAULT_NAME = 'Next Departure' ICON = 'mdi:bus' +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) TIME_STR_FORMAT = "%H:%M" -# Return cached results if last scan was less then this time ago. -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_DESTINATION): cv.string, + vol.Required(CONF_START): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) def setup_platform(hass, config, add_devices, discovery_info=None): """Get the Swiss public transport sensor.""" + name = config.get(CONF_NAME) # journal contains [0] Station ID start, [1] Station ID destination # [2] Station name start, and [3] Station name destination - journey = [config.get('from'), config.get('to')] + journey = [config.get(CONF_START), config.get(CONF_DESTINATION)] try: - for location in [config.get('from', None), config.get('to', None)]: + for location in [config.get(CONF_START), config.get(CONF_DESTINATION)]: # transport.opendata.ch doesn't play nice with requests.Session result = requests.get(_RESOURCE + 'locations?query=%s' % location, timeout=10) @@ -46,20 +60,18 @@ def setup_platform(hass, config, add_devices, discovery_info=None): "Check your settings and/or the availability of opendata.ch") return False - dev = [] data = PublicTransportData(journey) - dev.append(SwissPublicTransportSensor(data, journey)) - add_devices(dev) + add_devices([SwissPublicTransportSensor(data, journey, name)]) # pylint: disable=too-few-public-methods class SwissPublicTransportSensor(Entity): """Implementation of an Swiss public transport sensor.""" - def __init__(self, data, journey): + def __init__(self, data, journey, name): """Initialize the sensor.""" self.data = data - self._name = 'Next Departure' + self._name = name self._from = journey[2] self._to = journey[3] self.update() @@ -123,7 +135,7 @@ class PublicTransportData(object): 'to=' + self.destination + '&' + 'fields[]=connections/from/departureTimestamp/&' + 'fields[]=connections/', - timeout=30) + timeout=10) connections = response.json()['connections'][:2] try: diff --git a/homeassistant/components/sensor/systemmonitor.py b/homeassistant/components/sensor/systemmonitor.py index c9767428aaa..893ec8154c4 100755 --- a/homeassistant/components/sensor/systemmonitor.py +++ b/homeassistant/components/sensor/systemmonitor.py @@ -6,11 +6,19 @@ https://home-assistant.io/components/sensor.systemmonitor/ """ import logging +import voluptuous as vol + import homeassistant.util.dt as dt_util -from homeassistant.const import STATE_OFF, STATE_ON + +from homeassistant.const import (CONF_RESOURCES, STATE_OFF, STATE_ON) +from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['psutil==4.3.0'] + +_LOGGER = logging.getLogger(__name__) + SENSOR_TYPES = { 'disk_use_percent': ['Disk Use', '%', 'mdi:harddisk'], 'disk_use': ['Disk Use', 'GiB', 'mdi:harddisk'], @@ -33,20 +41,23 @@ SENSOR_TYPES = { 'since_last_boot': ['Since Last Boot', '', 'mdi:clock'] } -_LOGGER = logging.getLogger(__name__) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_RESOURCES, default=['disk_use']): + vol.All(cv.ensure_list, [vol.Schema({ + vol.Required('type'): vol.In(SENSOR_TYPES), + vol.Optional('arg'): cv.string, + })]) +}) # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the System sensors.""" dev = [] - for resource in config['resources']: + for resource in config[CONF_RESOURCES]: if 'arg' not in resource: resource['arg'] = '' - if resource['type'] not in SENSOR_TYPES: - _LOGGER.error('Sensor type: "%s" does not exist', resource['type']) - else: - dev.append(SystemMonitorSensor(resource['type'], resource['arg'])) + dev.append(SystemMonitorSensor(resource['type'], resource['arg'])) add_devices(dev) diff --git a/homeassistant/components/sensor/template.py b/homeassistant/components/sensor/template.py index 668db816619..961b6f39c17 100644 --- a/homeassistant/components/sensor/template.py +++ b/homeassistant/components/sensor/template.py @@ -5,8 +5,10 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.template/ """ import logging +import voluptuous as vol +import homeassistant.helpers.config_validation as cv -from homeassistant.components.sensor import ENTITY_ID_FORMAT +from homeassistant.components.sensor import ENTITY_ID_FORMAT, PLATFORM_SCHEMA from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, ATTR_ENTITY_ID, MATCH_ALL) @@ -14,39 +16,32 @@ from homeassistant.exceptions import TemplateError from homeassistant.helpers.entity import Entity, generate_entity_id from homeassistant.helpers import template from homeassistant.helpers.event import track_state_change -from homeassistant.util import slugify _LOGGER = logging.getLogger(__name__) CONF_SENSORS = 'sensors' +SENSOR_SCHEMA = vol.Schema({ + vol.Required(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(ATTR_FRIENDLY_NAME): cv.string, + vol.Optional(ATTR_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(ATTR_ENTITY_ID, default=MATCH_ALL): cv.entity_ids +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SENSORS): vol.Schema({cv.slug: SENSOR_SCHEMA}), +}) + # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the template sensors.""" sensors = [] - if config.get(CONF_SENSORS) is None: - _LOGGER.error("Missing configuration data for sensor platform") - return False for device, device_config in config[CONF_SENSORS].items(): - if device != slugify(device): - _LOGGER.error("Found invalid key for sensor.template: %s. " - "Use %s instead", device, slugify(device)) - continue - - if not isinstance(device_config, dict): - _LOGGER.error("Missing configuration data for sensor %s", device) - continue - + state_template = device_config[CONF_VALUE_TEMPLATE] + entity_ids = device_config[ATTR_ENTITY_ID] friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device) unit_of_measurement = device_config.get(ATTR_UNIT_OF_MEASUREMENT) - state_template = device_config.get(CONF_VALUE_TEMPLATE) - if state_template is None: - _LOGGER.error( - "Missing %s for sensor %s", CONF_VALUE_TEMPLATE, device) - continue - - entity_ids = device_config.get(ATTR_ENTITY_ID, MATCH_ALL) sensors.append( SensorTemplate( diff --git a/homeassistant/components/sensor/time_date.py b/homeassistant/components/sensor/time_date.py index 48760a4463e..9281080b3b4 100644 --- a/homeassistant/components/sensor/time_date.py +++ b/homeassistant/components/sensor/time_date.py @@ -5,12 +5,20 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.time_date/ """ import logging - from datetime import timedelta + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_DISPLAY_OPTIONS import homeassistant.util.dt as dt_util from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) + +TIME_STR_FORMAT = "%H:%M" + OPTION_TYPES = { 'time': 'Time', 'date': 'Date', @@ -20,7 +28,10 @@ OPTION_TYPES = { 'time_utc': 'Time (UTC)', } -TIME_STR_FORMAT = "%H:%M" +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_DISPLAY_OPTIONS, default=['time']): + vol.All(cv.ensure_list, [vol.In(OPTION_TYPES)]), +}) def setup_platform(hass, config, add_devices, discovery_info=None): @@ -29,14 +40,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.error("Timezone is not set in Home Assistant config") return False - dev = [] - for variable in config['display_options']: - if variable not in OPTION_TYPES: - _LOGGER.error('Option type: "%s" does not exist', variable) - else: - dev.append(TimeDateSensor(variable)) + devices = [] + for variable in config[CONF_DISPLAY_OPTIONS]: + devices.append(TimeDateSensor(variable)) - add_devices(dev) + add_devices(devices) # pylint: disable=too-few-public-methods diff --git a/homeassistant/components/sensor/transmission.py b/homeassistant/components/sensor/transmission.py index 46a2e607dbe..3c9aad05626 100644 --- a/homeassistant/components/sensor/transmission.py +++ b/homeassistant/components/sensor/transmission.py @@ -7,20 +7,39 @@ https://home-assistant.io/components/sensor.transmission/ import logging from datetime import timedelta -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_NAME, CONF_PORT, + CONF_MONITORED_VARIABLES, STATE_UNKNOWN, STATE_IDLE) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle +import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['transmissionrpc==0.11'] + +_LOGGER = logging.getLogger(__name__) +_THROTTLED_REFRESH = None + +DEFAULT_NAME = 'Transmission' +DEFAULT_PORT = 9091 + SENSOR_TYPES = { 'current_status': ['Status', None], 'download_speed': ['Down Speed', 'MB/s'], 'upload_speed': ['Up Speed', 'MB/s'] } -_LOGGER = logging.getLogger(__name__) - -_THROTTLED_REFRESH = None +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_MONITORED_VARIABLES, default=[]): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_USERNAME): cv.string, +}) # pylint: disable=unused-argument @@ -29,22 +48,18 @@ def setup_platform(hass, config, add_devices, discovery_info=None): import transmissionrpc from transmissionrpc.error import TransmissionError + name = config.get(CONF_NAME) host = config.get(CONF_HOST) - username = config.get(CONF_USERNAME, None) - password = config.get(CONF_PASSWORD, None) - port = config.get('port', 9091) - - name = config.get("name", "Transmission") - if not host: - _LOGGER.error('Missing config variable %s', CONF_HOST) - return False + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + port = config.get(CONF_PORT) transmission_api = transmissionrpc.Client( host, port=port, user=username, password=password) try: transmission_api.session_stats() except TransmissionError: - _LOGGER.exception("Connection to Transmission API failed.") + _LOGGER.exception("Connection to Transmission API failed") return False # pylint: disable=global-statement @@ -53,18 +68,14 @@ def setup_platform(hass, config, add_devices, discovery_info=None): transmission_api.session_stats) dev = [] - for variable in config['monitored_variables']: - if variable not in SENSOR_TYPES: - _LOGGER.error('Sensor type: "%s" does not exist', variable) - else: - dev.append(TransmissionSensor( - variable, transmission_api, name)) + for variable in config[CONF_MONITORED_VARIABLES]: + dev.append(TransmissionSensor(variable, transmission_api, name)) add_devices(dev) class TransmissionSensor(Entity): - """representation of a Transmission sensor.""" + """Representation of a Transmission sensor.""" def __init__(self, sensor_type, transmission_client, client_name): """Initialize the sensor.""" @@ -78,7 +89,7 @@ class TransmissionSensor(Entity): @property def name(self): """Return the name of the sensor.""" - return self.client_name + ' ' + self._name + return '{} {}'.format(self.client_name, self._name) @property def state(self): @@ -90,6 +101,7 @@ class TransmissionSensor(Entity): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement + # pylint: disable=no-self-use def refresh_transmission_data(self): """Call the throttled Transmission refresh method.""" from transmissionrpc.error import TransmissionError @@ -98,13 +110,12 @@ class TransmissionSensor(Entity): try: _THROTTLED_REFRESH() except TransmissionError: - _LOGGER.exception( - self.name + " Connection to Transmission API failed." - ) + _LOGGER.error("Connection to Transmission API failed") def update(self): """Get the latest data from Transmission and updates the state.""" self.refresh_transmission_data() + if self.type == 'current_status': if self.transmission_client.session: upload = self.transmission_client.session.uploadSpeed @@ -116,9 +127,9 @@ class TransmissionSensor(Entity): elif upload == 0 and download > 0: self._state = 'Downloading' else: - self._state = 'Idle' + self._state = STATE_IDLE else: - self._state = 'Unknown' + self._state = STATE_UNKNOWN if self.transmission_client.session: if self.type == 'download_speed': diff --git a/homeassistant/components/sensor/twitch.py b/homeassistant/components/sensor/twitch.py index 7b18408105d..73e2d221cb1 100644 --- a/homeassistant/components/sensor/twitch.py +++ b/homeassistant/components/sensor/twitch.py @@ -4,23 +4,39 @@ Support for the Twitch stream status. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.twitch/ """ -from homeassistant.helpers.entity import Entity +import logging -STATE_STREAMING = 'streaming' -STATE_OFFLINE = 'offline' -ATTR_GAME = 'game' -ATTR_TITLE = 'title' -ICON = 'mdi:twitch' +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['python-twitch==1.3.0'] -DOMAIN = 'twitch' + +_LOGGER = logging.getLogger(__name__) + +ATTR_GAME = 'game' +ATTR_TITLE = 'title' + +CONF_CHANNELS = 'channels' +ICON = 'mdi:twitch' + +STATE_OFFLINE = 'offline' +STATE_STREAMING = 'streaming' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_CHANNELS, default=[]): + vol.All(cv.ensure_list, [cv.string]), +}) # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Twitch platform.""" - add_devices( - [TwitchSensor(channel) for channel in config.get('channels', [])]) + channels = config.get(CONF_CHANNELS, []) + + add_devices([TwitchSensor(channel) for channel in channels]) class TwitchSensor(Entity): diff --git a/homeassistant/components/sensor/wink.py b/homeassistant/components/sensor/wink.py index 4f39bd9f2ff..ddc160c5064 100644 --- a/homeassistant/components/sensor/wink.py +++ b/homeassistant/components/sensor/wink.py @@ -12,7 +12,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.components.wink import WinkDevice from homeassistant.loader import get_component -REQUIREMENTS = ['python-wink==0.7.11', 'pubnub==3.8.2'] +REQUIREMENTS = ['python-wink==0.7.13', 'pubnub==3.8.2'] SENSOR_TYPES = ['temperature', 'humidity'] diff --git a/homeassistant/components/sensor/worldclock.py b/homeassistant/components/sensor/worldclock.py index 0cfc4598cd0..43141b51b3d 100644 --- a/homeassistant/components/sensor/worldclock.py +++ b/homeassistant/components/sensor/worldclock.py @@ -6,31 +6,32 @@ https://home-assistant.io/components/sensor.worldclock/ """ import logging +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (CONF_NAME, CONF_TIME_ZONE) import homeassistant.util.dt as dt_util from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = "Worldclock Sensor" + +DEFAULT_NAME = 'Worldclock Sensor' ICON = 'mdi:clock' TIME_STR_FORMAT = "%H:%M" +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_TIME_ZONE): cv.time_zone, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Worldclock sensor.""" - try: - time_zone = dt_util.get_time_zone(config.get('time_zone')) - except AttributeError: - _LOGGER.error("time_zone in platform configuration is missing.") - return False + name = config.get(CONF_NAME) + time_zone = dt_util.get_time_zone(config.get(CONF_TIME_ZONE)) - if time_zone is None: - _LOGGER.error("Timezone '%s' is not valid.", config.get('time_zone')) - return False - - add_devices([WorldClockSensor( - time_zone, - config.get('name', DEFAULT_NAME) - )]) + add_devices([WorldClockSensor(time_zone, name)]) class WorldClockSensor(Entity): diff --git a/homeassistant/components/sensor/wunderground.py b/homeassistant/components/sensor/wunderground.py new file mode 100644 index 00000000000..0321c4f7dcb --- /dev/null +++ b/homeassistant/components/sensor/wunderground.py @@ -0,0 +1,160 @@ +""" +Support for WUnderground weather service. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.wunderground/ +""" +from datetime import timedelta +import logging + +import requests +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle +from homeassistant.const import ( + CONF_MONITORED_CONDITIONS, CONF_API_KEY, TEMP_FAHRENHEIT, TEMP_CELSIUS, + STATE_UNKNOWN) + +_RESOURCE = 'http://api.wunderground.com/api/{}/conditions/q/' +_LOGGER = logging.getLogger(__name__) + +CONF_PWS_ID = 'pws_id' + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=300) + +# Sensor types are defined like: Name, units +SENSOR_TYPES = { + 'weather': ['Weather Summary', None], + 'station_id': ['Station ID', None], + 'feelslike_c': ['Feels Like (°C)', TEMP_CELSIUS], + 'feelslike_f': ['Feels Like (°F)', TEMP_FAHRENHEIT], + 'feelslike_string': ['Feels Like', None], + 'heat_index_c': ['Dewpoint (°C)', TEMP_CELSIUS], + 'heat_index_f': ['Dewpoint (°F)', TEMP_FAHRENHEIT], + 'heat_index_string': ['Heat Index Summary', None], + 'dewpoint_c': ['Dewpoint (°C)', TEMP_CELSIUS], + 'dewpoint_f': ['Dewpoint (°F)', TEMP_FAHRENHEIT], + 'dewpoint_string': ['Dewpoint Summary', None], + 'wind_kph': ['Wind Speed', 'kpH'], + 'wind_mph': ['Wind Speed', 'mpH'], + 'UV': ['UV', None], + 'pressure_in': ['Pressure', 'in'], + 'pressure_mb': ['Pressure', 'mbar'], + 'wind_dir': ['Wind Direction', None], + 'wind_string': ['Wind Summary', None], + 'temp_c': ['Temperature (°C)', TEMP_CELSIUS], + 'temp_f': ['Temperature (°F)', TEMP_FAHRENHEIT], + 'relative_humidity': ['Relative Humidity', '%'], + 'visibility_mi': ['Visibility (miles)', 'mi'], + 'visibility_km': ['Visibility (km)', 'km'], + 'precip_today_in': ['Precipation Today', 'in'], + 'precip_today_metric': ['Precipitation Today', 'mm'], + 'precip_today_string': ['Precipitation today', None], + 'solarradiation': ['Solar Radiation', None] +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_PWS_ID): cv.string, + vol.Required(CONF_MONITORED_CONDITIONS, default=[]): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the WUnderground sensor.""" + rest = WUndergroundData(hass, + config.get(CONF_API_KEY), + config.get(CONF_PWS_ID, None)) + sensors = [] + for variable in config[CONF_MONITORED_CONDITIONS]: + sensors.append(WUndergroundSensor(rest, variable)) + + try: + rest.update() + except ValueError as err: + _LOGGER.error("Received error from WUnderground: %s", err) + return False + + add_devices(sensors) + + return True + + +class WUndergroundSensor(Entity): + """Implementing the WUnderground sensor.""" + + def __init__(self, rest, condition): + """Initialize the sensor.""" + self.rest = rest + self._condition = condition + + @property + def name(self): + """Return the name of the sensor.""" + return "PWS_" + self._condition + + @property + def state(self): + """Return the state of the sensor.""" + if self.rest.data and self._condition in self.rest.data: + return self.rest.data[self._condition] + else: + return STATE_UNKNOWN + + @property + def entity_picture(self): + """Return the entity picture.""" + if self._condition == 'weather': + return self.rest.data['icon_url'] + + @property + def unit_of_measurement(self): + """Return the units of measurement.""" + return SENSOR_TYPES[self._condition][1] + + def update(self): + """Update current conditions.""" + self.rest.update() + +# pylint: disable=too-few-public-methods + + +class WUndergroundData(object): + """Get data from WUnderground.""" + + def __init__(self, hass, api_key, pws_id=None): + """Initialize the data object.""" + self._hass = hass + self._api_key = api_key + self._pws_id = pws_id + self._latitude = hass.config.latitude + self._longitude = hass.config.longitude + self.data = None + + def _build_url(self): + url = _RESOURCE.format(self._api_key) + if self._pws_id: + url = url + 'pws:{}'.format(self._pws_id) + else: + url = url + '{},{}'.format(self._latitude, self._longitude) + + return url + '.json' + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from WUnderground.""" + try: + result = requests.get(self._build_url(), timeout=10).json() + if "error" in result['response']: + raise ValueError(result['response']["error"] + ["description"]) + else: + self.data = result["current_observation"] + except ValueError as err: + _LOGGER.error("Check WUnderground API %s", err.args) + self.data = None + raise diff --git a/homeassistant/components/sensor/yr.py b/homeassistant/components/sensor/yr.py index 3407838899e..d69bd65688a 100644 --- a/homeassistant/components/sensor/yr.py +++ b/homeassistant/components/sensor/yr.py @@ -9,17 +9,16 @@ import requests import voluptuous as vol import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_PLATFORM, CONF_LATITUDE, CONF_LONGITUDE, CONF_ELEVATION, - CONF_MONITORED_CONDITIONS -) + CONF_LATITUDE, CONF_LONGITUDE, CONF_ELEVATION, CONF_MONITORED_CONDITIONS) from homeassistant.helpers.entity import Entity from homeassistant.util import dt as dt_util -_LOGGER = logging.getLogger(__name__) - REQUIREMENTS = ['xmltodict==0.10.2'] +_LOGGER = logging.getLogger(__name__) + # Sensor types are defined like so: SENSOR_TYPES = { 'symbol': ['Symbol', None], @@ -38,8 +37,7 @@ SENSOR_TYPES = { 'dewpointTemperature': ['Dewpoint temperature', '°C'], } -PLATFORM_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): 'yr', +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_MONITORED_CONDITIONS, default=[]): [vol.In(SENSOR_TYPES.keys())], vol.Optional(CONF_LATITUDE): cv.latitude, @@ -58,9 +56,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.error("Latitude or longitude not set in Home Assistant config") return False - coordinates = dict(lat=latitude, - lon=longitude, - msl=elevation) + coordinates = dict(lat=latitude, lon=longitude, msl=elevation) weather = YrData(coordinates) diff --git a/homeassistant/components/sensor/yweather.py b/homeassistant/components/sensor/yweather.py index c308d36f50b..8e89b25282c 100644 --- a/homeassistant/components/sensor/yweather.py +++ b/homeassistant/components/sensor/yweather.py @@ -6,14 +6,24 @@ https://home-assistant.io/components/sensor.yweather/ """ import logging from datetime import timedelta + import voluptuous as vol -from homeassistant.const import (CONF_PLATFORM, TEMP_CELSIUS, - CONF_MONITORED_CONDITIONS, STATE_UNKNOWN) +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + TEMP_CELSIUS, CONF_MONITORED_CONDITIONS, STATE_UNKNOWN) +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ["yahooweather==0.6"] +REQUIREMENTS = ["yahooweather==0.7"] + +_LOGGER = logging.getLogger(__name__) + +CONF_FORECAST = 'forecast' +CONF_WOEID = 'woeid' + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) SENSOR_TYPES = { 'weather_current': ['Current', None], @@ -27,27 +37,22 @@ SENSOR_TYPES = { 'visibility': ['Visibility', "distance"], } -PLATFORM_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): "yweather", - vol.Optional("woeid"): vol.Coerce(str), - vol.Optional("forecast"): vol.Coerce(int), +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_WOEID, default=None): cv.string, + vol.Optional(CONF_FORECAST, default=0): + vol.All(vol.Coerce(int), vol.Range(min=0, max=5)), vol.Required(CONF_MONITORED_CONDITIONS, default=[]): - [vol.In(SENSOR_TYPES.keys())], + [vol.In(SENSOR_TYPES)], }) -# Return cached results if last scan was less then this time ago. -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) - -_LOGGER = logging.getLogger(__name__) - def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Yahoo! weather sensor.""" from yahooweather import get_woeid, UNIT_C, UNIT_F unit = hass.config.units.temperature_unit - woeid = config.get("woeid", None) - forecast = config.get("forecast", 0) + woeid = config.get(CONF_WOEID) + forecast = config.get(CONF_FORECAST) # convert unit yunit = UNIT_C if unit == TEMP_CELSIUS else UNIT_F @@ -139,6 +144,9 @@ class YahooWeatherSensor(Entity): def update(self): """Get the latest data from Yahoo! and updates the states.""" self._data.update() + if not self._data.yahoo.RawData: + _LOGGER.info("Don't receive weather data from yahoo!") + return # default code for weather image self._code = self._data.yahoo.Now["code"] diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index 901a00a72ef..ac6d9829fc5 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -73,6 +73,18 @@ homematic: description: Event to send i.e. PRESS_LONG, PRESS_SHORT example: PRESS_LONG + set_value: + description: Set the name of a node. + + fields: + entity_id: + description: Name(s) of entities to set value + example: 'homematic.my_variable' + + value: + description: New value + example: 1 + zwave: add_node: description: Add a new node to the zwave network. Refer to OZW.log for details. @@ -100,3 +112,13 @@ zwave: test_network: description: This will send test to nodes in the zwave network. This will greatly slow down the zwave network while it is being processed. Refer to OZW.log for details. + + rename_node: + description: Set the name of a node. + fields: + entity_id: + description: Name(s) of entities to to rename + example: 'light.leviton_vrmx11lz_multilevel_scene_switch_level_40' + name: + description: New Name + example: 'kitchen' diff --git a/homeassistant/components/splunk.py b/homeassistant/components/splunk.py index 9a5f6e385eb..2ae2842bceb 100644 --- a/homeassistant/components/splunk.py +++ b/homeassistant/components/splunk.py @@ -1,8 +1,6 @@ """ Support to send data to an Splunk instance. -Uses the HTTP Event Collector. - For more details about this component, please refer to the documentation at https://home-assistant.io/components/splunk/ """ @@ -10,47 +8,47 @@ import json import logging import requests +import voluptuous as vol -import homeassistant.util as util -from homeassistant.const import EVENT_STATE_CHANGED +from homeassistant.const import ( + CONF_HOST, CONF_PORT, CONF_SSL, CONF_TOKEN, EVENT_STATE_CHANGED) from homeassistant.helpers import state as state_helper -from homeassistant.helpers import validate_config +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -DOMAIN = "splunk" -DEPENDENCIES = [] +DOMAIN = 'splunk' DEFAULT_HOST = 'localhost' -DEFAULT_PORT = '8088' +DEFAULT_PORT = 8088 DEFAULT_SSL = False -CONF_HOST = 'host' -CONF_PORT = 'port' -CONF_TOKEN = 'token' -CONF_SSL = 'SSL' +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_TOKEN): cv.string, + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_SSL, default=False): cv.boolean, + }), +}, extra=vol.ALLOW_EXTRA) def setup(hass, config): """Setup the Splunk component.""" - if not validate_config(config, {DOMAIN: ['token']}, _LOGGER): - _LOGGER.error("You must include the token for your HTTP " - "Event Collector input in Splunk.") - return False - conf = config[DOMAIN] + host = conf.get(CONF_HOST) + port = conf.get(CONF_PORT) + token = conf.get(CONF_TOKEN) + use_ssl = conf.get(CONF_SSL) - host = conf[CONF_HOST] - port = util.convert(conf.get(CONF_PORT), int, DEFAULT_PORT) - token = util.convert(conf.get(CONF_TOKEN), str) - use_ssl = util.convert(conf.get(CONF_SSL), bool, DEFAULT_SSL) if use_ssl: - uri_scheme = "https://" + uri_scheme = 'https://' else: - uri_scheme = "http://" - event_collector = uri_scheme + host + ":" + str(port) + \ - "/services/collector/event" - headers = {'Authorization': 'Splunk ' + token} + uri_scheme = 'http://' + + event_collector = '{}{}:{}/services/collector/event'.format( + uri_scheme, host, port) + headers = {'Authorization': 'Splunk {}'.format(token)} def splunk_event_listener(event): """Listen for new messages on the bus and sends them to Splunk.""" diff --git a/homeassistant/components/switch/dlink.py b/homeassistant/components/switch/dlink.py index 97e4ee9674f..b65c521bad5 100644 --- a/homeassistant/components/switch/dlink.py +++ b/homeassistant/components/switch/dlink.py @@ -6,42 +6,41 @@ https://home-assistant.io/components/switch.dlink/ """ import logging -from homeassistant.components.switch import DOMAIN, SwitchDevice +import voluptuous as vol + +from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME) -from homeassistant.helpers import validate_config +import homeassistant.helpers.config_validation as cv -# constants -DEFAULT_USERNAME = 'admin' -DEFAULT_PASSWORD = '' -DEVICE_DEFAULT_NAME = 'D-link Smart Plug W215' REQUIREMENTS = ['https://github.com/LinuxChristian/pyW215/archive/' 'v0.1.1.zip#pyW215==0.1.1'] -# setup logger _LOGGER = logging.getLogger(__name__) +DEFAULT_NAME = 'D-link Smart Plug W215' +DEFAULT_PASSWORD = '' +DEFAULT_USERNAME = 'admin' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, + vol.Required(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices_callback, discovery_info=None): - """Find and return D-Link Smart Plugs.""" +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup a D-Link Smart Plug.""" from pyW215.pyW215 import SmartPlug - # check for required values in configuration file - if not validate_config({DOMAIN: config}, - {DOMAIN: [CONF_HOST]}, - _LOGGER): - return False - host = config.get(CONF_HOST) - username = config.get(CONF_USERNAME, DEFAULT_USERNAME) - password = str(config.get(CONF_PASSWORD, DEFAULT_PASSWORD)) - name = config.get(CONF_NAME, DEVICE_DEFAULT_NAME) + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + name = config.get(CONF_NAME) - add_devices_callback([SmartPlugSwitch(SmartPlug(host, - password, - username), - name)]) + add_devices([SmartPlugSwitch(SmartPlug(host, password, username), name)]) class SmartPlugSwitch(SwitchDevice): diff --git a/homeassistant/components/switch/enocean.py b/homeassistant/components/switch/enocean.py index f0ae26100c3..87a89d148ab 100644 --- a/homeassistant/components/switch/enocean.py +++ b/homeassistant/components/switch/enocean.py @@ -4,25 +4,31 @@ Support for EnOcean switches. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.enocean/ """ - import logging -from homeassistant.const import CONF_NAME +import voluptuous as vol + +from homeassistant.components.switch import PLATFORM_SCHEMA +from homeassistant.const import (CONF_NAME, CONF_ID) from homeassistant.components import enocean from homeassistant.helpers.entity import ToggleEntity - +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -DEPENDENCIES = ["enocean"] +DEFAULT_NAME = 'EnOcean Switch' +DEPENDENCIES = ['enocean'] -CONF_ID = "id" +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ID): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the EnOcean switch platform.""" - dev_id = config.get(CONF_ID, None) - devname = config.get(CONF_NAME, "Enocean actuator") + dev_id = config.get(CONF_ID) + devname = config.get(CONF_NAME) add_devices([EnOceanSwitch(dev_id, devname)]) diff --git a/homeassistant/components/switch/flux.py b/homeassistant/components/switch/flux.py index a66b45bc82e..61a40315620 100644 --- a/homeassistant/components/switch/flux.py +++ b/homeassistant/components/switch/flux.py @@ -101,7 +101,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Update lights.""" flux.flux_update() - hass.services.register(DOMAIN, 'flux_update', update) + hass.services.register(DOMAIN, name + '_update', update) # pylint: disable=too-many-instance-attributes @@ -151,8 +151,10 @@ class FluxSwitch(SwitchDevice): self.update_ha_state() # pylint: disable=too-many-locals - def flux_update(self, now=dt_now()): + def flux_update(self, now=None): """Update all the lights using flux.""" + if now is None: + now = dt_now() sunset = next_setting(self.hass, SUN).replace(day=now.day, month=now.month, year=now.year) diff --git a/homeassistant/components/switch/mysensors.py b/homeassistant/components/switch/mysensors.py index 102490286f6..6e0ed7528a2 100644 --- a/homeassistant/components/switch/mysensors.py +++ b/homeassistant/components/switch/mysensors.py @@ -55,7 +55,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): pres.S_LOCK: MySensorsSwitch, pres.S_IR: MySensorsIRSwitch, } - if float(gateway.version) >= 1.5: + if float(gateway.protocol_version) >= 1.5: map_sv_types.update({ pres.S_BINARY: [set_req.V_STATUS, set_req.V_LIGHT], pres.S_SPRINKLER: [set_req.V_STATUS], diff --git a/homeassistant/components/switch/mystrom.py b/homeassistant/components/switch/mystrom.py index 867392e4661..2f8679fb209 100644 --- a/homeassistant/components/switch/mystrom.py +++ b/homeassistant/components/switch/mystrom.py @@ -8,9 +8,9 @@ import logging import voluptuous as vol -from homeassistant.const import (CONF_PLATFORM, CONF_NAME, CONF_HOST) +from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) +from homeassistant.const import (CONF_NAME, CONF_HOST) import homeassistant.helpers.config_validation as cv -from homeassistant.components.switch import SwitchDevice REQUIREMENTS = ['python-mystrom==0.3.6'] @@ -18,10 +18,9 @@ DEFAULT_NAME = 'myStrom Switch' _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): 'mystrom', - vol.Optional(CONF_NAME): cv.string, +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) @@ -29,6 +28,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Find and return myStrom switch.""" from pymystrom import MyStromPlug, exceptions + name = config.get(CONF_NAME) host = config.get(CONF_HOST) try: @@ -37,7 +37,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.error("No route to device '%s'", host) return False - add_devices([MyStromSwitch(config.get('name', DEFAULT_NAME), host)]) + add_devices([MyStromSwitch(name, host)]) class MyStromSwitch(SwitchDevice): diff --git a/homeassistant/components/switch/rest.py b/homeassistant/components/switch/rest.py index d4c8ebe026d..ee29dd13adb 100644 --- a/homeassistant/components/switch/rest.py +++ b/homeassistant/components/switch/rest.py @@ -7,24 +7,35 @@ https://home-assistant.io/components/switch.rest/ import logging import requests +import voluptuous as vol -from homeassistant.components.switch import SwitchDevice +from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) +from homeassistant.const import (CONF_NAME, CONF_RESOURCE) +import homeassistant.helpers.config_validation as cv + +CONF_BODY_OFF = 'body_off' +CONF_BODY_ON = 'body_on' +DEFAULT_BODY_OFF = 'OFF' +DEFAULT_BODY_ON = 'ON' +DEFAULT_NAME = 'REST Switch' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_RESOURCE): cv.url, + vol.Optional(CONF_BODY_OFF, default=DEFAULT_BODY_OFF): cv.string, + vol.Optional(CONF_BODY_ON, default=DEFAULT_BODY_ON): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = "REST Switch" -DEFAULT_BODY_ON = "ON" -DEFAULT_BODY_OFF = "OFF" - # pylint: disable=unused-argument, def setup_platform(hass, config, add_devices_callback, discovery_info=None): - """Setup the REST switch.""" - resource = config.get('resource') - - if resource is None: - _LOGGER.error("Missing required variable: resource") - return False + """Setup the RESTful switch.""" + name = config.get(CONF_NAME) + resource = config.get(CONF_RESOURCE) + body_on = config.get(CONF_BODY_ON) + body_off = config.get(CONF_BODY_OFF) try: requests.get(resource, timeout=10) @@ -36,12 +47,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): _LOGGER.error("No route to resource/endpoint: %s", resource) return False - add_devices_callback([RestSwitch( - hass, - config.get('name', DEFAULT_NAME), - config.get('resource'), - config.get('body_on', DEFAULT_BODY_ON), - config.get('body_off', DEFAULT_BODY_OFF))]) + add_devices_callback([RestSwitch(hass, name, resource, body_on, body_off)]) # pylint: disable=too-many-arguments diff --git a/homeassistant/components/switch/template.py b/homeassistant/components/switch/template.py index ebb3cb42258..6778315843e 100644 --- a/homeassistant/components/switch/template.py +++ b/homeassistant/components/switch/template.py @@ -5,8 +5,11 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.template/ """ import logging +import voluptuous as vol +import homeassistant.helpers.config_validation as cv -from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchDevice +from homeassistant.components.switch import ( + ENTITY_ID_FORMAT, SwitchDevice, PLATFORM_SCHEMA) from homeassistant.const import ( ATTR_FRIENDLY_NAME, CONF_VALUE_TEMPLATE, STATE_OFF, STATE_ON, ATTR_ENTITY_ID, MATCH_ALL) @@ -15,7 +18,6 @@ from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.script import Script from homeassistant.helpers import template from homeassistant.helpers.event import track_state_change -from homeassistant.util import slugify CONF_SWITCHES = 'switches' @@ -25,41 +27,30 @@ OFF_ACTION = 'turn_off' _LOGGER = logging.getLogger(__name__) _VALID_STATES = [STATE_ON, STATE_OFF, 'true', 'false'] +SWITCH_SCHEMA = vol.Schema({ + vol.Required(CONF_VALUE_TEMPLATE): cv.template, + vol.Required(ON_ACTION): cv.SCRIPT_SCHEMA, + vol.Required(OFF_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(ATTR_FRIENDLY_NAME): cv.string, + vol.Optional(ATTR_ENTITY_ID, default=MATCH_ALL): cv.entity_ids +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SWITCHES): vol.Schema({cv.slug: SWITCH_SCHEMA}), +}) + # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Template switch.""" switches = [] - if config.get(CONF_SWITCHES) is None: - _LOGGER.error("Missing configuration data for switch platform") - return False for device, device_config in config[CONF_SWITCHES].items(): - - if device != slugify(device): - _LOGGER.error("Found invalid key for switch.template: %s. " - "Use %s instead", device, slugify(device)) - continue - - if not isinstance(device_config, dict): - _LOGGER.error("Missing configuration data for switch %s", device) - continue - friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device) - state_template = device_config.get(CONF_VALUE_TEMPLATE) - on_action = device_config.get(ON_ACTION) - off_action = device_config.get(OFF_ACTION) - if state_template is None: - _LOGGER.error( - "Missing %s for switch %s", CONF_VALUE_TEMPLATE, device) - continue - - if on_action is None or off_action is None: - _LOGGER.error( - "Missing action for switch %s", device) - continue - - entity_ids = device_config.get(ATTR_ENTITY_ID, MATCH_ALL) + state_template = device_config[CONF_VALUE_TEMPLATE] + on_action = device_config[ON_ACTION] + off_action = device_config[OFF_ACTION] + entity_ids = device_config[ATTR_ENTITY_ID] switches.append( SwitchTemplate( diff --git a/homeassistant/components/switch/transmission.py b/homeassistant/components/switch/transmission.py index 10ebc7606a0..6b8f89838d5 100644 --- a/homeassistant/components/switch/transmission.py +++ b/homeassistant/components/switch/transmission.py @@ -6,48 +6,56 @@ https://home-assistant.io/components/switch.transmission/ """ import logging +import voluptuous as vol + +from homeassistant.components.switch import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_HOST, CONF_PASSWORD, CONF_USERNAME, STATE_OFF, STATE_ON) + CONF_HOST, CONF_NAME, CONF_PORT, CONF_PASSWORD, CONF_USERNAME, STATE_OFF, + STATE_ON) from homeassistant.helpers.entity import ToggleEntity +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['transmissionrpc==0.11'] _LOGGING = logging.getLogger(__name__) -REQUIREMENTS = ['transmissionrpc==0.11'] + +DEFAULT_NAME = 'Transmission Turtle Mode' +DEFAULT_PORT = 9091 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_USERNAME): cv.string, +}) # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices_callback, discovery_info=None): - """Setup the transmission sensor.""" +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Transmission switch.""" import transmissionrpc from transmissionrpc.error import TransmissionError + name = config.get(CONF_NAME) host = config.get(CONF_HOST) - username = config.get(CONF_USERNAME, None) - password = config.get(CONF_PASSWORD, None) - port = config.get('port', 9091) - - name = config.get("name", "Transmission Turtle Mode") - if not host: - _LOGGING.error('Missing config variable %s', CONF_HOST) - return False - - # import logging - # logging.getLogger('transmissionrpc').setLevel(logging.DEBUG) + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + port = config.get(CONF_PORT) transmission_api = transmissionrpc.Client( host, port=port, user=username, password=password) try: transmission_api.session_stats() except TransmissionError: - _LOGGING.exception("Connection to Transmission API failed.") + _LOGGING.error("Connection to Transmission API failed") return False - add_devices_callback([ - TransmissionSwitch(transmission_api, name) - ]) + add_devices([TransmissionSwitch(transmission_api, name)]) class TransmissionSwitch(ToggleEntity): - """Representation of a Transmission sensor.""" + """Representation of a Transmission switch.""" def __init__(self, transmission_client, name): """Initialize the Transmission switch.""" @@ -77,18 +85,15 @@ class TransmissionSwitch(ToggleEntity): def turn_on(self, **kwargs): """Turn the device on.""" - _LOGGING.info("Turning on Turtle Mode") - self.transmission_client.set_session( - alt_speed_enabled=True) + _LOGGING.debug("Turning Turtle Mode of Transmission on") + self.transmission_client.set_session(alt_speed_enabled=True) def turn_off(self, **kwargs): """Turn the device off.""" - _LOGGING.info("Turning off Turtle Mode ") - self.transmission_client.set_session( - alt_speed_enabled=False) + _LOGGING.debug("Turning Turtle Mode of Transmission off") + self.transmission_client.set_session(alt_speed_enabled=False) def update(self): """Get the latest data from Transmission and updates the state.""" - active = self.transmission_client.get_session( - ).alt_speed_enabled + active = self.transmission_client.get_session().alt_speed_enabled self._state = STATE_ON if active else STATE_OFF diff --git a/homeassistant/components/switch/wake_on_lan.py b/homeassistant/components/switch/wake_on_lan.py index 779f4759442..0ecbd51a11b 100644 --- a/homeassistant/components/switch/wake_on_lan.py +++ b/homeassistant/components/switch/wake_on_lan.py @@ -8,27 +8,35 @@ import logging import platform import subprocess as sp -from homeassistant.components.switch import SwitchDevice +import voluptuous as vol + +from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) +import homeassistant.helpers.config_validation as cv +from homeassistant.const import (CONF_HOST, CONF_NAME) -_LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['wakeonlan==0.2.2'] -DEFAULT_NAME = "Wake on LAN" +_LOGGER = logging.getLogger(__name__) + +CONF_MAC_ADDRESS = 'mac_address' + +DEFAULT_NAME = 'Wake on LAN' DEFAULT_PING_TIMEOUT = 1 +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_MAC_ADDRESS): cv.string, + vol.Optional(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Add wake on lan switch.""" - if config.get('mac_address') is None: - _LOGGER.error("Missing required variable: mac_address") - return False + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + mac_address = config.get(CONF_MAC_ADDRESS) - add_devices_callback([WOLSwitch( - hass, - config.get('name', DEFAULT_NAME), - config.get('host'), - config.get('mac_address'), - )]) + add_devices_callback([WOLSwitch(hass, name, host, mac_address)]) class WOLSwitch(SwitchDevice): diff --git a/homeassistant/components/switch/wink.py b/homeassistant/components/switch/wink.py index 1feb8e584bb..3a0cd1b7736 100644 --- a/homeassistant/components/switch/wink.py +++ b/homeassistant/components/switch/wink.py @@ -10,7 +10,7 @@ from homeassistant.components.wink import WinkDevice from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.helpers.entity import ToggleEntity -REQUIREMENTS = ['python-wink==0.7.11', 'pubnub==3.8.2'] +REQUIREMENTS = ['python-wink==0.7.13', 'pubnub==3.8.2'] def setup_platform(hass, config, add_devices, discovery_info=None): diff --git a/homeassistant/components/thermostat/__init__.py b/homeassistant/components/thermostat/__init__.py index 09a18b91402..a9169ce4756 100644 --- a/homeassistant/components/thermostat/__init__.py +++ b/homeassistant/components/thermostat/__init__.py @@ -115,6 +115,9 @@ def set_hvac_mode(hass, hvac_mode, entity_id=None): # pylint: disable=too-many-branches def setup(hass, config): """Setup thermostats.""" + _LOGGER.warning('This component has been deprecated in favour of' + ' the "climate" component and will be removed ' + 'in the future. Please upgrade.') component = EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL) component.setup(config) diff --git a/homeassistant/components/thermostat/zwave.py b/homeassistant/components/thermostat/zwave.py index bd5e5d04b58..6bed82284bb 100644 --- a/homeassistant/components/thermostat/zwave.py +++ b/homeassistant/components/thermostat/zwave.py @@ -51,6 +51,15 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if DEVICE_MAPPINGS[specific_sensor_key] == WORKAROUND_IGNORE: _LOGGER.debug("Remotec ZXT-120 Zwave Thermostat, ignoring") return + if not (value.node.get_values_for_command_class( + COMMAND_CLASS_SENSOR_MULTILEVEL) and + value.node.get_values_for_command_class( + COMMAND_CLASS_THERMOSTAT_SETPOINT)): + return + + if value.command_class != COMMAND_CLASS_SENSOR_MULTILEVEL and \ + value.command_class != COMMAND_CLASS_THERMOSTAT_SETPOINT: + return add_devices([ZWaveThermostat(value)]) _LOGGER.debug("discovery_info=%s and zwave.NETWORK=%s", diff --git a/homeassistant/components/vera.py b/homeassistant/components/vera.py index 514fe002568..f6162f5582c 100644 --- a/homeassistant/components/vera.py +++ b/homeassistant/components/vera.py @@ -7,12 +7,14 @@ https://home-assistant.io/components/vera/ import logging from collections import defaultdict -from requests.exceptions import RequestException +import voluptuous as vol +from requests.exceptions import RequestException from homeassistant.util.dt import utc_from_timestamp from homeassistant.util import convert from homeassistant.helpers import discovery +from homeassistant.helpers import config_validation as cv from homeassistant.const import ( ATTR_ARMED, ATTR_BATTERY_LEVEL, ATTR_LAST_TRIP_TIME, ATTR_TRIPPED, EVENT_HOMEASSISTANT_STOP) @@ -26,6 +28,7 @@ DOMAIN = 'vera' VERA_CONTROLLER = None +CONF_CONTROLLER = 'vera_controller_url' CONF_EXCLUDE = 'exclude' CONF_LIGHTS = 'lights' @@ -33,6 +36,16 @@ ATTR_CURRENT_POWER_MWH = "current_power_mwh" VERA_DEVICES = defaultdict(list) +VERA_ID_LIST_SCHEMA = vol.Schema([int]) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_CONTROLLER): cv.url, + vol.Optional(CONF_EXCLUDE, default=[]): VERA_ID_LIST_SCHEMA, + vol.Optional(CONF_LIGHTS, default=[]): VERA_ID_LIST_SCHEMA, + }), +}, extra=vol.ALLOW_EXTRA) + # pylint: disable=unused-argument, too-many-function-args def setup(hass, base_config): @@ -41,14 +54,7 @@ def setup(hass, base_config): import pyvera as veraApi config = base_config.get(DOMAIN) - base_url = config.get('vera_controller_url') - if not base_url: - _LOGGER.error( - "The required parameter 'vera_controller_url'" - " was not found in config" - ) - return False - + base_url = config.get(CONF_CONTROLLER) VERA_CONTROLLER, _ = veraApi.init_controller(base_url) def stop_subscription(event): @@ -65,15 +71,9 @@ def setup(hass, base_config): _LOGGER.exception("Error communicating with Vera API") return False - exclude = config.get(CONF_EXCLUDE, []) - if not isinstance(exclude, list): - _LOGGER.error("'exclude' must be a list of device_ids") - return False + exclude = config.get(CONF_EXCLUDE) - lights_ids = config.get(CONF_LIGHTS, []) - if not isinstance(lights_ids, list): - _LOGGER.error("'lights' must be a list of device_ids") - return False + lights_ids = config.get(CONF_LIGHTS) for device in all_devices: if device.device_id in exclude: diff --git a/homeassistant/components/verisure.py b/homeassistant/components/verisure.py index 601264c70f8..1231a4128fa 100644 --- a/homeassistant/components/verisure.py +++ b/homeassistant/components/verisure.py @@ -15,7 +15,7 @@ from homeassistant.util import Throttle DOMAIN = "verisure" -REQUIREMENTS = ['vsure==0.8.1'] +REQUIREMENTS = ['vsure==0.10.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/wemo.py b/homeassistant/components/wemo.py index 00a7017fa0f..29e6d53cd2c 100644 --- a/homeassistant/components/wemo.py +++ b/homeassistant/components/wemo.py @@ -6,11 +6,15 @@ https://home-assistant.io/components/wemo/ """ import logging +import voluptuous as vol + from homeassistant.components.discovery import SERVICE_WEMO from homeassistant.helpers import discovery +from homeassistant.helpers import config_validation as cv + from homeassistant.const import EVENT_HOMEASSISTANT_STOP -REQUIREMENTS = ['pywemo==0.4.5'] +REQUIREMENTS = ['pywemo==0.4.6'] DOMAIN = 'wemo' @@ -29,6 +33,14 @@ KNOWN_DEVICES = [] _LOGGER = logging.getLogger(__name__) +CONF_STATIC = 'static' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_STATIC, default=[]): vol.Schema([cv.string]) + }), +}, extra=vol.ALLOW_EXTRA) + # pylint: disable=unused-argument, too-many-function-args def setup(hass, config): @@ -69,7 +81,7 @@ def setup(hass, config): # Add static devices from the config file. devices.extend((address, None) - for address in config.get(DOMAIN, {}).get('static', [])) + for address in config.get(DOMAIN, {}).get(CONF_STATIC)) for address, device in devices: port = pywemo.ouimeaux_device.probe_wemo(address) diff --git a/homeassistant/components/wink.py b/homeassistant/components/wink.py index 4a45cd8576f..96f40c8d1f7 100644 --- a/homeassistant/components/wink.py +++ b/homeassistant/components/wink.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, ATTR_BATTERY_LEVEL from homeassistant.helpers.entity import Entity DOMAIN = "wink" -REQUIREMENTS = ['python-wink==0.7.11', 'pubnub==3.8.2'] +REQUIREMENTS = ['python-wink==0.7.13', 'pubnub==3.8.2'] SUBSCRIPTION_HANDLER = None CHANNELS = [] diff --git a/homeassistant/components/zwave.py b/homeassistant/components/zwave.py index a60319188ce..eb1d048244b 100644 --- a/homeassistant/components/zwave.py +++ b/homeassistant/components/zwave.py @@ -8,6 +8,7 @@ import logging import os.path import time from pprint import pprint +import voluptuous as vol from homeassistant.helpers import discovery from homeassistant.const import ( @@ -16,6 +17,8 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers.event import track_time_change from homeassistant.util import convert, slugify +import homeassistant.config as conf_util +import homeassistant.helpers.config_validation as cv DOMAIN = "zwave" REQUIREMENTS = ['pydispatcher==2.0.5'] @@ -40,6 +43,7 @@ SERVICE_SOFT_RESET = "soft_reset" SERVICE_TEST_NETWORK = "test_network" SERVICE_STOP_NETWORK = "stop_network" SERVICE_START_NETWORK = "start_network" +SERVICE_RENAME_NODE = "rename_node" EVENT_SCENE_ACTIVATED = "zwave.scene_activated" EVENT_NODE_EVENT = "zwave.node_event" @@ -151,18 +155,6 @@ DISCOVERY_COMPONENTS = [ [COMMAND_CLASS_SENSOR_BINARY], TYPE_BOOL, GENRE_USER), - ('thermostat', - [GENERIC_COMMAND_CLASS_THERMOSTAT], - [SPECIFIC_DEVICE_CLASS_WHATEVER], - [COMMAND_CLASS_THERMOSTAT_SETPOINT], - TYPE_WHATEVER, - GENRE_WHATEVER), - ('hvac', - [GENERIC_COMMAND_CLASS_THERMOSTAT], - [SPECIFIC_DEVICE_CLASS_WHATEVER], - [COMMAND_CLASS_THERMOSTAT_FAN_MODE], - TYPE_WHATEVER, - GENRE_WHATEVER), ('lock', [GENERIC_COMMAND_CLASS_ENTRY_CONTROL], [SPECIFIC_DEVICE_CLASS_ADVANCED_DOOR_LOCK, @@ -170,33 +162,41 @@ DISCOVERY_COMPONENTS = [ [COMMAND_CLASS_DOOR_LOCK], TYPE_BOOL, GENRE_USER), - ('rollershutter', - [GENERIC_COMMAND_CLASS_MULTILEVEL_SWITCH], + ('cover', + [GENERIC_COMMAND_CLASS_MULTILEVEL_SWITCH, + GENERIC_COMMAND_CLASS_ENTRY_CONTROL], [SPECIFIC_DEVICE_CLASS_MOTOR_CONTROL_CLASS_A, SPECIFIC_DEVICE_CLASS_MOTOR_CONTROL_CLASS_B, SPECIFIC_DEVICE_CLASS_MOTOR_CONTROL_CLASS_C, - SPECIFIC_DEVICE_CLASS_MULTIPOSITION_MOTOR], - [COMMAND_CLASS_WHATEVER], - TYPE_WHATEVER, - GENRE_USER), - ('garage_door', - [GENERIC_COMMAND_CLASS_ENTRY_CONTROL], - [SPECIFIC_DEVICE_CLASS_SECURE_BARRIER_ADD_ON, + SPECIFIC_DEVICE_CLASS_MULTIPOSITION_MOTOR, + SPECIFIC_DEVICE_CLASS_SECURE_BARRIER_ADD_ON, SPECIFIC_DEVICE_CLASS_SECURE_DOOR], [COMMAND_CLASS_SWITCH_BINARY, - COMMAND_CLASS_BARRIER_OPERATOR], - TYPE_BOOL, - GENRE_USER) + COMMAND_CLASS_BARRIER_OPERATOR, + COMMAND_CLASS_SWITCH_MULTILEVEL], + TYPE_WHATEVER, + GENRE_USER), + ('climate', + [GENERIC_COMMAND_CLASS_THERMOSTAT], + [SPECIFIC_DEVICE_CLASS_WHATEVER], + [COMMAND_CLASS_THERMOSTAT_SETPOINT], + TYPE_WHATEVER, + GENRE_WHATEVER), ] ATTR_NODE_ID = "node_id" ATTR_VALUE_ID = "value_id" ATTR_OBJECT_ID = "object_id" - +ATTR_NAME = "name" ATTR_SCENE_ID = "scene_id" ATTR_BASIC_LEVEL = "basic_level" +RENAME_NODE_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_NAME): cv.string, +}) + NETWORK = None _LOGGER = logging.getLogger(__name__) @@ -278,6 +278,9 @@ def setup(hass, config): # pylint: disable=global-statement, import-error global NETWORK + descriptions = conf_util.load_yaml_config_file( + os.path.join(os.path.dirname(__file__), "services.yaml")) + try: import libopenzwave except ImportError: @@ -467,6 +470,16 @@ def setup(hass, config): NETWORK.stop() hass.bus.fire(EVENT_NETWORK_STOP) + def rename_node(service): + """Rename a node.""" + state = hass.states.get(service.data.get(ATTR_ENTITY_ID)) + node_id = state.attributes.get(ATTR_NODE_ID) + node = NETWORK.nodes[node_id] + name = service.data.get(ATTR_NAME) + node.name = name + _LOGGER.info( + "Renamed ZWave node %d to %s", node_id, name) + def start_zwave(_service_or_event): """Startup Z-Wave network.""" _LOGGER.info("Starting ZWave network.") @@ -511,6 +524,9 @@ def setup(hass, config): hass.services.register(DOMAIN, SERVICE_TEST_NETWORK, test_network) hass.services.register(DOMAIN, SERVICE_STOP_NETWORK, stop_zwave) hass.services.register(DOMAIN, SERVICE_START_NETWORK, start_zwave) + hass.services.register(DOMAIN, SERVICE_RENAME_NODE, rename_node, + descriptions[DOMAIN][SERVICE_RENAME_NODE], + schema=RENAME_NODE_SCHEMA) # Setup autoheal if autoheal: diff --git a/homeassistant/config.py b/homeassistant/config.py index 65ed44bef83..d67bbea0bc6 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -267,8 +267,9 @@ def process_ha_core_config(hass, config): else: hac.units = IMPERIAL_SYSTEM _LOGGER.warning("Found deprecated temperature unit in core config, " - "expected unit system. Replace 'temperature: %s' with " - "'unit_system: %s'", unit, hac.units.name) + "expected unit system. Replace '%s: %s' with " + "'%s: %s'", CONF_TEMPERATURE_UNIT, unit, + CONF_UNIT_SYSTEM, hac.units.name) # Shortcut if no auto-detection necessary if None not in (hac.latitude, hac.longitude, hac.units, diff --git a/homeassistant/const.py b/homeassistant/const.py index 74ff104fcda..a43e4e58a1a 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ # coding: utf-8 """Constants used by Home Assistant components.""" -__version__ = "0.26.3" +__version__ = '0.27.0' REQUIRED_PYTHON_VER = (3, 4) PLATFORM_FORMAT = '{}.{}' @@ -10,7 +10,7 @@ PLATFORM_FORMAT = '{}.{}' MATCH_ALL = '*' # If no name is specified -DEVICE_DEFAULT_NAME = "Unnamed Device" +DEVICE_DEFAULT_NAME = 'Unnamed Device' WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] @@ -23,47 +23,71 @@ CONF_ACCESS_TOKEN = 'access_token' CONF_AFTER = 'after' CONF_ALIAS = 'alias' CONF_API_KEY = 'api_key' +CONF_AUTHENTICATION = 'authentication' CONF_BEFORE = 'before' CONF_BELOW = 'below' +CONF_BLACKLIST = 'blacklist' +CONF_CODE = 'code' CONF_CONDITION = 'condition' CONF_CUSTOMIZE = 'customize' +CONF_DEVICE = 'device' +CONF_DISARM_AFTER_TRIGGER = 'disarm_after_trigger' +CONF_DISPLAY_OPTIONS = 'display_options' CONF_ELEVATION = 'elevation' CONF_ENTITY_ID = 'entity_id' CONF_ENTITY_NAMESPACE = 'entity_namespace' CONF_EVENT = 'event' +CONF_FILE_PATH = 'file_path' CONF_FILENAME = 'filename' CONF_HOST = 'host' CONF_HOSTS = 'hosts' CONF_ICON = 'icon' +CONF_ID = 'id' CONF_LATITUDE = 'latitude' CONF_LONGITUDE = 'longitude' +CONF_METHOD = 'method' CONF_MONITORED_CONDITIONS = 'monitored_conditions' +CONF_MONITORED_VARIABLES = 'monitored_variables' CONF_NAME = 'name' CONF_OFFSET = 'offset' CONF_OPTIMISTIC = 'optimistic' CONF_PASSWORD = 'password' +CONF_PAYLOAD = 'payload' +CONF_PENDING_TIME = 'pending_time' CONF_PLATFORM = 'platform' CONF_PORT = 'port' +CONF_PREFIX = 'prefix' +CONF_RESOURCE = 'resource' +CONF_RESOURCES = 'resources' CONF_SCAN_INTERVAL = 'scan_interval' +CONF_SENSOR_CLASS = 'sensor_class' +CONF_SSL = 'ssl' CONF_STATE = 'state' +CONF_STRUCTURE = 'structure' CONF_TEMPERATURE_UNIT = 'temperature_unit' -CONF_UNIT_SYSTEM = 'unit_system' CONF_TIME_ZONE = 'time_zone' +CONF_TOKEN = 'token' +CONF_TRIGGER_TIME = 'trigger_time' +CONF_UNIT_OF_MEASUREMENT = 'unit_of_measurement' +CONF_UNIT_SYSTEM = 'unit_system' +CONF_URL = 'url' CONF_USERNAME = 'username' CONF_VALUE_TEMPLATE = 'value_template' +CONF_VERIFY_SSL = 'verify_ssl' CONF_WEEKDAY = 'weekday' +CONF_WHITELIST = 'whitelist' CONF_ZONE = 'zone' # #### EVENTS #### -EVENT_HOMEASSISTANT_START = "homeassistant_start" -EVENT_HOMEASSISTANT_STOP = "homeassistant_stop" -EVENT_STATE_CHANGED = "state_changed" -EVENT_TIME_CHANGED = "time_changed" -EVENT_CALL_SERVICE = "call_service" -EVENT_SERVICE_EXECUTED = "service_executed" -EVENT_PLATFORM_DISCOVERED = "platform_discovered" -EVENT_COMPONENT_LOADED = "component_loaded" -EVENT_SERVICE_REGISTERED = "service_registered" +EVENT_HOMEASSISTANT_START = 'homeassistant_start' +EVENT_HOMEASSISTANT_STOP = 'homeassistant_stop' +EVENT_STATE_CHANGED = 'state_changed' +EVENT_TIME_CHANGED = 'time_changed' +EVENT_CALL_SERVICE = 'call_service' +EVENT_SERVICE_EXECUTED = 'service_executed' +EVENT_PLATFORM_DISCOVERED = 'platform_discovered' +EVENT_COMPONENT_LOADED = 'component_loaded' +EVENT_SERVICE_REGISTERED = 'service_registered' # #### STATES #### STATE_ON = 'on' @@ -88,94 +112,94 @@ STATE_UNAVAILABLE = 'unavailable' # #### STATE AND EVENT ATTRIBUTES #### # Contains current time for a TIME_CHANGED event -ATTR_NOW = "now" +ATTR_NOW = 'now' # Contains domain, service for a SERVICE_CALL event -ATTR_DOMAIN = "domain" -ATTR_SERVICE = "service" -ATTR_SERVICE_DATA = "service_data" +ATTR_DOMAIN = 'domain' +ATTR_SERVICE = 'service' +ATTR_SERVICE_DATA = 'service_data' # Data for a SERVICE_EXECUTED event -ATTR_SERVICE_CALL_ID = "service_call_id" +ATTR_SERVICE_CALL_ID = 'service_call_id' # Contains one string or a list of strings, each being an entity id ATTR_ENTITY_ID = 'entity_id' # String with a friendly name for the entity -ATTR_FRIENDLY_NAME = "friendly_name" +ATTR_FRIENDLY_NAME = 'friendly_name' # A picture to represent entity -ATTR_ENTITY_PICTURE = "entity_picture" +ATTR_ENTITY_PICTURE = 'entity_picture' # Icon to use in the frontend -ATTR_ICON = "icon" +ATTR_ICON = 'icon' # The unit of measurement if applicable -ATTR_UNIT_OF_MEASUREMENT = "unit_of_measurement" +ATTR_UNIT_OF_MEASUREMENT = 'unit_of_measurement' CONF_UNIT_SYSTEM_METRIC = 'metric' # type: str CONF_UNIT_SYSTEM_IMPERIAL = 'imperial' # type: str # Temperature attribute -ATTR_TEMPERATURE = "temperature" -TEMP_CELSIUS = "°C" -TEMP_FAHRENHEIT = "°F" +ATTR_TEMPERATURE = 'temperature' +TEMP_CELSIUS = '°C' +TEMP_FAHRENHEIT = '°F' # Length units -LENGTH_CENTIMETERS = "cm" # type: str -LENGTH_METERS = "m" # type: str -LENGTH_KILOMETERS = "km" # type: str +LENGTH_CENTIMETERS = 'cm' # type: str +LENGTH_METERS = 'm' # type: str +LENGTH_KILOMETERS = 'km' # type: str -LENGTH_INCHES = "in" # type: str -LENGTH_FEET = "ft" # type: str -LENGTH_YARD = "yd" # type: str -LENGTH_MILES = "mi" # type: str +LENGTH_INCHES = 'in' # type: str +LENGTH_FEET = 'ft' # type: str +LENGTH_YARD = 'yd' # type: str +LENGTH_MILES = 'mi' # type: str # Volume units -VOLUME_LITERS = "L" # type: str -VOLUME_MILLILITERS = "mL" # type: str +VOLUME_LITERS = 'L' # type: str +VOLUME_MILLILITERS = 'mL' # type: str -VOLUME_GALLONS = "gal" # type: str -VOLUME_FLUID_OUNCE = "fl. oz." # type: str +VOLUME_GALLONS = 'gal' # type: str +VOLUME_FLUID_OUNCE = 'fl. oz.' # type: str # Mass units -MASS_GRAMS = "g" # type: str -MASS_KILOGRAMS = "kg" # type: str +MASS_GRAMS = 'g' # type: str +MASS_KILOGRAMS = 'kg' # type: str -MASS_OUNCES = "oz" # type: str -MASS_POUNDS = "lb" # type: str +MASS_OUNCES = 'oz' # type: str +MASS_POUNDS = 'lb' # type: str # Contains the information that is discovered -ATTR_DISCOVERED = "discovered" +ATTR_DISCOVERED = 'discovered' # Location of the device/sensor -ATTR_LOCATION = "location" +ATTR_LOCATION = 'location' -ATTR_BATTERY_LEVEL = "battery_level" +ATTR_BATTERY_LEVEL = 'battery_level' # For devices which support a code attribute ATTR_CODE = 'code' ATTR_CODE_FORMAT = 'code_format' # For devices which support an armed state -ATTR_ARMED = "device_armed" +ATTR_ARMED = 'device_armed' # For devices which support a locked state -ATTR_LOCKED = "locked" +ATTR_LOCKED = 'locked' # For sensors that support 'tripping', eg. motion and door sensors -ATTR_TRIPPED = "device_tripped" +ATTR_TRIPPED = 'device_tripped' # For sensors that support 'tripping' this holds the most recent # time the device was tripped -ATTR_LAST_TRIP_TIME = "last_tripped_time" +ATTR_LAST_TRIP_TIME = 'last_tripped_time' # For all entity's, this hold whether or not it should be hidden -ATTR_HIDDEN = "hidden" +ATTR_HIDDEN = 'hidden' # Location of the entity -ATTR_LATITUDE = "latitude" -ATTR_LONGITUDE = "longitude" +ATTR_LATITUDE = 'latitude' +ATTR_LONGITUDE = 'longitude' # Accuracy of location in meters ATTR_GPS_ACCURACY = 'gps_accuracy' @@ -184,59 +208,69 @@ ATTR_GPS_ACCURACY = 'gps_accuracy' ATTR_ASSUMED_STATE = 'assumed_state' # #### SERVICES #### -SERVICE_HOMEASSISTANT_STOP = "stop" -SERVICE_HOMEASSISTANT_RESTART = "restart" +SERVICE_HOMEASSISTANT_STOP = 'stop' +SERVICE_HOMEASSISTANT_RESTART = 'restart' SERVICE_TURN_ON = 'turn_on' SERVICE_TURN_OFF = 'turn_off' SERVICE_TOGGLE = 'toggle' -SERVICE_VOLUME_UP = "volume_up" -SERVICE_VOLUME_DOWN = "volume_down" -SERVICE_VOLUME_MUTE = "volume_mute" -SERVICE_VOLUME_SET = "volume_set" -SERVICE_MEDIA_PLAY_PAUSE = "media_play_pause" -SERVICE_MEDIA_PLAY = "media_play" -SERVICE_MEDIA_PAUSE = "media_pause" -SERVICE_MEDIA_STOP = "media_stop" -SERVICE_MEDIA_NEXT_TRACK = "media_next_track" -SERVICE_MEDIA_PREVIOUS_TRACK = "media_previous_track" -SERVICE_MEDIA_SEEK = "media_seek" +SERVICE_VOLUME_UP = 'volume_up' +SERVICE_VOLUME_DOWN = 'volume_down' +SERVICE_VOLUME_MUTE = 'volume_mute' +SERVICE_VOLUME_SET = 'volume_set' +SERVICE_MEDIA_PLAY_PAUSE = 'media_play_pause' +SERVICE_MEDIA_PLAY = 'media_play' +SERVICE_MEDIA_PAUSE = 'media_pause' +SERVICE_MEDIA_STOP = 'media_stop' +SERVICE_MEDIA_NEXT_TRACK = 'media_next_track' +SERVICE_MEDIA_PREVIOUS_TRACK = 'media_previous_track' +SERVICE_MEDIA_SEEK = 'media_seek' -SERVICE_ALARM_DISARM = "alarm_disarm" -SERVICE_ALARM_ARM_HOME = "alarm_arm_home" -SERVICE_ALARM_ARM_AWAY = "alarm_arm_away" -SERVICE_ALARM_TRIGGER = "alarm_trigger" +SERVICE_ALARM_DISARM = 'alarm_disarm' +SERVICE_ALARM_ARM_HOME = 'alarm_arm_home' +SERVICE_ALARM_ARM_AWAY = 'alarm_arm_away' +SERVICE_ALARM_TRIGGER = 'alarm_trigger' -SERVICE_LOCK = "lock" -SERVICE_UNLOCK = "unlock" +SERVICE_LOCK = 'lock' +SERVICE_UNLOCK = 'unlock' -SERVICE_OPEN = "open" -SERVICE_CLOSE = "close" +SERVICE_OPEN = 'open' +SERVICE_CLOSE = 'close' + +SERVICE_CLOSE_COVER = 'close_cover' +SERVICE_CLOSE_COVER_TILT = 'close_cover_tilt' +SERVICE_OPEN_COVER = 'open_cover' +SERVICE_OPEN_COVER_TILT = 'open_cover_tilt' +SERVICE_SET_COVER_POSITION = 'set_cover_position' +SERVICE_SET_COVER_TILT_POSITION = 'set_cover_tilt_position' +SERVICE_STOP_COVER = 'stop' +SERVICE_STOP_COVER_TILT = 'stop_cover_tilt' SERVICE_MOVE_UP = 'move_up' SERVICE_MOVE_DOWN = 'move_down' +SERVICE_MOVE_POSITION = 'move_position' SERVICE_STOP = 'stop' # #### API / REMOTE #### SERVER_PORT = 8123 -URL_ROOT = "/" -URL_API = "/api/" -URL_API_STREAM = "/api/stream" -URL_API_CONFIG = "/api/config" -URL_API_DISCOVERY_INFO = "/api/discovery_info" -URL_API_STATES = "/api/states" -URL_API_STATES_ENTITY = "/api/states/{}" -URL_API_EVENTS = "/api/events" -URL_API_EVENTS_EVENT = "/api/events/{}" -URL_API_SERVICES = "/api/services" -URL_API_SERVICES_SERVICE = "/api/services/{}/{}" -URL_API_EVENT_FORWARD = "/api/event_forwarding" -URL_API_COMPONENTS = "/api/components" -URL_API_ERROR_LOG = "/api/error_log" -URL_API_LOG_OUT = "/api/log_out" -URL_API_TEMPLATE = "/api/template" +URL_ROOT = '/' +URL_API = '/api/' +URL_API_STREAM = '/api/stream' +URL_API_CONFIG = '/api/config' +URL_API_DISCOVERY_INFO = '/api/discovery_info' +URL_API_STATES = '/api/states' +URL_API_STATES_ENTITY = '/api/states/{}' +URL_API_EVENTS = '/api/events' +URL_API_EVENTS_EVENT = '/api/events/{}' +URL_API_SERVICES = '/api/services' +URL_API_SERVICES_SERVICE = '/api/services/{}/{}' +URL_API_EVENT_FORWARD = '/api/event_forwarding' +URL_API_COMPONENTS = '/api/components' +URL_API_ERROR_LOG = '/api/error_log' +URL_API_LOG_OUT = '/api/log_out' +URL_API_TEMPLATE = '/api/template' HTTP_OK = 200 HTTP_CREATED = 201 @@ -248,25 +282,28 @@ HTTP_METHOD_NOT_ALLOWED = 405 HTTP_UNPROCESSABLE_ENTITY = 422 HTTP_INTERNAL_SERVER_ERROR = 500 -HTTP_HEADER_HA_AUTH = "X-HA-access" -HTTP_HEADER_ACCEPT_ENCODING = "Accept-Encoding" -HTTP_HEADER_CONTENT_TYPE = "Content-type" -HTTP_HEADER_CONTENT_ENCODING = "Content-Encoding" -HTTP_HEADER_VARY = "Vary" -HTTP_HEADER_CONTENT_LENGTH = "Content-Length" -HTTP_HEADER_CACHE_CONTROL = "Cache-Control" -HTTP_HEADER_EXPIRES = "Expires" -HTTP_HEADER_ORIGIN = "Origin" -HTTP_HEADER_X_REQUESTED_WITH = "X-Requested-With" -HTTP_HEADER_ACCEPT = "Accept" -HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN = "Access-Control-Allow-Origin" -HTTP_HEADER_ACCESS_CONTROL_ALLOW_HEADERS = "Access-Control-Allow-Headers" +HTTP_BASIC_AUTHENTICATION = 'basic' +HTTP_DIGEST_AUTHENTICATION = 'digest' + +HTTP_HEADER_HA_AUTH = 'X-HA-access' +HTTP_HEADER_ACCEPT_ENCODING = 'Accept-Encoding' +HTTP_HEADER_CONTENT_TYPE = 'Content-type' +HTTP_HEADER_CONTENT_ENCODING = 'Content-Encoding' +HTTP_HEADER_VARY = 'Vary' +HTTP_HEADER_CONTENT_LENGTH = 'Content-Length' +HTTP_HEADER_CACHE_CONTROL = 'Cache-Control' +HTTP_HEADER_EXPIRES = 'Expires' +HTTP_HEADER_ORIGIN = 'Origin' +HTTP_HEADER_X_REQUESTED_WITH = 'X-Requested-With' +HTTP_HEADER_ACCEPT = 'Accept' +HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN = 'Access-Control-Allow-Origin' +HTTP_HEADER_ACCESS_CONTROL_ALLOW_HEADERS = 'Access-Control-Allow-Headers' ALLOWED_CORS_HEADERS = [HTTP_HEADER_ORIGIN, HTTP_HEADER_ACCEPT, HTTP_HEADER_X_REQUESTED_WITH, HTTP_HEADER_CONTENT_TYPE, HTTP_HEADER_HA_AUTH] -CONTENT_TYPE_JSON = "application/json" +CONTENT_TYPE_JSON = 'application/json' CONTENT_TYPE_MULTIPART = 'multipart/x-mixed-replace; boundary={}' CONTENT_TYPE_TEXT_PLAIN = 'text/plain' diff --git a/homeassistant/core.py b/homeassistant/core.py index ccd8a971f61..b77d8356a35 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -115,7 +115,7 @@ class HomeAssistant(object): @property def is_running(self) -> bool: """Return if Home Assistant is running.""" - return self.state == CoreState.running + return self.state in (CoreState.starting, CoreState.running) def start(self) -> None: """Start home assistant.""" diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 91a05b37b5d..d9c761832dc 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1,5 +1,6 @@ """Helpers for config validation using voluptuous.""" from datetime import timedelta +from urllib.parse import urlparse from typing import Any, Union, TypeVar, Callable, Sequence, List, Dict @@ -29,6 +30,7 @@ latitude = vol.All(vol.Coerce(float), vol.Range(min=-90, max=90), longitude = vol.All(vol.Coerce(float), vol.Range(min=-180, max=180), msg='invalid longitude') sun_event = vol.All(vol.Lower, vol.Any(SUN_EVENT_SUNSET, SUN_EVENT_SUNRISE)) +port = vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)) # typing typevar T = TypeVar('T') @@ -254,6 +256,17 @@ def time_zone(value): weekdays = vol.All(ensure_list, [vol.In(WEEKDAYS)]) +# pylint: disable=no-value-for-parameter +def url(value: Any) -> str: + """Validate an URL.""" + url_in = str(value) + + if urlparse(url_in).scheme in ['http', 'https']: + return vol.Schema(vol.Url())(url_in) + + raise vol.Invalid('invalid url') + + # Validator helpers def key_dependency(key, dependency): diff --git a/homeassistant/scripts/__init__.py b/homeassistant/scripts/__init__.py index d737726f78e..af9e00626dd 100644 --- a/homeassistant/scripts/__init__.py +++ b/homeassistant/scripts/__init__.py @@ -49,7 +49,7 @@ def run(args: List) -> int: def extract_config_dir(args=None) -> str: """Extract the config dir from the arguments or get the default.""" - parser = argparse.ArgumentParser() + parser = argparse.ArgumentParser(add_help=False) parser.add_argument('-c', '--config', default=None) args = parser.parse_known_args(args)[0] return (os.path.join(os.getcwd(), args.config) if args.config diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py new file mode 100644 index 00000000000..624452b0592 --- /dev/null +++ b/homeassistant/scripts/check_config.py @@ -0,0 +1,263 @@ +"""Script to ensure a configuration file exists.""" +import argparse +import os +from glob import glob +import logging +from typing import List, Dict, Sequence +from unittest.mock import patch +from platform import system + +from homeassistant.exceptions import HomeAssistantError +import homeassistant.bootstrap as bootstrap +import homeassistant.config as config_util +import homeassistant.loader as loader +import homeassistant.util.yaml as yaml + +REQUIREMENTS = ('colorlog>2.1,<3',) +if system() == 'Windows': # Ensure colorama installed for colorlog on Windows + REQUIREMENTS += ('colorama<=1',) + +_LOGGER = logging.getLogger(__name__) +# pylint: disable=protected-access +MOCKS = { + 'load': ("homeassistant.util.yaml.load_yaml", yaml.load_yaml), + 'load*': ("homeassistant.config.load_yaml", yaml.load_yaml), + 'get': ("homeassistant.loader.get_component", loader.get_component), + 'secrets': ("homeassistant.util.yaml._secret_yaml", yaml._secret_yaml), + 'except': ("homeassistant.bootstrap.log_exception", + bootstrap.log_exception) +} +SILENCE = ( + 'homeassistant.bootstrap.clear_secret_cache', + 'homeassistant.core._LOGGER.info', + 'homeassistant.loader._LOGGER.info', + 'homeassistant.bootstrap._LOGGER.info', + 'homeassistant.bootstrap._LOGGER.warning', + 'homeassistant.util.yaml._LOGGER.debug', +) +PATCHES = {} + +C_HEAD = 'bold' +ERROR_STR = 'General Errors' + + +def color(the_color, *args, reset=None): + """Color helper.""" + from colorlog.escape_codes import escape_codes, parse_colors + try: + if len(args) == 0: + assert reset is None, "You cannot reset if nothing being printed" + return parse_colors(the_color) + return parse_colors(the_color) + ' '.join(args) + \ + escape_codes[reset or 'reset'] + except KeyError as k: + raise ValueError("Invalid color {} in {}".format(str(k), the_color)) + + +# pylint: disable=too-many-locals, too-many-branches +def run(script_args: List) -> int: + """Handle ensure config commandline script.""" + parser = argparse.ArgumentParser( + description=("Check Home Assistant configuration.")) + parser.add_argument( + '--script', choices=['check_config']) + parser.add_argument( + '-c', '--config', + default=config_util.get_default_config_dir(), + help="Directory that contains the Home Assistant configuration") + parser.add_argument( + '-i', '--info', + default=None, + help="Show a portion of the config") + parser.add_argument( + '-f', '--files', + action='store_true', + help="Show used configuration files") + parser.add_argument( + '-s', '--secrets', + action='store_true', + help="Show secret information") + + args = parser.parse_args() + + config_dir = os.path.join(os.getcwd(), args.config) + config_path = os.path.join(config_dir, 'configuration.yaml') + if not os.path.isfile(config_path): + print('Config does not exist:', config_path) + return 1 + + print(color('bold', "Testing configuration at", config_dir)) + + domain_info = [] + if args.info: + domain_info = args.info.split(',') + + res = check(config_path) + + if args.files: + print(color(C_HEAD, 'yaml files'), '(used /', + color('red', 'not used')+')') + # Python 3.5 gets a recursive, but not in 3.4 + for yfn in sorted(glob(os.path.join(config_dir, '*.yaml')) + + glob(os.path.join(config_dir, '*/*.yaml'))): + the_color = '' if yfn in res['yaml_files'] else 'red' + print(color(the_color, '-', yfn)) + + if len(res['except']) > 0: + print(color('bold_white', 'Failed config')) + for domain, config in res['except'].items(): + domain_info.append(domain) + print(' ', color('bold_red', domain + ':'), + color('red', '', reset='red')) + dump_dict(config, reset='red', indent_count=3) + print(color('reset')) + + if domain_info: + if 'all' in domain_info: + print(color('bold_white', 'Successful config (all)')) + for domain, config in res['components'].items(): + print(' ', color(C_HEAD, domain + ':')) + dump_dict(config, indent_count=3) + else: + print(color('bold_white', 'Successful config (partial)')) + for domain in domain_info: + if domain == ERROR_STR: + continue + print(' ', color(C_HEAD, domain + ':')) + dump_dict(res['components'].get(domain, None), indent_count=3) + + if args.secrets: + flatsecret = {} + + for sfn, sdict in res['secret_cache'].items(): + sss = [] + for skey, sval in sdict.items(): + if skey in flatsecret: + _LOGGER.error('Duplicated secrets in files %s and %s', + flatsecret[skey], sfn) + flatsecret[skey] = sfn + sss.append(color('green', skey) if skey in res['secrets'] + else skey) + print(color(C_HEAD, 'Secrets from', sfn + ':'), ', '.join(sss)) + + print(color(C_HEAD, 'Used Secrets:')) + for skey, sval in res['secrets'].items(): + print(' -', skey + ':', sval, color('cyan', '[from:', flatsecret + .get(skey, 'keyring') + ']')) + + return 0 + + +def check(config_path): + """Perform a check by mocking hass load functions.""" + res = { + 'yaml_files': {}, # yaml_files loaded + 'secrets': {}, # secret cache and secrets loaded + 'except': {}, # exceptions raised (with config) + 'components': {}, # successful components + 'secret_cache': {}, + } + + def mock_load(filename): # pylint: disable=unused-variable + """Mock hass.util.load_yaml to save config files.""" + res['yaml_files'][filename] = True + return MOCKS['load'][1](filename) + + def mock_get(comp_name): # pylint: disable=unused-variable + """Mock hass.loader.get_component to replace setup & setup_platform.""" + def mock_setup(*kwargs): + """Mock setup, only record the component name & config.""" + assert comp_name not in res['components'], \ + "Components should contain a list of platforms" + res['components'][comp_name] = kwargs[1].get(comp_name) + return True + module = MOCKS['get'][1](comp_name) + + if module is None: + # Ensure list + res['except'][ERROR_STR] = res['except'].get(ERROR_STR, []) + res['except'][ERROR_STR].append('{} not found: {}'.format( + 'Platform' if '.' in comp_name else 'Component', comp_name)) + return None + + # Test if platform/component and overwrite setup + if '.' in comp_name: + module.setup_platform = mock_setup + else: + module.setup = mock_setup + + return module + + def mock_secrets(ldr, node): # pylint: disable=unused-variable + """Mock _get_secrets.""" + try: + val = MOCKS['secrets'][1](ldr, node) + except HomeAssistantError: + val = None + res['secrets'][node.value] = val + return val + + def mock_except(ex, domain, config): # pylint: disable=unused-variable + """Mock bootstrap.log_exception.""" + MOCKS['except'][1](ex, domain, config) + res['except'][domain] = config.get(domain, config) + + # Patches to skip functions + for sil in SILENCE: + PATCHES[sil] = patch(sil) + + # Patches with local mock functions + for key, val in MOCKS.items(): + # The * in the key is removed to find the mock_function (side_effect) + # This allows us to use one side_effect to patch multiple locations + mock_function = locals()['mock_' + key.replace('*', '')] + PATCHES[key] = patch(val[0], side_effect=mock_function) + + # Start all patches + for pat in PATCHES.values(): + pat.start() + # Ensure !secrets point to the patched function + yaml.yaml.SafeLoader.add_constructor('!secret', yaml._secret_yaml) + + try: + bootstrap.from_config_file(config_path, skip_pip=True) + res['secret_cache'] = yaml.__SECRET_CACHE + return res + finally: + # Stop all patches + for pat in PATCHES.values(): + pat.stop() + # Ensure !secrets point to the original function + yaml.yaml.SafeLoader.add_constructor('!secret', yaml._secret_yaml) + + +def dump_dict(layer, indent_count=1, listi=False, **kwargs): + """Display a dict. + + A friendly version of print yaml.yaml.dump(config). + """ + def line_src(this): + """Display line config source.""" + if hasattr(this, '__config_file__'): + return color('cyan', "[source {}:{}]" + .format(this.__config_file__, this.__line__ or '?'), + **kwargs) + return '' + + indent_str = indent_count * ' ' + if listi or isinstance(layer, list): + indent_str = indent_str[:-1]+'-' + if isinstance(layer, Dict): + for key, value in layer.items(): + if isinstance(value, dict) or isinstance(value, list): + print(indent_str, key + ':', line_src(value)) + dump_dict(value, indent_count+2) + else: + print(indent_str, key + ':', value) + indent_str = indent_count * ' ' + if isinstance(layer, Sequence): + for i in layer: + if isinstance(i, dict): + dump_dict(i, indent_count, True) + else: + print(indent_str, i) diff --git a/homeassistant/scripts/macos/launchd.plist b/homeassistant/scripts/macos/launchd.plist index 50bc18e3e38..b65cdac7439 100644 --- a/homeassistant/scripts/macos/launchd.plist +++ b/homeassistant/scripts/macos/launchd.plist @@ -3,7 +3,7 @@ Label - org.homeassitant + org.homeassistant EnvironmentVariables @@ -27,10 +27,10 @@ StandardErrorPath - /Users/$USER$/Library/Logs/homeassitant.log + /Users/$USER$/Library/Logs/homeassistant.log StandardOutPath - /Users/$USER$/Library/Logs/homeassitant.log + /Users/$USER$/Library/Logs/homeassistant.log diff --git a/homeassistant/util/yaml.py b/homeassistant/util/yaml.py index 8b2521e3e9b..b834ac8048c 100644 --- a/homeassistant/util/yaml.py +++ b/homeassistant/util/yaml.py @@ -1,10 +1,11 @@ """YAML utility functions.""" +import glob import logging import os +import sys from collections import OrderedDict from typing import Union, List, Dict -import glob import yaml try: import keyring @@ -16,6 +17,7 @@ from homeassistant.exceptions import HomeAssistantError _LOGGER = logging.getLogger(__name__) _SECRET_NAMESPACE = 'homeassistant' _SECRET_YAML = 'secrets.yaml' +__SECRET_CACHE = {} # type: Dict # pylint: disable=too-many-ancestors @@ -43,6 +45,11 @@ def load_yaml(fname: str) -> Union[List, Dict]: raise HomeAssistantError(exc) +def clear_secret_cache() -> None: + """Clear the secret cache.""" + __SECRET_CACHE.clear() + + def _include_yaml(loader: SafeLineLoader, node: yaml.nodes.Node) -> Union[List, Dict]: """Load another YAML file and embeds it using the !include tag. @@ -140,43 +147,51 @@ def _env_var_yaml(loader: SafeLineLoader, raise HomeAssistantError(node.value) +def _load_secret_yaml(secret_path: str) -> Dict: + """Load the secrets yaml from path.""" + secret_path = os.path.join(secret_path, _SECRET_YAML) + if secret_path in __SECRET_CACHE: + return __SECRET_CACHE[secret_path] + + _LOGGER.debug('Loading %s', secret_path) + try: + secrets = load_yaml(secret_path) + if 'logger' in secrets: + logger = str(secrets['logger']).lower() + if logger == 'debug': + _LOGGER.setLevel(logging.DEBUG) + else: + _LOGGER.error("secrets.yaml: 'logger: debug' expected," + " but 'logger: %s' found", logger) + del secrets['logger'] + except FileNotFoundError: + secrets = {} + __SECRET_CACHE[secret_path] = secrets + return secrets + + # pylint: disable=protected-access def _secret_yaml(loader: SafeLineLoader, node: yaml.nodes.Node): """Load secrets and embed it into the configuration YAML.""" - # Create secret cache on loader and load secrets.yaml - if not hasattr(loader, '_SECRET_CACHE'): - loader._SECRET_CACHE = {} + secret_path = os.path.dirname(loader.name) + while True: + secrets = _load_secret_yaml(secret_path) - secret_path = os.path.join(os.path.dirname(loader.name), _SECRET_YAML) - if secret_path not in loader._SECRET_CACHE: - if os.path.isfile(secret_path): - loader._SECRET_CACHE[secret_path] = load_yaml(secret_path) - secrets = loader._SECRET_CACHE[secret_path] - if 'logger' in secrets: - logger = str(secrets['logger']).lower() - if logger == 'debug': - _LOGGER.setLevel(logging.DEBUG) - else: - _LOGGER.error("secrets.yaml: 'logger: debug' expected," - " but 'logger: %s' found", logger) - del secrets['logger'] - else: - loader._SECRET_CACHE[secret_path] = None - secrets = loader._SECRET_CACHE[secret_path] + if node.value in secrets: + _LOGGER.debug('Secret %s retrieved from secrets.yaml in ' + 'folder %s', node.value, secret_path) + return secrets[node.value] - # Retrieve secret, first from secrets.yaml, then from keyring - if secrets is not None and node.value in secrets: - _LOGGER.debug('Secret %s retrieved from secrets.yaml.', node.value) - return secrets[node.value] - for sname, sdict in loader._SECRET_CACHE.items(): - if node.value in sdict: - _LOGGER.debug('Secret %s retrieved from secrets.yaml in other ' - 'folder %s', node.value, sname) - return sdict[node.value] + if secret_path == os.path.dirname(sys.path[0]): + break # sys.path[0] set to config/deps folder by bootstrap + + secret_path = os.path.dirname(secret_path) + if not os.path.exists(secret_path) or len(secret_path) < 5: + break # Somehow we got past the .homeassistant config folder if keyring: - # do ome keyring stuff + # do some keyring stuff pwd = keyring.get_password(_SECRET_NAMESPACE, node.value) if pwd: _LOGGER.debug('Secret %s retrieved from keyring.', node.value) diff --git a/requirements_all.txt b/requirements_all.txt index be9977d6b8b..f19f22813f2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -10,6 +10,9 @@ typing>=3,<4 # homeassistant.components.isy994 PyISY==1.0.6 +# homeassistant.components.notify.html5 +PyJWT==1.4.2 + # homeassistant.components.arduino PyMata==2.12 @@ -32,11 +35,12 @@ apcaccess==0.0.4 astral==1.2 # homeassistant.components.light.blinksticklight -blinkstick==1.1.7 +blinkstick==1.1.8 # homeassistant.components.sensor.bitcoin blockchain==1.3.3 +# homeassistant.components.climate.eq3btsmart # homeassistant.components.thermostat.eq3btsmart # bluepy_devices==0.2.0 @@ -48,6 +52,9 @@ boto3==1.3.1 # homeassistant.components.http cherrypy==7.1.0 +# homeassistant.scripts.check_config +colorlog>2.1,<3 + # homeassistant.components.media_player.directv directpy==0.1 @@ -64,6 +71,7 @@ eliqonline==1.0.12 # homeassistant.components.enocean enocean==0.31 +# homeassistant.components.climate.honeywell # homeassistant.components.thermostat.honeywell evohomeclient==0.2.5 @@ -82,6 +90,9 @@ freesms==0.1.0 # homeassistant.components.conversation fuzzywuzzy==0.11.1 +# homeassistant.components.device_tracker.bluetooth_le_tracker +# gattlib==0.20150805 + # homeassistant.components.notify.gntp gntp==1.0.3 @@ -91,12 +102,14 @@ googlemaps==2.4.4 # homeassistant.components.sensor.gpsd gps3==0.33.2 +# homeassistant.components.binary_sensor.ffmpeg # homeassistant.components.camera.ffmpeg -ha-ffmpeg==0.4 +ha-ffmpeg==0.8 # homeassistant.components.mqtt.server hbmqtt==0.7.1 +# homeassistant.components.climate.heatmiser # homeassistant.components.thermostat.heatmiser heatmiserV3==0.9.1 @@ -104,7 +117,7 @@ heatmiserV3==0.9.1 hikvision==0.4 # homeassistant.components.sensor.dht -# http://github.com/mala-zaba/Adafruit_Python_DHT/archive/4101340de8d2457dd194bca1e8d11cbfc237e919.zip#Adafruit_DHT==1.1.0 +# http://github.com/adafruit/Adafruit_Python_DHT/archive/310c59b0293354d07d94375f1365f7b9b9110c7d.zip#Adafruit_DHT==1.3.0 # homeassistant.components.light.flux_led https://github.com/Danielhiversen/flux_led/archive/0.6.zip#flux_led==0.6 @@ -176,11 +189,14 @@ https://github.com/robbiet480/pygtfs/archive/00546724e4bbcb3053110d844ca44e22462 https://github.com/sander76/powerviewApi/archive/master.zip#powerviewApi==0.2 # homeassistant.components.mysensors -https://github.com/theolind/pymysensors/archive/cc5d0b325e13c2b623fa934f69eea7cd4555f110.zip#pymysensors==0.6 +https://github.com/theolind/pymysensors/archive/8ce98b7fb56f7921a808eb66845ce8b2c455c81e.zip#pymysensors==0.7.1 # homeassistant.components.alarm_control_panel.simplisafe https://github.com/w1ll1am23/simplisafe-python/archive/586fede0e85fd69e56e516aaa8e97eb644ca8866.zip#simplisafe-python==0.0.1 +# homeassistant.components.notify.html5 +https://github.com/web-push-libs/pywebpush/archive/e743dc92558fc62178d255c0018920d74fa778ed.zip#pywebpush==0.5.0 + # homeassistant.components.media_player.lg_netcast https://github.com/wokar/pylgnetcast/archive/v0.2.0.zip#pylgnetcast==0.2.0 @@ -188,13 +204,16 @@ https://github.com/wokar/pylgnetcast/archive/v0.2.0.zip#pylgnetcast==0.2.0 influxdb==3.0.0 # homeassistant.components.insteon_hub -insteon_hub==0.4.5 +insteon_hub==0.5.0 # homeassistant.components.media_player.kodi jsonrpc-requests==0.3 +# homeassistant.scripts.keyring +keyring>=9.3,<10.0 + # homeassistant.components.knx -knxip==0.3.2 +knxip==0.3.3 # homeassistant.components.light.lifx liffylights==0.9.4 @@ -242,9 +261,11 @@ pilight==0.0.2 # homeassistant.components.sensor.plex plexapi==2.0.2 +# homeassistant.components.sensor.mhz19 # homeassistant.components.sensor.serial_pm -pmsensor==0.2 +pmsensor==0.3 +# homeassistant.components.climate.proliphix # homeassistant.components.thermostat.proliphix proliphix==0.3.1 @@ -253,6 +274,7 @@ psutil==4.3.0 # homeassistant.components.wink # homeassistant.components.binary_sensor.wink +# homeassistant.components.cover.wink # homeassistant.components.garage_door.wink # homeassistant.components.light.wink # homeassistant.components.lock.wink @@ -271,7 +293,7 @@ pushetta==1.0.15 py-cpuinfo==0.2.3 # homeassistant.components.rfxtrx -pyRFXtrx==0.10.1 +pyRFXtrx==0.11.0 # homeassistant.components.notify.xmpp pyasn1-modules==0.0.8 @@ -299,7 +321,7 @@ pyenvisalink==1.0 pyfttt==0.3 # homeassistant.components.homematic -pyhomematic==0.1.11 +pyhomematic==0.1.13 # homeassistant.components.device_tracker.icloud pyicloud==0.9.1 @@ -333,6 +355,9 @@ pysnmp==4.3.2 # homeassistant.components.sensor.forecast python-forecastio==1.3.4 +# homeassistant.components.sensor.hp_ilo +python-hpilo==3.8 + # homeassistant.components.lirc # python-lirc==1.2.1 @@ -359,13 +384,14 @@ python-twitch==1.3.0 # homeassistant.components.wink # homeassistant.components.binary_sensor.wink +# homeassistant.components.cover.wink # homeassistant.components.garage_door.wink # homeassistant.components.light.wink # homeassistant.components.lock.wink # homeassistant.components.rollershutter.wink # homeassistant.components.sensor.wink # homeassistant.components.switch.wink -python-wink==0.7.11 +python-wink==0.7.13 # homeassistant.components.keyboard pyuserinput==0.1.9 @@ -374,8 +400,9 @@ pyuserinput==0.1.9 pyvera==0.2.15 # homeassistant.components.wemo -pywemo==0.4.5 +pywemo==0.4.6 +# homeassistant.components.climate.radiotherm # homeassistant.components.thermostat.radiotherm radiotherm==1.2 @@ -406,6 +433,7 @@ sleekxmpp==1.3.1 # homeassistant.components.media_player.snapcast snapcast==1.2.1 +# homeassistant.components.climate.honeywell # homeassistant.components.thermostat.honeywell somecomfort==0.2.1 @@ -413,6 +441,7 @@ somecomfort==0.2.1 speedtest-cli==0.3.4 # homeassistant.components.recorder +# homeassistant.scripts.db_migrator sqlalchemy==1.0.14 # homeassistant.components.http @@ -451,7 +480,7 @@ urllib3 uvcclient==0.9.0 # homeassistant.components.verisure -vsure==0.8.1 +vsure==0.10.2 # homeassistant.components.switch.wake_on_lan wakeonlan==0.2.2 @@ -467,7 +496,7 @@ xbee-helper==0.0.7 xmltodict==0.10.2 # homeassistant.components.sensor.yweather -yahooweather==0.6 +yahooweather==0.7 # homeassistant.components.zeroconf zeroconf==0.17.6 diff --git a/requirements_test.txt b/requirements_test.txt index 233856e8363..a996ef411c3 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,11 +1,10 @@ -flake8>=2.6.0 +flake8>=3.0.4 pylint>=1.5.6 -astroid>=1.4.8 coveralls>=1.1 pytest>=2.9.2 -pytest-cov>=2.2.1 +pytest-cov>=2.3.1 pytest-timeout>=1.0.0 -pytest-capturelog>=0.7 +pytest-catchlog>=1.2.2 pydocstyle>=1.0.0 requests_mock>=1.0 mypy-lang>=0.4 diff --git a/script/build_frontend b/script/build_frontend index da484a943b0..a00f89f1eea 100755 --- a/script/build_frontend +++ b/script/build_frontend @@ -10,7 +10,7 @@ npm run frontend_prod cp bower_components/webcomponentsjs/webcomponents-lite.min.js .. cp -r build/* .. -node script/sw-precache.js +BUILD_DEV=0 node script/gen-service-worker.js cp build/service_worker.js .. cd .. diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 1fae3b92600..8621cb24c95 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -14,6 +14,7 @@ COMMENT_REQUIREMENTS = ( 'pybluez', 'bluepy', 'python-lirc', + 'gattlib' ) IGNORE_PACKAGES = ( @@ -30,7 +31,7 @@ def explore_module(package, explore_children): if not hasattr(module, '__path__'): return found - for _, name, ispkg in pkgutil.iter_modules(module.__path__, package + '.'): + for _, name, _ in pkgutil.iter_modules(module.__path__, package + '.'): found.append(name) if explore_children: @@ -59,7 +60,8 @@ def gather_modules(): errors = [] output = [] - for package in sorted(explore_module('homeassistant.components', True)): + for package in sorted(explore_module('homeassistant.components', True) + + explore_module('homeassistant.scripts', True)): try: module = importlib.import_module(package) except ImportError: diff --git a/script/lint b/script/lint index 4a517ef7494..ea8d84e7b84 100755 --- a/script/lint +++ b/script/lint @@ -3,4 +3,21 @@ # NOTE: all testing is now driven through tox. The tox command below # performs roughly what this test did in the past. -tox -e lint +if [ "$1" == "--changed" ]; then + export files=`git diff upstream/dev --name-only | grep -v requirements_all.txt` + echo "=================================================" + echo "FILES CHANGED (git diff upstream/dev --name-only)" + echo "=================================================" + echo $files + echo "================" + echo "LINT with flake8" + echo "================" + flake8 --doctests $files + echo "================" + echo "LINT with pylint" + echo "================" + pylint $files + echo +else + tox -e lint +fi diff --git a/setup.cfg b/setup.cfg index b11dfac0c42..98a4f54d55d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,7 @@ [wheel] universal = 1 -[pytest] +[tool:pytest] testpaths = tests norecursedirs = .git testing_config diff --git a/tests/common.py b/tests/common.py index b0e3ef17653..5d1f485d7fe 100644 --- a/tests/common.py +++ b/tests/common.py @@ -2,12 +2,16 @@ import os from datetime import timedelta from unittest import mock +from unittest.mock import patch +from io import StringIO +import logging from homeassistant import core as ha, loader from homeassistant.bootstrap import _setup_component from homeassistant.helpers.entity import ToggleEntity from homeassistant.util.unit_system import METRIC_SYSTEM import homeassistant.util.dt as date_util +import homeassistant.util.yaml as yaml from homeassistant.const import ( STATE_ON, STATE_OFF, DEVICE_DEFAULT_NAME, EVENT_TIME_CHANGED, EVENT_STATE_CHANGED, EVENT_PLATFORM_DISCOVERED, ATTR_SERVICE, @@ -15,11 +19,12 @@ from homeassistant.const import ( from homeassistant.components import sun, mqtt _TEST_INSTANCE_PORT = SERVER_PORT +_LOGGER = logging.getLogger(__name__) -def get_test_config_dir(): +def get_test_config_dir(*add_path): """Return a path to a test config dir.""" - return os.path.join(os.path.dirname(__file__), "testing_config") + return os.path.join(os.path.dirname(__file__), "testing_config", *add_path) def get_test_home_assistant(num_threads=None): @@ -65,8 +70,7 @@ def mock_service(hass, domain, service): """ calls = [] - hass.services.register( - domain, service, lambda call: calls.append(call)) + hass.services.register(domain, service, calls.append) return calls @@ -110,8 +114,8 @@ def ensure_sun_set(hass): def load_fixture(filename): """Helper to load a fixture.""" path = os.path.join(os.path.dirname(__file__), 'fixtures', filename) - with open(path) as fp: - return fp.read() + with open(path) as fptr: + return fptr.read() def mock_state_change_event(hass, new_state, old_state=None): @@ -147,6 +151,7 @@ def mock_mqtt_component(hass, mock_mqtt): class MockModule(object): """Representation of a fake module.""" + # pylint: disable=invalid-name,too-few-public-methods,too-many-arguments def __init__(self, domain=None, dependencies=None, setup=None, requirements=None, config_schema=None, platform_schema=None): """Initialize the mock module.""" @@ -170,6 +175,7 @@ class MockModule(object): class MockPlatform(object): """Provide a fake platform.""" + # pylint: disable=invalid-name,too-few-public-methods def __init__(self, setup_platform=None, dependencies=None, platform_schema=None): """Initialize the platform.""" @@ -234,3 +240,33 @@ class MockToggleDevice(ToggleEntity): if call[0] == method) except StopIteration: return None + + +def patch_yaml_files(files_dict, endswith=True): + """Patch load_yaml with a dictionary of yaml files.""" + # match using endswith, start search with longest string + matchlist = sorted(list(files_dict.keys()), key=len) if endswith else [] + # matchlist.sort(key=len) + + def mock_open_f(fname, **_): + """Mock open() in the yaml module, used by load_yaml.""" + # Return the mocked file on full match + if fname in files_dict: + _LOGGER.debug('patch_yaml_files match %s', fname) + return StringIO(files_dict[fname]) + + # Match using endswith + for ends in matchlist: + if fname.endswith(ends): + _LOGGER.debug('patch_yaml_files end match %s: %s', ends, fname) + return StringIO(files_dict[ends]) + + # Fallback for hass.components (i.e. services.yaml) + if 'homeassistant/components' in fname: + _LOGGER.debug('patch_yaml_files using real file: %s', fname) + return open(fname, encoding='utf-8') + + # Not found + raise IOError('File not found: {}'.format(fname)) + + return patch.object(yaml, 'open', mock_open_f, create=True) diff --git a/tests/components/alarm_control_panel/test_manual.py b/tests/components/alarm_control_panel/test_manual.py index 55bb6b36646..e77180a8bc2 100644 --- a/tests/components/alarm_control_panel/test_manual.py +++ b/tests/components/alarm_control_panel/test_manual.py @@ -32,7 +32,8 @@ class TestAlarmControlPanelManual(unittest.TestCase): 'platform': 'manual', 'name': 'test', 'code': CODE, - 'pending_time': 0 + 'pending_time': 0, + 'disarm_after_trigger': False }})) entity_id = 'alarm_control_panel.test' @@ -53,7 +54,8 @@ class TestAlarmControlPanelManual(unittest.TestCase): 'platform': 'manual', 'name': 'test', 'code': CODE, - 'pending_time': 1 + 'pending_time': 1, + 'disarm_after_trigger': False }})) entity_id = 'alarm_control_panel.test' @@ -83,7 +85,8 @@ class TestAlarmControlPanelManual(unittest.TestCase): 'platform': 'manual', 'name': 'test', 'code': CODE, - 'pending_time': 1 + 'pending_time': 1, + 'disarm_after_trigger': False }})) entity_id = 'alarm_control_panel.test' @@ -104,7 +107,8 @@ class TestAlarmControlPanelManual(unittest.TestCase): 'platform': 'manual', 'name': 'test', 'code': CODE, - 'pending_time': 0 + 'pending_time': 0, + 'disarm_after_trigger': False }})) entity_id = 'alarm_control_panel.test' @@ -125,7 +129,8 @@ class TestAlarmControlPanelManual(unittest.TestCase): 'platform': 'manual', 'name': 'test', 'code': CODE, - 'pending_time': 1 + 'pending_time': 1, + 'disarm_after_trigger': False }})) entity_id = 'alarm_control_panel.test' @@ -155,7 +160,8 @@ class TestAlarmControlPanelManual(unittest.TestCase): 'platform': 'manual', 'name': 'test', 'code': CODE, - 'pending_time': 1 + 'pending_time': 1, + 'disarm_after_trigger': False }})) entity_id = 'alarm_control_panel.test' @@ -175,7 +181,8 @@ class TestAlarmControlPanelManual(unittest.TestCase): 'alarm_control_panel': { 'platform': 'manual', 'name': 'test', - 'trigger_time': 0 + 'trigger_time': 0, + 'disarm_after_trigger': False }})) entity_id = 'alarm_control_panel.test' @@ -196,7 +203,8 @@ class TestAlarmControlPanelManual(unittest.TestCase): 'platform': 'manual', 'name': 'test', 'pending_time': 2, - 'trigger_time': 3 + 'trigger_time': 3, + 'disarm_after_trigger': False }})) entity_id = 'alarm_control_panel.test' @@ -228,13 +236,45 @@ class TestAlarmControlPanelManual(unittest.TestCase): self.assertEqual(STATE_ALARM_DISARMED, self.hass.states.get(entity_id).state) + def test_trigger_with_disarm_after_trigger(self): + """Test disarm after trigger.""" + self.assertTrue(alarm_control_panel.setup(self.hass, { + 'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'trigger_time': 5, + 'pending_time': 0, + 'disarm_after_trigger': True + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.pool.block_till_done() + + self.assertEqual(STATE_ALARM_TRIGGERED, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.pool.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + def test_disarm_while_pending_trigger(self): """Test disarming while pending state.""" self.assertTrue(alarm_control_panel.setup(self.hass, { 'alarm_control_panel': { 'platform': 'manual', 'name': 'test', - 'trigger_time': 5 + 'trigger_time': 5, + 'disarm_after_trigger': False }})) entity_id = 'alarm_control_panel.test' @@ -270,7 +310,8 @@ class TestAlarmControlPanelManual(unittest.TestCase): 'platform': 'manual', 'name': 'test', 'pending_time': 5, - 'code': CODE + '2' + 'code': CODE + '2', + 'disarm_after_trigger': False }})) entity_id = 'alarm_control_panel.test' diff --git a/tests/components/binary_sensor/test_template.py b/tests/components/binary_sensor/test_template.py index 634834779d5..0f08817f15a 100644 --- a/tests/components/binary_sensor/test_template.py +++ b/tests/components/binary_sensor/test_template.py @@ -3,6 +3,7 @@ import unittest from unittest import mock from homeassistant.const import EVENT_STATE_CHANGED, MATCH_ALL +import homeassistant.bootstrap as bootstrap from homeassistant.components.binary_sensor import template from homeassistant.exceptions import TemplateError @@ -21,6 +22,7 @@ class TestBinarySensorTemplate(unittest.TestCase): 'friendly_name': 'virtual thingy', 'value_template': '{{ foo }}', 'sensor_class': 'motion', + 'entity_id': 'test' }, } } @@ -29,48 +31,61 @@ class TestBinarySensorTemplate(unittest.TestCase): result = template.setup_platform(hass, config, add_devices) self.assertTrue(result) mock_template.assert_called_once_with(hass, 'test', 'virtual thingy', - 'motion', '{{ foo }}', MATCH_ALL) + 'motion', '{{ foo }}', 'test') add_devices.assert_called_once_with([mock_template.return_value]) def test_setup_no_sensors(self): """"Test setup with no sensors.""" - config = {} - result = template.setup_platform(None, config, None) + hass = mock.MagicMock() + result = bootstrap.setup_component(hass, 'sensor', { + 'sensor': { + 'platform': 'template' + } + }) self.assertFalse(result) def test_setup_invalid_device(self): """"Test the setup with invalid devices.""" - config = { - 'sensors': { - 'foo bar': {}, - }, - } - result = template.setup_platform(None, config, None) + hass = mock.MagicMock() + result = bootstrap.setup_component(hass, 'sensor', { + 'sensor': { + 'platform': 'template', + 'sensors': { + 'foo bar': {}, + }, + } + }) self.assertFalse(result) def test_setup_invalid_sensor_class(self): """"Test setup with invalid sensor class.""" - config = { - 'sensors': { - 'test': { - 'value_template': '{{ foo }}', - 'sensor_class': 'foobarnotreal', + hass = mock.MagicMock() + result = bootstrap.setup_component(hass, 'sensor', { + 'sensor': { + 'platform': 'template', + 'sensors': { + 'test': { + 'value_template': '{{ foo }}', + 'sensor_class': 'foobarnotreal', + }, }, - }, - } - result = template.setup_platform(None, config, None) + } + }) self.assertFalse(result) def test_setup_invalid_missing_template(self): """"Test setup with invalid and missing template.""" - config = { - 'sensors': { - 'test': { - 'sensor_class': 'motion', - }, - }, - } - result = template.setup_platform(None, config, None) + hass = mock.MagicMock() + result = bootstrap.setup_component(hass, 'sensor', { + 'sensor': { + 'platform': 'template', + 'sensors': { + 'test': { + 'sensor_class': 'motion', + }, + } + } + }) self.assertFalse(result) def test_attributes(self): diff --git a/tests/components/camera/test_generic.py b/tests/components/camera/test_generic.py new file mode 100644 index 00000000000..df80b48e36b --- /dev/null +++ b/tests/components/camera/test_generic.py @@ -0,0 +1,115 @@ +"""The tests for generic camera component.""" +import unittest +from unittest import mock + +import requests_mock +from werkzeug.test import EnvironBuilder + +from homeassistant.bootstrap import setup_component +from homeassistant.components.http import request_class + +from tests.common import get_test_home_assistant + + +class TestGenericCamera(unittest.TestCase): + """Test the generic camera platform.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.hass.wsgi = mock.MagicMock() + self.hass.config.components.append('http') + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + @requests_mock.Mocker() + def test_fetching_url(self, m): + """Test that it fetches the given url.""" + self.hass.wsgi = mock.MagicMock() + m.get('http://example.com', text='hello world') + + assert setup_component(self.hass, 'camera', { + 'camera': { + 'name': 'config_test', + 'platform': 'generic', + 'still_image_url': 'http://example.com', + 'username': 'user', + 'password': 'pass' + }}) + + image_view = self.hass.wsgi.mock_calls[0][1][0] + + builder = EnvironBuilder(method='GET') + Request = request_class() + request = Request(builder.get_environ()) + request.authenticated = True + resp = image_view.get(request, 'camera.config_test') + + assert m.call_count == 1 + assert resp.status_code == 200, resp.response + assert resp.response[0].decode('utf-8') == 'hello world' + + image_view.get(request, 'camera.config_test') + assert m.call_count == 2 + + @requests_mock.Mocker() + def test_limit_refetch(self, m): + """Test that it fetches the given url.""" + self.hass.wsgi = mock.MagicMock() + from requests.exceptions import Timeout + m.get('http://example.com/5a', text='hello world') + m.get('http://example.com/10a', text='hello world') + m.get('http://example.com/15a', text='hello planet') + m.get('http://example.com/20a', status_code=404) + + assert setup_component(self.hass, 'camera', { + 'camera': { + 'name': 'config_test', + 'platform': 'generic', + 'still_image_url': + 'http://example.com/{{ states.sensor.temp.state + "a" }}', + 'limit_refetch_to_url_change': True, + }}) + + image_view = self.hass.wsgi.mock_calls[0][1][0] + + builder = EnvironBuilder(method='GET') + Request = request_class() + request = Request(builder.get_environ()) + request.authenticated = True + + self.hass.states.set('sensor.temp', '5') + + with mock.patch('requests.get', side_effect=Timeout()): + resp = image_view.get(request, 'camera.config_test') + assert m.call_count == 0 + assert resp.status_code == 500, resp.response + + self.hass.states.set('sensor.temp', '10') + + resp = image_view.get(request, 'camera.config_test') + assert m.call_count == 1 + assert resp.status_code == 200, resp.response + assert resp.response[0].decode('utf-8') == 'hello world' + + resp = image_view.get(request, 'camera.config_test') + assert m.call_count == 1 + assert resp.status_code == 200, resp.response + assert resp.response[0].decode('utf-8') == 'hello world' + + self.hass.states.set('sensor.temp', '15') + + # Url change = fetch new image + resp = image_view.get(request, 'camera.config_test') + assert m.call_count == 2 + assert resp.status_code == 200, resp.response + assert resp.response[0].decode('utf-8') == 'hello planet' + + # Cause a template render error + self.hass.states.remove('sensor.temp') + resp = image_view.get(request, 'camera.config_test') + assert m.call_count == 2 + assert resp.status_code == 200, resp.response + assert resp.response[0].decode('utf-8') == 'hello planet' diff --git a/tests/components/camera/test_local_file.py b/tests/components/camera/test_local_file.py new file mode 100644 index 00000000000..c30f59968e8 --- /dev/null +++ b/tests/components/camera/test_local_file.py @@ -0,0 +1,69 @@ +"""The tests for local file camera component.""" +from tempfile import NamedTemporaryFile +import unittest +from unittest import mock + +from werkzeug.test import EnvironBuilder + +from homeassistant.bootstrap import setup_component +from homeassistant.components.http import request_class + +from tests.common import get_test_home_assistant + + +class TestLocalCamera(unittest.TestCase): + """Test the local file camera component.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.hass.wsgi = mock.MagicMock() + self.hass.config.components.append('http') + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + def test_loading_file(self): + """Test that it loads image from disk.""" + self.hass.wsgi = mock.MagicMock() + + with NamedTemporaryFile() as fp: + fp.write('hello'.encode('utf-8')) + fp.flush() + + assert setup_component(self.hass, 'camera', { + 'camera': { + 'name': 'config_test', + 'platform': 'local_file', + 'file_path': fp.name, + }}) + + image_view = self.hass.wsgi.mock_calls[0][1][0] + + builder = EnvironBuilder(method='GET') + Request = request_class() + request = Request(builder.get_environ()) + request.authenticated = True + resp = image_view.get(request, 'camera.config_test') + + assert resp.status_code == 200, resp.response + assert resp.response[0].decode('utf-8') == 'hello' + + def test_file_not_readable(self): + """Test local file will not setup when file is not readable.""" + self.hass.wsgi = mock.MagicMock() + + with NamedTemporaryFile() as fp: + fp.write('hello'.encode('utf-8')) + fp.flush() + + with mock.patch('os.access', return_value=False): + assert setup_component(self.hass, 'camera', { + 'camera': { + 'name': 'config_test', + 'platform': 'local_file', + 'file_path': fp.name, + }}) + + assert [] == self.hass.states.all() diff --git a/tests/components/climate/__init__.py b/tests/components/climate/__init__.py new file mode 100644 index 00000000000..441c917cab7 --- /dev/null +++ b/tests/components/climate/__init__.py @@ -0,0 +1 @@ +"""The tests for climate component.""" diff --git a/tests/components/climate/test_demo.py b/tests/components/climate/test_demo.py new file mode 100644 index 00000000000..4dab359688c --- /dev/null +++ b/tests/components/climate/test_demo.py @@ -0,0 +1,166 @@ +"""The tests for the demo climate component.""" +import unittest + +from homeassistant.util.unit_system import ( + METRIC_SYSTEM, +) +from homeassistant.components import climate + +from tests.common import get_test_home_assistant + + +ENTITY_CLIMATE = 'climate.hvac' + + +class TestDemoClimate(unittest.TestCase): + """Test the demo climate hvac.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.hass.config.units = METRIC_SYSTEM + self.assertTrue(climate.setup(self.hass, {'climate': { + 'platform': 'demo', + }})) + + def tearDown(self): # pylint: disable=invalid-name + """Stop down everything that was started.""" + self.hass.stop() + + def test_setup_params(self): + """Test the inititial parameters.""" + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual(21, state.attributes.get('temperature')) + self.assertEqual('on', state.attributes.get('away_mode')) + self.assertEqual(22, state.attributes.get('current_temperature')) + self.assertEqual("On High", state.attributes.get('fan_mode')) + self.assertEqual(67, state.attributes.get('humidity')) + self.assertEqual(54, state.attributes.get('current_humidity')) + self.assertEqual("Off", state.attributes.get('swing_mode')) + self.assertEqual("Cool", state.attributes.get('operation_mode')) + self.assertEqual('off', state.attributes.get('aux_heat')) + + def test_default_setup_params(self): + """Test the setup with default parameters.""" + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual(7, state.attributes.get('min_temp')) + self.assertEqual(35, state.attributes.get('max_temp')) + self.assertEqual(30, state.attributes.get('min_humidity')) + self.assertEqual(99, state.attributes.get('max_humidity')) + + def test_set_target_temp_bad_attr(self): + """Test setting the target temperature without required attribute.""" + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual(21, state.attributes.get('temperature')) + climate.set_temperature(self.hass, None, ENTITY_CLIMATE) + self.hass.pool.block_till_done() + self.assertEqual(21, state.attributes.get('temperature')) + + def test_set_target_temp(self): + """Test the setting of the target temperature.""" + climate.set_temperature(self.hass, 30, ENTITY_CLIMATE) + self.hass.pool.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual(30.0, state.attributes.get('temperature')) + + def test_set_target_humidity_bad_attr(self): + """Test setting the target humidity without required attribute.""" + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual(67, state.attributes.get('humidity')) + climate.set_humidity(self.hass, None, ENTITY_CLIMATE) + self.hass.pool.block_till_done() + self.assertEqual(67, state.attributes.get('humidity')) + + def test_set_target_humidity(self): + """Test the setting of the target humidity.""" + climate.set_humidity(self.hass, 64, ENTITY_CLIMATE) + self.hass.pool.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual(64.0, state.attributes.get('humidity')) + + def test_set_fan_mode_bad_attr(self): + """Test setting fan mode without required attribute.""" + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("On High", state.attributes.get('fan_mode')) + climate.set_fan_mode(self.hass, None, ENTITY_CLIMATE) + self.hass.pool.block_till_done() + self.assertEqual("On High", state.attributes.get('fan_mode')) + + def test_set_fan_mode(self): + """Test setting of new fan mode.""" + climate.set_fan_mode(self.hass, "On Low", ENTITY_CLIMATE) + self.hass.pool.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("On Low", state.attributes.get('fan_mode')) + + def test_set_swing_mode_bad_attr(self): + """Test setting swing mode without required attribute.""" + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("Off", state.attributes.get('swing_mode')) + climate.set_swing_mode(self.hass, None, ENTITY_CLIMATE) + self.hass.pool.block_till_done() + self.assertEqual("Off", state.attributes.get('swing_mode')) + + def test_set_swing(self): + """Test setting of new swing mode.""" + climate.set_swing_mode(self.hass, "Auto", ENTITY_CLIMATE) + self.hass.pool.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("Auto", state.attributes.get('swing_mode')) + + def test_set_operation_bad_attr(self): + """Test setting operation mode without required attribute.""" + self.assertEqual("Cool", self.hass.states.get(ENTITY_CLIMATE).state) + climate.set_operation_mode(self.hass, None, ENTITY_CLIMATE) + self.hass.pool.block_till_done() + self.assertEqual("Cool", self.hass.states.get(ENTITY_CLIMATE).state) + + def test_set_operation(self): + """Test setting of new operation mode.""" + climate.set_operation_mode(self.hass, "Heat", ENTITY_CLIMATE) + self.hass.pool.block_till_done() + self.assertEqual("Heat", self.hass.states.get(ENTITY_CLIMATE).state) + + def test_set_away_mode_bad_attr(self): + """Test setting the away mode without required attribute.""" + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('on', state.attributes.get('away_mode')) + climate.set_away_mode(self.hass, None, ENTITY_CLIMATE) + self.hass.pool.block_till_done() + self.assertEqual('on', state.attributes.get('away_mode')) + + def test_set_away_mode_on(self): + """Test setting the away mode on/true.""" + climate.set_away_mode(self.hass, True, ENTITY_CLIMATE) + self.hass.pool.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('on', state.attributes.get('away_mode')) + + def test_set_away_mode_off(self): + """Test setting the away mode off/false.""" + climate.set_away_mode(self.hass, False, ENTITY_CLIMATE) + self.hass.pool.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('off', state.attributes.get('away_mode')) + + def test_set_aux_heat_bad_attr(self): + """Test setting the auxillary heater without required attribute.""" + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('off', state.attributes.get('aux_heat')) + climate.set_aux_heat(self.hass, None, ENTITY_CLIMATE) + self.hass.pool.block_till_done() + self.assertEqual('off', state.attributes.get('aux_heat')) + + def test_set_aux_heat_on(self): + """Test setting the axillary heater on/true.""" + climate.set_aux_heat(self.hass, True, ENTITY_CLIMATE) + self.hass.pool.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('on', state.attributes.get('aux_heat')) + + def test_set_aux_heat_off(self): + """Test setting the auxillary heater off/false.""" + climate.set_aux_heat(self.hass, False, ENTITY_CLIMATE) + self.hass.pool.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('off', state.attributes.get('aux_heat')) diff --git a/tests/components/climate/test_generic_thermostat.py b/tests/components/climate/test_generic_thermostat.py new file mode 100644 index 00000000000..5c03abdf90f --- /dev/null +++ b/tests/components/climate/test_generic_thermostat.py @@ -0,0 +1,493 @@ +"""The tests for the generic_thermostat.""" +import datetime +import unittest +from unittest import mock + + +from homeassistant.bootstrap import _setup_component +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_ON, + STATE_OFF, + TEMP_CELSIUS, +) +from homeassistant.util.unit_system import METRIC_SYSTEM +from homeassistant.components import climate + +from tests.common import get_test_home_assistant + + +ENTITY = 'climate.test' +ENT_SENSOR = 'sensor.test' +ENT_SWITCH = 'switch.test' +MIN_TEMP = 3.0 +MAX_TEMP = 65.0 +TARGET_TEMP = 42.0 + + +class TestSetupClimateGenericThermostat(unittest.TestCase): + """Test the Generic thermostat with custom config.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def tearDown(self): # pylint: disable=invalid-name + """Stop down everything that was started.""" + self.hass.stop() + + def test_setup_missing_conf(self): + """Test set up heat_control with missing config values.""" + config = { + 'name': 'test', + 'target_sensor': ENT_SENSOR + } + self.assertFalse(_setup_component(self.hass, 'climate', { + 'climate': config})) + + def test_valid_conf(self): + """Test set up genreic_thermostat with valid config values.""" + self.assertTrue(_setup_component(self.hass, 'climate', + {'climate': { + 'platform': 'generic_thermostat', + 'name': 'test', + 'heater': ENT_SWITCH, + 'target_sensor': ENT_SENSOR}})) + + def test_setup_with_sensor(self): + """Test set up heat_control with sensor to trigger update at init.""" + self.hass.states.set(ENT_SENSOR, 22.0, { + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS + }) + climate.setup(self.hass, {'climate': { + 'platform': 'generic_thermostat', + 'name': 'test', + 'heater': ENT_SWITCH, + 'target_sensor': ENT_SENSOR + }}) + state = self.hass.states.get(ENTITY) + self.assertEqual( + TEMP_CELSIUS, state.attributes.get('unit_of_measurement')) + self.assertEqual(22.0, state.attributes.get('current_temperature')) + + +class TestClimateGenericThermostat(unittest.TestCase): + """Test the Generic thermostat.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.hass.config.units = METRIC_SYSTEM + climate.setup(self.hass, {'climate': { + 'platform': 'generic_thermostat', + 'name': 'test', + 'heater': ENT_SWITCH, + 'target_sensor': ENT_SENSOR + }}) + + def tearDown(self): # pylint: disable=invalid-name + """Stop down everything that was started.""" + self.hass.stop() + + def test_setup_defaults_to_unknown(self): + """Test the setting of defaults to unknown.""" + self.assertEqual('unknown', self.hass.states.get(ENTITY).state) + + def test_default_setup_params(self): + """Test the setup with default parameters.""" + state = self.hass.states.get(ENTITY) + self.assertEqual(7, state.attributes.get('min_temp')) + self.assertEqual(35, state.attributes.get('max_temp')) + self.assertEqual(None, state.attributes.get('temperature')) + + def test_custom_setup_params(self): + """Test the setup with custom parameters.""" + climate.setup(self.hass, {'climate': { + 'platform': 'generic_thermostat', + 'name': 'test', + 'heater': ENT_SWITCH, + 'target_sensor': ENT_SENSOR, + 'min_temp': MIN_TEMP, + 'max_temp': MAX_TEMP, + 'target_temp': TARGET_TEMP + }}) + state = self.hass.states.get(ENTITY) + self.assertEqual(MIN_TEMP, state.attributes.get('min_temp')) + self.assertEqual(MAX_TEMP, state.attributes.get('max_temp')) + self.assertEqual(TARGET_TEMP, state.attributes.get('temperature')) + + def test_set_target_temp(self): + """Test the setting of the target temperature.""" + climate.set_temperature(self.hass, 30) + self.hass.pool.block_till_done() + state = self.hass.states.get(ENTITY) + self.assertEqual(30.0, state.attributes.get('temperature')) + + def test_sensor_bad_unit(self): + """Test sensor that have bad unit.""" + state = self.hass.states.get(ENTITY) + temp = state.attributes.get('current_temperature') + unit = state.attributes.get('unit_of_measurement') + + self._setup_sensor(22.0, unit='bad_unit') + self.hass.pool.block_till_done() + + state = self.hass.states.get(ENTITY) + self.assertEqual(unit, state.attributes.get('unit_of_measurement')) + self.assertEqual(temp, state.attributes.get('current_temperature')) + + def test_sensor_bad_value(self): + """Test sensor that have None as state.""" + state = self.hass.states.get(ENTITY) + temp = state.attributes.get('current_temperature') + unit = state.attributes.get('unit_of_measurement') + + self._setup_sensor(None) + self.hass.pool.block_till_done() + + state = self.hass.states.get(ENTITY) + self.assertEqual(unit, state.attributes.get('unit_of_measurement')) + self.assertEqual(temp, state.attributes.get('current_temperature')) + + def test_set_target_temp_heater_on(self): + """Test if target temperature turn heater on.""" + self._setup_switch(False) + self._setup_sensor(25) + self.hass.pool.block_till_done() + climate.set_temperature(self.hass, 30) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_ON, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def test_set_target_temp_heater_off(self): + """Test if target temperature turn heater off.""" + self._setup_switch(True) + self._setup_sensor(30) + self.hass.pool.block_till_done() + climate.set_temperature(self.hass, 25) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_OFF, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def test_set_temp_change_heater_on(self): + """Test if temperature change turn heater on.""" + self._setup_switch(False) + climate.set_temperature(self.hass, 30) + self.hass.pool.block_till_done() + self._setup_sensor(25) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_ON, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def test_temp_change_heater_off(self): + """Test if temperature change turn heater off.""" + self._setup_switch(True) + climate.set_temperature(self.hass, 25) + self.hass.pool.block_till_done() + self._setup_sensor(30) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_OFF, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def _setup_sensor(self, temp, unit=TEMP_CELSIUS): + """Setup the test sensor.""" + self.hass.states.set(ENT_SENSOR, temp, { + ATTR_UNIT_OF_MEASUREMENT: unit + }) + + def _setup_switch(self, is_on): + """Setup the test switch.""" + self.hass.states.set(ENT_SWITCH, STATE_ON if is_on else STATE_OFF) + self.calls = [] + + def log_call(call): + """Log service calls.""" + self.calls.append(call) + + self.hass.services.register('switch', SERVICE_TURN_ON, log_call) + self.hass.services.register('switch', SERVICE_TURN_OFF, log_call) + + +class TestClimateGenericThermostatACMode(unittest.TestCase): + """Test the Generic thermostat.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.hass.config.temperature_unit = TEMP_CELSIUS + climate.setup(self.hass, {'climate': { + 'platform': 'generic_thermostat', + 'name': 'test', + 'heater': ENT_SWITCH, + 'target_sensor': ENT_SENSOR, + 'ac_mode': True + }}) + + def tearDown(self): # pylint: disable=invalid-name + """Stop down everything that was started.""" + self.hass.stop() + + def test_set_target_temp_ac_off(self): + """Test if target temperature turn ac off.""" + self._setup_switch(True) + self._setup_sensor(25) + self.hass.pool.block_till_done() + climate.set_temperature(self.hass, 30) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_OFF, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def test_set_target_temp_ac_on(self): + """Test if target temperature turn ac on.""" + self._setup_switch(False) + self._setup_sensor(30) + self.hass.pool.block_till_done() + climate.set_temperature(self.hass, 25) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_ON, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def test_set_temp_change_ac_off(self): + """Test if temperature change turn ac off.""" + self._setup_switch(True) + climate.set_temperature(self.hass, 30) + self.hass.pool.block_till_done() + self._setup_sensor(25) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_OFF, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def test_temp_change_ac_on(self): + """Test if temperature change turn ac on.""" + self._setup_switch(False) + climate.set_temperature(self.hass, 25) + self.hass.pool.block_till_done() + self._setup_sensor(30) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_ON, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def _setup_sensor(self, temp, unit=TEMP_CELSIUS): + """Setup the test sensor.""" + self.hass.states.set(ENT_SENSOR, temp, { + ATTR_UNIT_OF_MEASUREMENT: unit + }) + + def _setup_switch(self, is_on): + """Setup the test switch.""" + self.hass.states.set(ENT_SWITCH, STATE_ON if is_on else STATE_OFF) + self.calls = [] + + def log_call(call): + """Log service calls.""" + self.calls.append(call) + + self.hass.services.register('switch', SERVICE_TURN_ON, log_call) + self.hass.services.register('switch', SERVICE_TURN_OFF, log_call) + + +class TestClimateGenericThermostatACModeMinCycle(unittest.TestCase): + """Test the Generic Thermostat.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.hass.config.temperature_unit = TEMP_CELSIUS + climate.setup(self.hass, {'climate': { + 'platform': 'generic_thermostat', + 'name': 'test', + 'heater': ENT_SWITCH, + 'target_sensor': ENT_SENSOR, + 'ac_mode': True, + 'min_cycle_duration': datetime.timedelta(minutes=10) + }}) + + def tearDown(self): # pylint: disable=invalid-name + """Stop down everything that was started.""" + self.hass.stop() + + def test_temp_change_ac_trigger_on_not_long_enough(self): + """Test if temperature change turn ac on.""" + self._setup_switch(False) + climate.set_temperature(self.hass, 25) + self.hass.pool.block_till_done() + self._setup_sensor(30) + self.hass.pool.block_till_done() + self.assertEqual(0, len(self.calls)) + + def test_temp_change_ac_trigger_on_long_enough(self): + """Test if temperature change turn ac on.""" + fake_changed = datetime.datetime(1918, 11, 11, 11, 11, 11, + tzinfo=datetime.timezone.utc) + with mock.patch('homeassistant.helpers.condition.dt_util.utcnow', + return_value=fake_changed): + self._setup_switch(False) + climate.set_temperature(self.hass, 25) + self.hass.pool.block_till_done() + self._setup_sensor(30) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_ON, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def test_temp_change_ac_trigger_off_not_long_enough(self): + """Test if temperature change turn ac on.""" + self._setup_switch(True) + climate.set_temperature(self.hass, 30) + self.hass.pool.block_till_done() + self._setup_sensor(25) + self.hass.pool.block_till_done() + self.assertEqual(0, len(self.calls)) + + def test_temp_change_ac_trigger_off_long_enough(self): + """Test if temperature change turn ac on.""" + fake_changed = datetime.datetime(1918, 11, 11, 11, 11, 11, + tzinfo=datetime.timezone.utc) + with mock.patch('homeassistant.helpers.condition.dt_util.utcnow', + return_value=fake_changed): + self._setup_switch(True) + climate.set_temperature(self.hass, 30) + self.hass.pool.block_till_done() + self._setup_sensor(25) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_OFF, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def _setup_sensor(self, temp, unit=TEMP_CELSIUS): + """Setup the test sensor.""" + self.hass.states.set(ENT_SENSOR, temp, { + ATTR_UNIT_OF_MEASUREMENT: unit + }) + + def _setup_switch(self, is_on): + """Setup the test switch.""" + self.hass.states.set(ENT_SWITCH, STATE_ON if is_on else STATE_OFF) + self.calls = [] + + def log_call(call): + """Log service calls.""" + self.calls.append(call) + + self.hass.services.register('switch', SERVICE_TURN_ON, log_call) + self.hass.services.register('switch', SERVICE_TURN_OFF, log_call) + + +class TestClimateGenericThermostatMinCycle(unittest.TestCase): + """Test the Generic thermostat.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.hass.config.temperature_unit = TEMP_CELSIUS + climate.setup(self.hass, {'climate': { + 'platform': 'generic_thermostat', + 'name': 'test', + 'heater': ENT_SWITCH, + 'target_sensor': ENT_SENSOR, + 'min_cycle_duration': datetime.timedelta(minutes=10) + }}) + + def tearDown(self): # pylint: disable=invalid-name + """Stop down everything that was started.""" + self.hass.stop() + + def test_temp_change_heater_trigger_off_not_long_enough(self): + """Test if temp change doesn't turn heater off because of time.""" + self._setup_switch(True) + climate.set_temperature(self.hass, 25) + self.hass.pool.block_till_done() + self._setup_sensor(30) + self.hass.pool.block_till_done() + self.assertEqual(0, len(self.calls)) + + def test_temp_change_heater_trigger_on_not_long_enough(self): + """Test if temp change doesn't turn heater on because of time.""" + self._setup_switch(False) + climate.set_temperature(self.hass, 30) + self.hass.pool.block_till_done() + self._setup_sensor(25) + self.hass.pool.block_till_done() + self.assertEqual(0, len(self.calls)) + + def test_temp_change_heater_trigger_on_long_enough(self): + """Test if temperature change turn heater on after min cycle.""" + fake_changed = datetime.datetime(1918, 11, 11, 11, 11, 11, + tzinfo=datetime.timezone.utc) + with mock.patch('homeassistant.helpers.condition.dt_util.utcnow', + return_value=fake_changed): + self._setup_switch(False) + climate.set_temperature(self.hass, 30) + self.hass.pool.block_till_done() + self._setup_sensor(25) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_ON, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def test_temp_change_heater_trigger_off_long_enough(self): + """Test if temperature change turn heater off after min cycle.""" + fake_changed = datetime.datetime(1918, 11, 11, 11, 11, 11, + tzinfo=datetime.timezone.utc) + with mock.patch('homeassistant.helpers.condition.dt_util.utcnow', + return_value=fake_changed): + self._setup_switch(True) + climate.set_temperature(self.hass, 25) + self.hass.pool.block_till_done() + self._setup_sensor(30) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_OFF, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def _setup_sensor(self, temp, unit=TEMP_CELSIUS): + """Setup the test sensor.""" + self.hass.states.set(ENT_SENSOR, temp, { + ATTR_UNIT_OF_MEASUREMENT: unit + }) + + def _setup_switch(self, is_on): + """Setup the test switch.""" + self.hass.states.set(ENT_SWITCH, STATE_ON if is_on else STATE_OFF) + self.calls = [] + + def log_call(call): + """Log service calls.""" + self.calls.append(call) + + self.hass.services.register('switch', SERVICE_TURN_ON, log_call) + self.hass.services.register('switch', SERVICE_TURN_OFF, log_call) diff --git a/tests/components/climate/test_honeywell.py b/tests/components/climate/test_honeywell.py new file mode 100644 index 00000000000..6c97b65dea7 --- /dev/null +++ b/tests/components/climate/test_honeywell.py @@ -0,0 +1,377 @@ +"""The test the Honeywell thermostat module.""" +import socket +import unittest +from unittest import mock + +import somecomfort + +from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, + TEMP_CELSIUS, TEMP_FAHRENHEIT) +import homeassistant.components.climate.honeywell as honeywell + + +class TestHoneywell(unittest.TestCase): + """A test class for Honeywell themostats.""" + + @mock.patch('somecomfort.SomeComfort') + @mock.patch('homeassistant.components.climate.' + 'honeywell.HoneywellUSThermostat') + def test_setup_us(self, mock_ht, mock_sc): + """Test for the US setup.""" + config = { + CONF_USERNAME: 'user', + CONF_PASSWORD: 'pass', + 'region': 'us', + } + bad_pass_config = { + CONF_USERNAME: 'user', + 'region': 'us', + } + bad_region_config = { + CONF_USERNAME: 'user', + CONF_PASSWORD: 'pass', + 'region': 'un', + } + hass = mock.MagicMock() + add_devices = mock.MagicMock() + + locations = [ + mock.MagicMock(), + mock.MagicMock(), + ] + devices_1 = [mock.MagicMock()] + devices_2 = [mock.MagicMock(), mock.MagicMock] + mock_sc.return_value.locations_by_id.values.return_value = \ + locations + locations[0].devices_by_id.values.return_value = devices_1 + locations[1].devices_by_id.values.return_value = devices_2 + + result = honeywell.setup_platform(hass, bad_pass_config, add_devices) + self.assertFalse(result) + result = honeywell.setup_platform(hass, bad_region_config, add_devices) + self.assertFalse(result) + result = honeywell.setup_platform(hass, config, add_devices) + self.assertTrue(result) + mock_sc.assert_called_once_with('user', 'pass') + mock_ht.assert_has_calls([ + mock.call(mock_sc.return_value, devices_1[0]), + mock.call(mock_sc.return_value, devices_2[0]), + mock.call(mock_sc.return_value, devices_2[1]), + ]) + + @mock.patch('somecomfort.SomeComfort') + def test_setup_us_failures(self, mock_sc): + """Test the US setup.""" + hass = mock.MagicMock() + add_devices = mock.MagicMock() + config = { + CONF_USERNAME: 'user', + CONF_PASSWORD: 'pass', + 'region': 'us', + } + + mock_sc.side_effect = somecomfort.AuthError + result = honeywell.setup_platform(hass, config, add_devices) + self.assertFalse(result) + self.assertFalse(add_devices.called) + + mock_sc.side_effect = somecomfort.SomeComfortError + result = honeywell.setup_platform(hass, config, add_devices) + self.assertFalse(result) + self.assertFalse(add_devices.called) + + @mock.patch('somecomfort.SomeComfort') + @mock.patch('homeassistant.components.climate.' + 'honeywell.HoneywellUSThermostat') + def _test_us_filtered_devices(self, mock_ht, mock_sc, loc=None, dev=None): + """Test for US filtered thermostats.""" + config = { + CONF_USERNAME: 'user', + CONF_PASSWORD: 'pass', + 'region': 'us', + 'location': loc, + 'thermostat': dev, + } + locations = { + 1: mock.MagicMock(locationid=mock.sentinel.loc1, + devices_by_id={ + 11: mock.MagicMock( + deviceid=mock.sentinel.loc1dev1), + 12: mock.MagicMock( + deviceid=mock.sentinel.loc1dev2), + }), + 2: mock.MagicMock(locationid=mock.sentinel.loc2, + devices_by_id={ + 21: mock.MagicMock( + deviceid=mock.sentinel.loc2dev1), + }), + 3: mock.MagicMock(locationid=mock.sentinel.loc3, + devices_by_id={ + 31: mock.MagicMock( + deviceid=mock.sentinel.loc3dev1), + }), + } + mock_sc.return_value = mock.MagicMock(locations_by_id=locations) + hass = mock.MagicMock() + add_devices = mock.MagicMock() + self.assertEqual(True, + honeywell.setup_platform(hass, config, add_devices)) + + return mock_ht.call_args_list, mock_sc + + def test_us_filtered_thermostat_1(self): + """Test for US filtered thermostats.""" + result, client = self._test_us_filtered_devices( + dev=mock.sentinel.loc1dev1) + devices = [x[0][1].deviceid for x in result] + self.assertEqual([mock.sentinel.loc1dev1], devices) + + def test_us_filtered_thermostat_2(self): + """Test for US filtered location.""" + result, client = self._test_us_filtered_devices( + dev=mock.sentinel.loc2dev1) + devices = [x[0][1].deviceid for x in result] + self.assertEqual([mock.sentinel.loc2dev1], devices) + + def test_us_filtered_location_1(self): + """Test for US filtered locations.""" + result, client = self._test_us_filtered_devices( + loc=mock.sentinel.loc1) + devices = [x[0][1].deviceid for x in result] + self.assertEqual([mock.sentinel.loc1dev1, + mock.sentinel.loc1dev2], devices) + + def test_us_filtered_location_2(self): + """Test for US filtered locations.""" + result, client = self._test_us_filtered_devices( + loc=mock.sentinel.loc2) + devices = [x[0][1].deviceid for x in result] + self.assertEqual([mock.sentinel.loc2dev1], devices) + + @mock.patch('evohomeclient.EvohomeClient') + @mock.patch('homeassistant.components.climate.honeywell.' + 'RoundThermostat') + def test_eu_setup_full_config(self, mock_round, mock_evo): + """Test the EU setup wwith complete configuration.""" + config = { + CONF_USERNAME: 'user', + CONF_PASSWORD: 'pass', + honeywell.CONF_AWAY_TEMP: 20, + 'region': 'eu', + } + mock_evo.return_value.temperatures.return_value = [ + {'id': 'foo'}, {'id': 'bar'}] + hass = mock.MagicMock() + add_devices = mock.MagicMock() + self.assertTrue(honeywell.setup_platform(hass, config, add_devices)) + mock_evo.assert_called_once_with('user', 'pass') + mock_evo.return_value.temperatures.assert_called_once_with( + force_refresh=True) + mock_round.assert_has_calls([ + mock.call(mock_evo.return_value, 'foo', True, 20), + mock.call(mock_evo.return_value, 'bar', False, 20), + ]) + self.assertEqual(2, add_devices.call_count) + + @mock.patch('evohomeclient.EvohomeClient') + @mock.patch('homeassistant.components.climate.honeywell.' + 'RoundThermostat') + def test_eu_setup_partial_config(self, mock_round, mock_evo): + """Test the EU setup with partial configuration.""" + config = { + CONF_USERNAME: 'user', + CONF_PASSWORD: 'pass', + 'region': 'eu', + } + mock_evo.return_value.temperatures.return_value = [ + {'id': 'foo'}, {'id': 'bar'}] + hass = mock.MagicMock() + add_devices = mock.MagicMock() + self.assertTrue(honeywell.setup_platform(hass, config, add_devices)) + default = honeywell.DEFAULT_AWAY_TEMP + mock_round.assert_has_calls([ + mock.call(mock_evo.return_value, 'foo', True, default), + mock.call(mock_evo.return_value, 'bar', False, default), + ]) + + @mock.patch('evohomeclient.EvohomeClient') + @mock.patch('homeassistant.components.climate.honeywell.' + 'RoundThermostat') + def test_eu_setup_bad_temp(self, mock_round, mock_evo): + """Test the EU setup with invalid temperature.""" + config = { + CONF_USERNAME: 'user', + CONF_PASSWORD: 'pass', + honeywell.CONF_AWAY_TEMP: 'ponies', + 'region': 'eu', + } + self.assertFalse(honeywell.setup_platform(None, config, None)) + + @mock.patch('evohomeclient.EvohomeClient') + @mock.patch('homeassistant.components.climate.honeywell.' + 'RoundThermostat') + def test_eu_setup_error(self, mock_round, mock_evo): + """Test the EU setup with errors.""" + config = { + CONF_USERNAME: 'user', + CONF_PASSWORD: 'pass', + honeywell.CONF_AWAY_TEMP: 20, + 'region': 'eu', + } + mock_evo.return_value.temperatures.side_effect = socket.error + add_devices = mock.MagicMock() + hass = mock.MagicMock() + self.assertFalse(honeywell.setup_platform(hass, config, add_devices)) + + +class TestHoneywellRound(unittest.TestCase): + """A test class for Honeywell Round thermostats.""" + + def setup_method(self, method): + """Test the setup method.""" + def fake_temperatures(force_refresh=None): + """Create fake temperatures.""" + temps = [ + {'id': '1', 'temp': 20, 'setpoint': 21, + 'thermostat': 'main', 'name': 'House'}, + {'id': '2', 'temp': 21, 'setpoint': 22, + 'thermostat': 'DOMESTIC_HOT_WATER'}, + ] + return temps + + self.device = mock.MagicMock() + self.device.temperatures.side_effect = fake_temperatures + self.round1 = honeywell.RoundThermostat(self.device, '1', + True, 16) + self.round2 = honeywell.RoundThermostat(self.device, '2', + False, 17) + + def test_attributes(self): + """Test the attributes.""" + self.assertEqual('House', self.round1.name) + self.assertEqual(TEMP_CELSIUS, self.round1.unit_of_measurement) + self.assertEqual(20, self.round1.current_temperature) + self.assertEqual(21, self.round1.target_temperature) + self.assertFalse(self.round1.is_away_mode_on) + + self.assertEqual('Hot Water', self.round2.name) + self.assertEqual(TEMP_CELSIUS, self.round2.unit_of_measurement) + self.assertEqual(21, self.round2.current_temperature) + self.assertEqual(None, self.round2.target_temperature) + self.assertFalse(self.round2.is_away_mode_on) + + def test_away_mode(self): + """Test setting the away mode.""" + self.assertFalse(self.round1.is_away_mode_on) + self.round1.turn_away_mode_on() + self.assertTrue(self.round1.is_away_mode_on) + self.device.set_temperature.assert_called_once_with('House', 16) + + self.device.set_temperature.reset_mock() + self.round1.turn_away_mode_off() + self.assertFalse(self.round1.is_away_mode_on) + self.device.cancel_temp_override.assert_called_once_with('House') + + def test_set_temperature(self): + """Test setting the temperature.""" + self.round1.set_temperature(25) + self.device.set_temperature.assert_called_once_with('House', 25) + + def test_set_operation_mode(self: unittest.TestCase) -> None: + """Test setting the system operation.""" + self.round1.set_operation_mode('cool') + self.assertEqual('cool', self.round1.current_operation) + self.assertEqual('cool', self.device.system_mode) + + self.round1.set_operation_mode('heat') + self.assertEqual('heat', self.round1.current_operation) + self.assertEqual('heat', self.device.system_mode) + + +class TestHoneywellUS(unittest.TestCase): + """A test class for Honeywell US thermostats.""" + + def setup_method(self, method): + """Test the setup method.""" + self.client = mock.MagicMock() + self.device = mock.MagicMock() + self.honeywell = honeywell.HoneywellUSThermostat( + self.client, self.device) + + self.device.fan_running = True + self.device.name = 'test' + self.device.temperature_unit = 'F' + self.device.current_temperature = 72 + self.device.setpoint_cool = 78 + self.device.setpoint_heat = 65 + self.device.system_mode = 'heat' + self.device.fan_mode = 'auto' + + def test_properties(self): + """Test the properties.""" + self.assertTrue(self.honeywell.is_fan_on) + self.assertEqual('test', self.honeywell.name) + self.assertEqual(72, self.honeywell.current_temperature) + + def test_unit_of_measurement(self): + """Test the unit of measurement.""" + self.assertEqual(TEMP_FAHRENHEIT, self.honeywell.unit_of_measurement) + self.device.temperature_unit = 'C' + self.assertEqual(TEMP_CELSIUS, self.honeywell.unit_of_measurement) + + def test_target_temp(self): + """Test the target temperature.""" + self.assertEqual(65, self.honeywell.target_temperature) + self.device.system_mode = 'cool' + self.assertEqual(78, self.honeywell.target_temperature) + + def test_set_temp(self): + """Test setting the temperature.""" + self.honeywell.set_temperature(70) + self.assertEqual(70, self.device.setpoint_heat) + self.assertEqual(70, self.honeywell.target_temperature) + + self.device.system_mode = 'cool' + self.assertEqual(78, self.honeywell.target_temperature) + self.honeywell.set_temperature(74) + self.assertEqual(74, self.device.setpoint_cool) + self.assertEqual(74, self.honeywell.target_temperature) + + def test_set_operation_mode(self: unittest.TestCase) -> None: + """Test setting the operation mode.""" + self.honeywell.set_operation_mode('cool') + self.assertEqual('cool', self.honeywell.current_operation) + self.assertEqual('cool', self.device.system_mode) + + self.honeywell.set_operation_mode('heat') + self.assertEqual('heat', self.honeywell.current_operation) + self.assertEqual('heat', self.device.system_mode) + + def test_set_temp_fail(self): + """Test if setting the temperature fails.""" + self.device.setpoint_heat = mock.MagicMock( + side_effect=somecomfort.SomeComfortError) + self.honeywell.set_temperature(123) + + def test_attributes(self): + """Test the attributes.""" + expected = { + 'fan': 'running', + 'fanmode': 'auto', + 'system_mode': 'heat', + } + self.assertEqual(expected, self.honeywell.device_state_attributes) + expected['fan'] = 'idle' + self.device.fan_running = False + self.assertEqual(expected, self.honeywell.device_state_attributes) + + def test_with_no_fan(self): + """Test if there is on fan.""" + self.device.fan_running = False + self.device.fan_mode = None + expected = { + 'fan': 'idle', + 'fanmode': None, + 'system_mode': 'heat', + } + self.assertEqual(expected, self.honeywell.device_state_attributes) diff --git a/tests/components/cover/test_command_line.py b/tests/components/cover/test_command_line.py new file mode 100644 index 00000000000..bab0137f4f8 --- /dev/null +++ b/tests/components/cover/test_command_line.py @@ -0,0 +1,84 @@ +"""The tests the cover command line platform.""" + +import os +import tempfile +import unittest +from unittest import mock + +import homeassistant.core as ha +import homeassistant.components.cover as cover +from homeassistant.components.cover import ( + command_line as cmd_rs) + + +class TestCommandCover(unittest.TestCase): + """Test the cover command line platform.""" + + def setup_method(self, method): + """Setup things to be run when tests are started.""" + self.hass = ha.HomeAssistant() + self.hass.config.latitude = 32.87336 + self.hass.config.longitude = 117.22743 + self.rs = cmd_rs.CommandCover(self.hass, 'foo', + 'cmd_open', 'cmd_close', + 'cmd_stop', 'cmd_state', + None) # FIXME + + def teardown_method(self, method): + """Stop down everything that was started.""" + self.hass.stop() + + def test_should_poll(self): + """Test the setting of polling.""" + self.assertTrue(self.rs.should_poll) + self.rs._command_state = None + self.assertFalse(self.rs.should_poll) + + def test_query_state_value(self): + """Test with state value.""" + with mock.patch('subprocess.check_output') as mock_run: + mock_run.return_value = b' foo bar ' + result = self.rs._query_state_value('runme') + self.assertEqual('foo bar', result) + mock_run.assert_called_once_with('runme', shell=True) + + def test_state_value(self): + """Test with state value.""" + with tempfile.TemporaryDirectory() as tempdirname: + path = os.path.join(tempdirname, 'cover_status') + test_cover = { + 'statecmd': 'cat {}'.format(path), + 'opencmd': 'echo 1 > {}'.format(path), + 'closecmd': 'echo 1 > {}'.format(path), + 'stopcmd': 'echo 0 > {}'.format(path), + 'value_template': '{{ value }}' + } + self.assertTrue(cover.setup(self.hass, { + 'cover': { + 'platform': 'command_line', + 'covers': { + 'test': test_cover + } + } + })) + + state = self.hass.states.get('cover.test') + self.assertEqual('unknown', state.state) + + cover.open_cover(self.hass, 'cover.test') + self.hass.pool.block_till_done() + + state = self.hass.states.get('cover.test') + self.assertEqual('open', state.state) + + cover.close_cover(self.hass, 'cover.test') + self.hass.pool.block_till_done() + + state = self.hass.states.get('cover.test') + self.assertEqual('open', state.state) + + cover.stop_cover(self.hass, 'cover.test') + self.hass.pool.block_till_done() + + state = self.hass.states.get('cover.test') + self.assertEqual('closed', state.state) diff --git a/tests/components/cover/test_demo.py b/tests/components/cover/test_demo.py new file mode 100644 index 00000000000..d7431f8fcbb --- /dev/null +++ b/tests/components/cover/test_demo.py @@ -0,0 +1,138 @@ +"""The tests for the Demo cover platform.""" +import unittest +from datetime import timedelta +import homeassistant.util.dt as dt_util + +from homeassistant.components import cover +from tests.common import get_test_home_assistant, fire_time_changed + +ENTITY_COVER = 'cover.living_room_window' + + +class TestCoverDemo(unittest.TestCase): + """Test the Demo cover.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.assertTrue(cover.setup(self.hass, {'cover': { + 'platform': 'demo', + }})) + + def tearDown(self): # pylint: disable=invalid-name + """Stop down everything that was started.""" + self.hass.stop() + + def test_close_cover(self): + """Test closing the cover.""" + state = self.hass.states.get(ENTITY_COVER) + self.assertEqual(70, state.attributes.get('current_position')) + cover.close_cover(self.hass, ENTITY_COVER) + self.hass.pool.block_till_done() + for _ in range(7): + future = dt_util.utcnow() + timedelta(seconds=1) + fire_time_changed(self.hass, future) + self.hass.pool.block_till_done() + + state = self.hass.states.get(ENTITY_COVER) + self.assertEqual(0, state.attributes.get('current_position')) + + def test_open_cover(self): + """Test opening the cover.""" + state = self.hass.states.get(ENTITY_COVER) + self.assertEqual(70, state.attributes.get('current_position')) + cover.open_cover(self.hass, ENTITY_COVER) + self.hass.pool.block_till_done() + for _ in range(7): + future = dt_util.utcnow() + timedelta(seconds=1) + fire_time_changed(self.hass, future) + self.hass.pool.block_till_done() + + state = self.hass.states.get(ENTITY_COVER) + self.assertEqual(100, state.attributes.get('current_position')) + + def test_set_cover_position(self): + """Test moving the cover to a specific position.""" + state = self.hass.states.get(ENTITY_COVER) + self.assertEqual(70, state.attributes.get('current_position')) + cover.set_cover_position(self.hass, 10, ENTITY_COVER) + self.hass.pool.block_till_done() + for _ in range(6): + future = dt_util.utcnow() + timedelta(seconds=1) + fire_time_changed(self.hass, future) + self.hass.pool.block_till_done() + + state = self.hass.states.get(ENTITY_COVER) + self.assertEqual(10, state.attributes.get('current_position')) + + def test_stop_cover(self): + """Test stopping the cover.""" + state = self.hass.states.get(ENTITY_COVER) + self.assertEqual(70, state.attributes.get('current_position')) + cover.open_cover(self.hass, ENTITY_COVER) + self.hass.pool.block_till_done() + future = dt_util.utcnow() + timedelta(seconds=1) + fire_time_changed(self.hass, future) + self.hass.pool.block_till_done() + cover.stop_cover(self.hass, ENTITY_COVER) + self.hass.pool.block_till_done() + fire_time_changed(self.hass, future) + state = self.hass.states.get(ENTITY_COVER) + self.assertEqual(80, state.attributes.get('current_position')) + + def test_close_cover_tilt(self): + """Test closing the cover tilt.""" + state = self.hass.states.get(ENTITY_COVER) + self.assertEqual(50, state.attributes.get('current_tilt_position')) + cover.close_cover_tilt(self.hass, ENTITY_COVER) + self.hass.pool.block_till_done() + for _ in range(7): + future = dt_util.utcnow() + timedelta(seconds=1) + fire_time_changed(self.hass, future) + self.hass.pool.block_till_done() + + state = self.hass.states.get(ENTITY_COVER) + self.assertEqual(0, state.attributes.get('current_tilt_position')) + + def test_open_cover_tilt(self): + """Test opening the cover tilt.""" + state = self.hass.states.get(ENTITY_COVER) + self.assertEqual(50, state.attributes.get('current_tilt_position')) + cover.open_cover_tilt(self.hass, ENTITY_COVER) + self.hass.pool.block_till_done() + for _ in range(7): + future = dt_util.utcnow() + timedelta(seconds=1) + fire_time_changed(self.hass, future) + self.hass.pool.block_till_done() + + state = self.hass.states.get(ENTITY_COVER) + self.assertEqual(100, state.attributes.get('current_tilt_position')) + + def test_set_cover_tilt_position(self): + """Test moving the cover til to a specific position.""" + state = self.hass.states.get(ENTITY_COVER) + self.assertEqual(50, state.attributes.get('current_tilt_position')) + cover.set_cover_tilt_position(self.hass, 90, ENTITY_COVER) + self.hass.pool.block_till_done() + for _ in range(7): + future = dt_util.utcnow() + timedelta(seconds=1) + fire_time_changed(self.hass, future) + self.hass.pool.block_till_done() + + state = self.hass.states.get(ENTITY_COVER) + self.assertEqual(90, state.attributes.get('current_tilt_position')) + + def test_stop_cover_tilt(self): + """Test stopping the cover tilt.""" + state = self.hass.states.get(ENTITY_COVER) + self.assertEqual(50, state.attributes.get('current_tilt_position')) + cover.close_cover_tilt(self.hass, ENTITY_COVER) + self.hass.pool.block_till_done() + future = dt_util.utcnow() + timedelta(seconds=1) + fire_time_changed(self.hass, future) + self.hass.pool.block_till_done() + cover.stop_cover_tilt(self.hass, ENTITY_COVER) + self.hass.pool.block_till_done() + fire_time_changed(self.hass, future) + state = self.hass.states.get(ENTITY_COVER) + self.assertEqual(40, state.attributes.get('current_tilt_position')) diff --git a/tests/components/cover/test_mqtt.py b/tests/components/cover/test_mqtt.py new file mode 100644 index 00000000000..e2bc008f3d7 --- /dev/null +++ b/tests/components/cover/test_mqtt.py @@ -0,0 +1,174 @@ +"""The tests for the MQTT cover platform.""" +import unittest + +from homeassistant.bootstrap import _setup_component +from homeassistant.const import STATE_OPEN, STATE_CLOSED, STATE_UNKNOWN +import homeassistant.components.cover as cover +from tests.common import mock_mqtt_component, fire_mqtt_message + +from tests.common import get_test_home_assistant + + +class TestCoverMQTT(unittest.TestCase): + """Test the MQTT cover.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.mock_publish = mock_mqtt_component(self.hass) + + def tearDown(self): # pylint: disable=invalid-name + """Stop down everything that was started.""" + self.hass.stop() + + def test_controlling_state_via_topic(self): + """Test the controlling state via topic.""" + self.hass.config.components = ['mqtt'] + assert _setup_component(self.hass, cover.DOMAIN, { + cover.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'command_topic': 'command-topic', + 'qos': 0, + 'payload_open': 'OPEN', + 'payload_close': 'CLOSE', + 'payload_stop': 'STOP' + } + }) + + state = self.hass.states.get('cover.test') + self.assertEqual(STATE_UNKNOWN, state.state) + + fire_mqtt_message(self.hass, 'state-topic', '0') + self.hass.pool.block_till_done() + + state = self.hass.states.get('cover.test') + self.assertEqual(STATE_CLOSED, state.state) + + fire_mqtt_message(self.hass, 'state-topic', '50') + self.hass.pool.block_till_done() + + state = self.hass.states.get('cover.test') + self.assertEqual(STATE_OPEN, state.state) + + fire_mqtt_message(self.hass, 'state-topic', '100') + self.hass.pool.block_till_done() + + state = self.hass.states.get('cover.test') + self.assertEqual(STATE_OPEN, state.state) + + def test_send_open_cover_command(self): + """Test the sending of open_cover.""" + self.hass.config.components = ['mqtt'] + assert _setup_component(self.hass, cover.DOMAIN, { + cover.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'command_topic': 'command-topic', + 'qos': 2 + } + }) + + state = self.hass.states.get('cover.test') + self.assertEqual(STATE_UNKNOWN, state.state) + + cover.open_cover(self.hass, 'cover.test') + self.hass.pool.block_till_done() + + self.assertEqual(('command-topic', 'OPEN', 2, False), + self.mock_publish.mock_calls[-1][1]) + state = self.hass.states.get('cover.test') + self.assertEqual(STATE_UNKNOWN, state.state) + + def test_send_close_cover_command(self): + """Test the sending of close_cover.""" + self.hass.config.components = ['mqtt'] + assert _setup_component(self.hass, cover.DOMAIN, { + cover.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'command_topic': 'command-topic', + 'qos': 2 + } + }) + + state = self.hass.states.get('cover.test') + self.assertEqual(STATE_UNKNOWN, state.state) + + cover.close_cover(self.hass, 'cover.test') + self.hass.pool.block_till_done() + + self.assertEqual(('command-topic', 'CLOSE', 2, False), + self.mock_publish.mock_calls[-1][1]) + state = self.hass.states.get('cover.test') + self.assertEqual(STATE_UNKNOWN, state.state) + + def test_send_stop__cover_command(self): + """Test the sending of stop_cover.""" + self.hass.config.components = ['mqtt'] + assert _setup_component(self.hass, cover.DOMAIN, { + cover.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'command_topic': 'command-topic', + 'qos': 2 + } + }) + + state = self.hass.states.get('cover.test') + self.assertEqual(STATE_UNKNOWN, state.state) + + cover.stop_cover(self.hass, 'cover.test') + self.hass.pool.block_till_done() + + self.assertEqual(('command-topic', 'STOP', 2, False), + self.mock_publish.mock_calls[-1][1]) + state = self.hass.states.get('cover.test') + self.assertEqual(STATE_UNKNOWN, state.state) + + def test_state_attributes_current_cover_position(self): + """Test the current cover position.""" + self.hass.config.components = ['mqtt'] + assert _setup_component(self.hass, cover.DOMAIN, { + cover.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'command_topic': 'command-topic', + 'payload_open': 'OPEN', + 'payload_close': 'CLOSE', + 'payload_stop': 'STOP' + } + }) + + state_attributes_dict = self.hass.states.get( + 'cover.test').attributes + self.assertFalse('current_position' in state_attributes_dict) + + fire_mqtt_message(self.hass, 'state-topic', '0') + self.hass.pool.block_till_done() + current_cover_position = self.hass.states.get( + 'cover.test').attributes['current_position'] + self.assertEqual(0, current_cover_position) + + fire_mqtt_message(self.hass, 'state-topic', '50') + self.hass.pool.block_till_done() + current_cover_position = self.hass.states.get( + 'cover.test').attributes['current_position'] + self.assertEqual(50, current_cover_position) + + fire_mqtt_message(self.hass, 'state-topic', '101') + self.hass.pool.block_till_done() + current_cover_position = self.hass.states.get( + 'cover.test').attributes['current_position'] + self.assertEqual(50, current_cover_position) + + fire_mqtt_message(self.hass, 'state-topic', 'non-numeric') + self.hass.pool.block_till_done() + current_cover_position = self.hass.states.get( + 'cover.test').attributes['current_position'] + self.assertEqual(50, current_cover_position) diff --git a/tests/components/cover/test_rfxtrx.py b/tests/components/cover/test_rfxtrx.py new file mode 100644 index 00000000000..96fecff2ee2 --- /dev/null +++ b/tests/components/cover/test_rfxtrx.py @@ -0,0 +1,216 @@ +"""The tests for the Rfxtrx cover platform.""" +import unittest + +from homeassistant.bootstrap import _setup_component +from homeassistant.components import rfxtrx as rfxtrx_core + +from tests.common import get_test_home_assistant + + +class TestCoverRfxtrx(unittest.TestCase): + """Test the Rfxtrx cover platform.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant(0) + self.hass.config.components = ['rfxtrx'] + + def tearDown(self): + """Stop everything that was started.""" + rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS = [] + rfxtrx_core.RFX_DEVICES = {} + if rfxtrx_core.RFXOBJECT: + rfxtrx_core.RFXOBJECT.close_connection() + self.hass.stop() + + def test_valid_config(self): + """Test configuration.""" + self.assertTrue(_setup_component(self.hass, 'cover', { + 'cover': {'platform': 'rfxtrx', + 'automatic_add': True, + 'devices': + {'0b1100cd0213c7f210010f51': { + 'name': 'Test', + rfxtrx_core.ATTR_FIREEVENT: True} + }}})) + + def test_invalid_config_capital_letters(self): + """Test configuration.""" + self.assertFalse(_setup_component(self.hass, 'cover', { + 'cover': {'platform': 'rfxtrx', + 'automatic_add': True, + 'devices': + {'2FF7f216': { + 'name': 'Test', + 'packetid': '0b1100cd0213c7f210010f51', + 'signal_repetitions': 3} + }}})) + + def test_invalid_config_extra_key(self): + """Test configuration.""" + self.assertFalse(_setup_component(self.hass, 'cover', { + 'cover': {'platform': 'rfxtrx', + 'automatic_add': True, + 'invalid_key': 'afda', + 'devices': + {'213c7f216': { + 'name': 'Test', + 'packetid': '0b1100cd0213c7f210010f51', + rfxtrx_core.ATTR_FIREEVENT: True} + }}})) + + def test_invalid_config_capital_packetid(self): + """Test configuration.""" + self.assertFalse(_setup_component(self.hass, 'cover', { + 'cover': {'platform': 'rfxtrx', + 'automatic_add': True, + 'devices': + {'213c7f216': { + 'name': 'Test', + 'packetid': 'AA1100cd0213c7f210010f51', + rfxtrx_core.ATTR_FIREEVENT: True} + }}})) + + def test_invalid_config_missing_packetid(self): + """Test configuration.""" + self.assertFalse(_setup_component(self.hass, 'cover', { + 'cover': {'platform': 'rfxtrx', + 'automatic_add': True, + 'devices': + {'213c7f216': { + 'name': 'Test', + rfxtrx_core.ATTR_FIREEVENT: True} + }}})) + + def test_default_config(self): + """Test with 0 cover.""" + self.assertTrue(_setup_component(self.hass, 'cover', { + 'cover': {'platform': 'rfxtrx', + 'devices': {}}})) + self.assertEqual(0, len(rfxtrx_core.RFX_DEVICES)) + + def test_one_cover(self): + """Test with 1 cover.""" + self.assertTrue(_setup_component(self.hass, 'cover', { + 'cover': {'platform': 'rfxtrx', + 'devices': + {'0b1400cd0213c7f210010f51': { + 'name': 'Test' + }}}})) + + import RFXtrx as rfxtrxmod + rfxtrx_core.RFXOBJECT =\ + rfxtrxmod.Core("", transport_protocol=rfxtrxmod.DummyTransport) + + self.assertEqual(1, len(rfxtrx_core.RFX_DEVICES)) + for id in rfxtrx_core.RFX_DEVICES: + entity = rfxtrx_core.RFX_DEVICES[id] + self.assertEqual(entity.signal_repetitions, 1) + self.assertFalse(entity.should_fire_event) + self.assertFalse(entity.should_poll) + entity.open_cover() + entity.close_cover() + entity.stop_cover() + + def test_several_covers(self): + """Test with 3 covers.""" + self.assertTrue(_setup_component(self.hass, 'cover', { + 'cover': {'platform': 'rfxtrx', + 'signal_repetitions': 3, + 'devices': + {'0b1100cd0213c7f230010f71': { + 'name': 'Test'}, + '0b1100100118cdea02010f70': { + 'name': 'Bath'}, + '0b1100101118cdea02010f70': { + 'name': 'Living'} + }}})) + + self.assertEqual(3, len(rfxtrx_core.RFX_DEVICES)) + device_num = 0 + for id in rfxtrx_core.RFX_DEVICES: + entity = rfxtrx_core.RFX_DEVICES[id] + self.assertEqual(entity.signal_repetitions, 3) + if entity.name == 'Living': + device_num = device_num + 1 + elif entity.name == 'Bath': + device_num = device_num + 1 + elif entity.name == 'Test': + device_num = device_num + 1 + + self.assertEqual(3, device_num) + + def test_discover_covers(self): + """Test with discovery of covers.""" + self.assertTrue(_setup_component(self.hass, 'cover', { + 'cover': {'platform': 'rfxtrx', + 'automatic_add': True, + 'devices': {}}})) + + event = rfxtrx_core.get_rfx_object('0a140002f38cae010f0070') + event.data = bytearray([0x0A, 0x14, 0x00, 0x02, 0xF3, 0x8C, + 0xAE, 0x01, 0x0F, 0x00, 0x70]) + + for evt_sub in rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS: + evt_sub(event) + self.assertEqual(1, len(rfxtrx_core.RFX_DEVICES)) + + event = rfxtrx_core.get_rfx_object('0a1400adf394ab020e0060') + event.data = bytearray([0x0A, 0x14, 0x00, 0xAD, 0xF3, 0x94, + 0xAB, 0x02, 0x0E, 0x00, 0x60]) + + for evt_sub in rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS: + evt_sub(event) + self.assertEqual(2, len(rfxtrx_core.RFX_DEVICES)) + + # Trying to add a sensor + event = rfxtrx_core.get_rfx_object('0a52085e070100b31b0279') + event.data = bytearray(b'\nR\x08^\x07\x01\x00\xb3\x1b\x02y') + for evt_sub in rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS: + evt_sub(event) + self.assertEqual(2, len(rfxtrx_core.RFX_DEVICES)) + + # Trying to add a light + event = rfxtrx_core.get_rfx_object('0b1100100118cdea02010f70') + event.data = bytearray([0x0b, 0x11, 0x11, 0x10, 0x01, 0x18, + 0xcd, 0xea, 0x01, 0x02, 0x0f, 0x70]) + for evt_sub in rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS: + evt_sub(event) + self.assertEqual(2, len(rfxtrx_core.RFX_DEVICES)) + + def test_discover_cover_noautoadd(self): + """Test with discovery of cover when auto add is False.""" + self.assertTrue(_setup_component(self.hass, 'cover', { + 'cover': {'platform': 'rfxtrx', + 'automatic_add': False, + 'devices': {}}})) + + event = rfxtrx_core.get_rfx_object('0a1400adf394ab010d0060') + event.data = bytearray([0x0A, 0x14, 0x00, 0xAD, 0xF3, 0x94, + 0xAB, 0x01, 0x0D, 0x00, 0x60]) + + for evt_sub in rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS: + evt_sub(event) + self.assertEqual(0, len(rfxtrx_core.RFX_DEVICES)) + + event = rfxtrx_core.get_rfx_object('0a1400adf394ab020e0060') + event.data = bytearray([0x0A, 0x14, 0x00, 0xAD, 0xF3, 0x94, + 0xAB, 0x02, 0x0E, 0x00, 0x60]) + for evt_sub in rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS: + evt_sub(event) + self.assertEqual(0, len(rfxtrx_core.RFX_DEVICES)) + + # Trying to add a sensor + event = rfxtrx_core.get_rfx_object('0a52085e070100b31b0279') + event.data = bytearray(b'\nR\x08^\x07\x01\x00\xb3\x1b\x02y') + for evt_sub in rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS: + evt_sub(event) + self.assertEqual(0, len(rfxtrx_core.RFX_DEVICES)) + + # Trying to add a light + event = rfxtrx_core.get_rfx_object('0b1100100118cdea02010f70') + event.data = bytearray([0x0b, 0x11, 0x11, 0x10, 0x01, + 0x18, 0xcd, 0xea, 0x01, 0x02, 0x0f, 0x70]) + for evt_sub in rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS: + evt_sub(event) + self.assertEqual(0, len(rfxtrx_core.RFX_DEVICES)) diff --git a/tests/components/device_tracker/test_asuswrt.py b/tests/components/device_tracker/test_asuswrt.py index 241e4a65a0f..fc03426a7a1 100644 --- a/tests/components/device_tracker/test_asuswrt.py +++ b/tests/components/device_tracker/test_asuswrt.py @@ -1,34 +1,56 @@ """The tests for the ASUSWRT device tracker platform.""" - import os import unittest from unittest import mock +import voluptuous as vol + +from homeassistant.bootstrap import _setup_component from homeassistant.components import device_tracker +from homeassistant.components.device_tracker.asuswrt import ( + CONF_PROTOCOL, CONF_MODE, CONF_PUB_KEY, PLATFORM_SCHEMA, DOMAIN) from homeassistant.const import (CONF_PLATFORM, CONF_PASSWORD, CONF_USERNAME, CONF_HOST) -from tests.common import get_test_home_assistant +from tests.common import get_test_home_assistant, get_test_config_dir + +FAKEFILE = None + + +def setup_module(): + """Setup the test module.""" + global FAKEFILE + FAKEFILE = get_test_config_dir('fake_file') + with open(FAKEFILE, 'w') as out: + out.write(' ') + + +def teardown_module(): + """Tear down the module.""" + os.remove(FAKEFILE) class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): """Tests for the ASUSWRT device tracker platform.""" + hass = None - def setUp(self): # pylint: disable=invalid-name + def setup_method(self, _): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() + self.hass.config.components = ['zone'] - def tearDown(self): # pylint: disable=invalid-name + def teardown_method(self, _): """Stop everything that was started.""" try: os.remove(self.hass.config.path(device_tracker.YAML_DEVICES)) except FileNotFoundError: pass - def test_password_or_pub_key_required(self): + def test_password_or_pub_key_required(self): \ + # pylint: disable=invalid-name """Test creating an AsusWRT scanner without a pass or pubkey.""" - self.assertIsNone(device_tracker.asuswrt.get_scanner( - self.hass, {device_tracker.DOMAIN: { + self.assertFalse(_setup_component( + self.hass, DOMAIN, {DOMAIN: { CONF_PLATFORM: 'asuswrt', CONF_HOST: 'fake_host', CONF_USERNAME: 'fake_user' @@ -37,36 +59,42 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): @mock.patch( 'homeassistant.components.device_tracker.asuswrt.AsusWrtDeviceScanner', return_value=mock.MagicMock()) - def test_get_scanner_with_password_no_pubkey(self, asuswrt_mock): + def test_get_scanner_with_password_no_pubkey(self, asuswrt_mock): \ + # pylint: disable=invalid-name """Test creating an AsusWRT scanner with a password and no pubkey.""" conf_dict = { - device_tracker.DOMAIN: { + DOMAIN: { CONF_PLATFORM: 'asuswrt', CONF_HOST: 'fake_host', CONF_USERNAME: 'fake_user', CONF_PASSWORD: 'fake_pass' } } - self.assertIsNotNone(device_tracker.asuswrt.get_scanner( - self.hass, conf_dict)) - asuswrt_mock.assert_called_once_with(conf_dict[device_tracker.DOMAIN]) + self.assertIsNotNone(_setup_component(self.hass, DOMAIN, conf_dict)) + conf_dict[DOMAIN][CONF_MODE] = 'router' + conf_dict[DOMAIN][CONF_PROTOCOL] = 'ssh' + asuswrt_mock.assert_called_once_with(conf_dict[DOMAIN]) @mock.patch( 'homeassistant.components.device_tracker.asuswrt.AsusWrtDeviceScanner', return_value=mock.MagicMock()) - def test_get_scanner_with_pubkey_no_password(self, asuswrt_mock): + def test_get_scanner_with_pubkey_no_password(self, asuswrt_mock): \ + # pylint: disable=invalid-name """Test creating an AsusWRT scanner with a pubkey and no password.""" conf_dict = { device_tracker.DOMAIN: { CONF_PLATFORM: 'asuswrt', CONF_HOST: 'fake_host', CONF_USERNAME: 'fake_user', - 'pub_key': '/fake_path' + CONF_PUB_KEY: FAKEFILE } } - self.assertIsNotNone(device_tracker.asuswrt.get_scanner( - self.hass, conf_dict)) - asuswrt_mock.assert_called_once_with(conf_dict[device_tracker.DOMAIN]) + + self.assertIsNotNone(_setup_component(self.hass, DOMAIN, conf_dict)) + + conf_dict[DOMAIN][CONF_MODE] = 'router' + conf_dict[DOMAIN][CONF_PROTOCOL] = 'ssh' + asuswrt_mock.assert_called_once_with(conf_dict[DOMAIN]) def test_ssh_login_with_pub_key(self): """Test that login is done with pub_key when configured to.""" @@ -74,12 +102,12 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): ssh_mock = mock.patch('pexpect.pxssh.pxssh', return_value=ssh) ssh_mock.start() self.addCleanup(ssh_mock.stop) - conf_dict = { - CONF_PLATFORM: 'asuswrt', - CONF_HOST: 'fake_host', - CONF_USERNAME: 'fake_user', - 'pub_key': '/fake_path' - } + conf_dict = PLATFORM_SCHEMA({ + CONF_PLATFORM: 'asuswrt', + CONF_HOST: 'fake_host', + CONF_USERNAME: 'fake_user', + CONF_PUB_KEY: FAKEFILE + }) update_mock = mock.patch( 'homeassistant.components.device_tracker.asuswrt.' 'AsusWrtDeviceScanner.get_asuswrt_data') @@ -88,7 +116,7 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): asuswrt = device_tracker.asuswrt.AsusWrtDeviceScanner(conf_dict) asuswrt.ssh_connection() ssh.login.assert_called_once_with('fake_host', 'fake_user', - ssh_key='/fake_path') + ssh_key=FAKEFILE) def test_ssh_login_with_password(self): """Test that login is done with password when configured to.""" @@ -96,12 +124,12 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): ssh_mock = mock.patch('pexpect.pxssh.pxssh', return_value=ssh) ssh_mock.start() self.addCleanup(ssh_mock.stop) - conf_dict = { - CONF_PLATFORM: 'asuswrt', - CONF_HOST: 'fake_host', - CONF_USERNAME: 'fake_user', - CONF_PASSWORD: 'fake_pass' - } + conf_dict = PLATFORM_SCHEMA({ + CONF_PLATFORM: 'asuswrt', + CONF_HOST: 'fake_host', + CONF_USERNAME: 'fake_user', + CONF_PASSWORD: 'fake_pass' + }) update_mock = mock.patch( 'homeassistant.components.device_tracker.asuswrt.' 'AsusWrtDeviceScanner.get_asuswrt_data') @@ -112,23 +140,29 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): ssh.login.assert_called_once_with('fake_host', 'fake_user', 'fake_pass') - def test_ssh_login_without_password_or_pubkey(self): + def test_ssh_login_without_password_or_pubkey(self): \ + # pylint: disable=invalid-name """Test that login is not called without password or pub_key.""" ssh = mock.MagicMock() ssh_mock = mock.patch('pexpect.pxssh.pxssh', return_value=ssh) ssh_mock.start() self.addCleanup(ssh_mock.stop) + conf_dict = { - CONF_PLATFORM: 'asuswrt', - CONF_HOST: 'fake_host', - CONF_USERNAME: 'fake_user', + CONF_PLATFORM: 'asuswrt', + CONF_HOST: 'fake_host', + CONF_USERNAME: 'fake_user', } + + with self.assertRaises(vol.Invalid): + conf_dict = PLATFORM_SCHEMA(conf_dict) + update_mock = mock.patch( 'homeassistant.components.device_tracker.asuswrt.' 'AsusWrtDeviceScanner.get_asuswrt_data') update_mock.start() self.addCleanup(update_mock.stop) - asuswrt = device_tracker.asuswrt.AsusWrtDeviceScanner(conf_dict) - result = asuswrt.ssh_connection() + + self.assertFalse(_setup_component(self.hass, DOMAIN, + {DOMAIN: conf_dict})) ssh.login.assert_not_called() - self.assertIsNone(result) diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index a88b4d18de4..c6721b8c8cc 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -63,7 +63,8 @@ class TestComponentsDeviceTracker(unittest.TestCase): dev_id = 'test' device = device_tracker.Device( self.hass, timedelta(seconds=180), 0, True, dev_id, - 'AB:CD:EF:GH:IJ', 'Test name', 'http://test.picture', True) + 'AB:CD:EF:GH:IJ', 'Test name', picture='http://test.picture', + away_hide=True) device_tracker.update_config(self.yaml_devices, dev_id, device) self.assertTrue(device_tracker.setup(self.hass, {})) config = device_tracker.load_config(self.yaml_devices, self.hass, @@ -94,6 +95,27 @@ class TestComponentsDeviceTracker(unittest.TestCase): assert config[0].dev_id == 'dev1' assert config[0].track + def test_gravatar(self): + """Test the Gravatar generation.""" + dev_id = 'test' + device = device_tracker.Device( + self.hass, timedelta(seconds=180), 0, True, dev_id, + 'AB:CD:EF:GH:IJ', 'Test name', gravatar='test@example.com') + gravatar_url = ("https://www.gravatar.com/avatar/" + "55502f40dc8b7c769880b10874abc9d0.jpg?s=80&d=wavatar") + self.assertEqual(device.config_picture, gravatar_url) + + def test_gravatar_and_picture(self): + """Test that Gravatar overrides picture.""" + dev_id = 'test' + device = device_tracker.Device( + self.hass, timedelta(seconds=180), 0, True, dev_id, + 'AB:CD:EF:GH:IJ', 'Test name', picture='http://test.picture', + gravatar='test@example.com') + gravatar_url = ("https://www.gravatar.com/avatar/" + "55502f40dc8b7c769880b10874abc9d0.jpg?s=80&d=wavatar") + self.assertEqual(device.config_picture, gravatar_url) + def test_discovery(self): """Test discovery.""" scanner = get_component('device_tracker.test').SCANNER diff --git a/tests/components/device_tracker/test_owntracks.py b/tests/components/device_tracker/test_owntracks.py index 16fb1c4a4ce..393b61a3134 100644 --- a/tests/components/device_tracker/test_owntracks.py +++ b/tests/components/device_tracker/test_owntracks.py @@ -108,6 +108,31 @@ REGION_LEAVE_INACCURATE_MESSAGE = { '_type': 'transition'} +REGION_ENTER_ZERO_MESSAGE = { + 'lon': 1.0, + 'event': 'enter', + 'tid': 'user', + 'desc': 'inner', + 'wtst': 1, + 't': 'b', + 'acc': 0, + 'tst': 2, + 'lat': 2.0, + '_type': 'transition'} + +REGION_LEAVE_ZERO_MESSAGE = { + 'lon': 10.0, + 'event': 'leave', + 'tid': 'user', + 'desc': 'inner', + 'wtst': 1, + 't': 'b', + 'acc': 0, + 'tst': 2, + 'lat': 20.0, + '_type': 'transition'} + + class TestDeviceTrackerOwnTracks(unittest.TestCase): """Test the OwnTrack sensor.""" @@ -293,6 +318,25 @@ class TestDeviceTrackerOwnTracks(unittest.TestCase): # But does exit region correctly self.assertFalse(owntracks.REGIONS_ENTERED[USER]) + def test_event_entry_exit_zero_accuracy(self): + """Test entry/exit events with accuracy zero.""" + self.send_message(EVENT_TOPIC, REGION_ENTER_ZERO_MESSAGE) + + # Enter uses the zone's gps co-ords + self.assert_location_latitude(2.1) + self.assert_location_accuracy(10.0) + self.assert_location_state('inner') + + self.send_message(EVENT_TOPIC, REGION_LEAVE_ZERO_MESSAGE) + + # Exit doesn't use zero gps + self.assert_location_latitude(2.1) + self.assert_location_accuracy(10.0) + self.assert_location_state('inner') + + # But does exit region correctly + self.assertFalse(owntracks.REGIONS_ENTERED[USER]) + def test_event_exit_outside_zone_sets_away(self): """Test the event for exit zone.""" self.send_message(EVENT_TOPIC, REGION_ENTER_MESSAGE) diff --git a/tests/components/device_tracker/test_tplink.py b/tests/components/device_tracker/test_tplink.py new file mode 100644 index 00000000000..da6243e6eff --- /dev/null +++ b/tests/components/device_tracker/test_tplink.py @@ -0,0 +1,67 @@ +"""The tests for the tplink device tracker platform.""" + +import os +import unittest + +from homeassistant.components import device_tracker +from homeassistant.components.device_tracker.tplink import Tplink4DeviceScanner +from homeassistant.const import (CONF_PLATFORM, CONF_PASSWORD, CONF_USERNAME, + CONF_HOST) +import requests_mock + +from tests.common import get_test_home_assistant + + +class TestTplink4DeviceScanner(unittest.TestCase): + """Tests for the Tplink4DeviceScanner class.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def tearDown(self): # pylint: disable=invalid-name + """Stop everything that was started.""" + try: + os.remove(self.hass.config.path(device_tracker.YAML_DEVICES)) + except FileNotFoundError: + pass + + @requests_mock.mock() + def test_get_mac_addresses_from_both_bands(self, m): + """Test grabbing the mac addresses from 2.4 and 5 GHz clients pages.""" + conf_dict = { + CONF_PLATFORM: 'tplink', + CONF_HOST: 'fake_host', + CONF_USERNAME: 'fake_user', + CONF_PASSWORD: 'fake_pass' + } + + # Mock the token retrieval process + FAKE_TOKEN = 'fake_token' + fake_auth_token_response = 'window.parent.location.href = ' \ + '"https://a/{}/userRpm/Index.htm";'.format( + FAKE_TOKEN) + + m.get('http://{}/userRpm/LoginRpm.htm?Save=Save'.format( + conf_dict[CONF_HOST]), text=fake_auth_token_response) + + FAKE_MAC_1 = 'CA-FC-8A-C8-BB-53' + FAKE_MAC_2 = '6C-48-83-21-46-8D' + FAKE_MAC_3 = '77-98-75-65-B1-2B' + mac_response_2_4 = '{} {}'.format(FAKE_MAC_1, FAKE_MAC_2) + mac_response_5 = '{}'.format(FAKE_MAC_3) + + # Mock the 2.4 GHz clients page + m.get('http://{}/{}/userRpm/WlanStationRpm.htm'.format( + conf_dict[CONF_HOST], FAKE_TOKEN), text=mac_response_2_4) + + # Mock the 5 GHz clients page + m.get('http://{}/{}/userRpm/WlanStationRpm_5g.htm'.format( + conf_dict[CONF_HOST], FAKE_TOKEN), text=mac_response_5) + + tplink = Tplink4DeviceScanner(conf_dict) + + expected_mac_results = [mac.replace('-', ':') for mac in + [FAKE_MAC_1, FAKE_MAC_2, FAKE_MAC_3]] + + self.assertEquals(tplink.last_results, expected_mac_results) diff --git a/tests/components/fan/__init__.py b/tests/components/fan/__init__.py new file mode 100644 index 00000000000..463e96a4319 --- /dev/null +++ b/tests/components/fan/__init__.py @@ -0,0 +1,39 @@ +"""Test fan component plaforms.""" + +import unittest + +from homeassistant.components.fan import FanEntity + + +class BaseFan(FanEntity): + """Implementation of the abstract FanEntity.""" + + def __init__(self): + """Initialize the fan.""" + pass + + +class TestFanEntity(unittest.TestCase): + """Test coverage for base fan entity class.""" + + def setUp(self): + """Setup test data.""" + self.fan = BaseFan() + + def tearDown(self): + """Tear down unit test data.""" + self.fan = None + + def test_fanentity(self): + """Test fan entity methods.""" + self.assertIsNone(self.fan.state) + self.assertEqual(0, len(self.fan.speed_list)) + self.assertEqual(0, self.fan.supported_features) + self.assertEqual({}, self.fan.state_attributes) + # Test set_speed not required + self.fan.set_speed() + self.fan.oscillate() + with self.assertRaises(NotImplementedError): + self.fan.turn_on() + with self.assertRaises(NotImplementedError): + self.fan.turn_off() diff --git a/tests/components/fan/test_demo.py b/tests/components/fan/test_demo.py new file mode 100644 index 00000000000..db894ee54ed --- /dev/null +++ b/tests/components/fan/test_demo.py @@ -0,0 +1,84 @@ +"""Test cases around the demo fan platform.""" + +import unittest + +from homeassistant.components import fan +from homeassistant.components.fan.demo import FAN_ENTITY_ID +from homeassistant.const import STATE_OFF, STATE_ON + +from tests.common import get_test_home_assistant + + +class TestDemoFan(unittest.TestCase): + """Test the fan demo platform.""" + + def get_entity(self): + """Helper method to get the fan entity.""" + return self.hass.states.get(FAN_ENTITY_ID) + + def setUp(self): + """Initialize unit test data.""" + self.hass = get_test_home_assistant() + self.assertTrue(fan.setup(self.hass, {'fan': { + 'platform': 'demo', + }})) + self.hass.pool.block_till_done() + + def tearDown(self): + """Tear down unit test data.""" + self.hass.stop() + + def test_turn_on(self): + """Test turning on the device.""" + self.assertEqual(STATE_OFF, self.get_entity().state) + + fan.turn_on(self.hass, FAN_ENTITY_ID) + self.hass.pool.block_till_done() + self.assertNotEqual(STATE_OFF, self.get_entity().state) + + fan.turn_on(self.hass, FAN_ENTITY_ID, fan.SPEED_HIGH) + self.hass.pool.block_till_done() + self.assertEqual(STATE_ON, self.get_entity().state) + self.assertEqual(fan.SPEED_HIGH, + self.get_entity().attributes[fan.ATTR_SPEED]) + + def test_turn_off(self): + """Test turning off the device.""" + self.assertEqual(STATE_OFF, self.get_entity().state) + + fan.turn_on(self.hass, FAN_ENTITY_ID) + self.hass.pool.block_till_done() + self.assertNotEqual(STATE_OFF, self.get_entity().state) + + fan.turn_off(self.hass, FAN_ENTITY_ID) + self.hass.pool.block_till_done() + self.assertEqual(STATE_OFF, self.get_entity().state) + + def test_set_speed(self): + """Test setting the speed of the device.""" + self.assertEqual(STATE_OFF, self.get_entity().state) + + fan.set_speed(self.hass, FAN_ENTITY_ID, fan.SPEED_LOW) + self.hass.pool.block_till_done() + self.assertEqual(fan.SPEED_LOW, + self.get_entity().attributes.get('speed')) + + def test_oscillate(self): + """Test oscillating the fan.""" + self.assertFalse(self.get_entity().attributes.get('oscillating')) + + fan.oscillate(self.hass, FAN_ENTITY_ID, True) + self.hass.pool.block_till_done() + self.assertTrue(self.get_entity().attributes.get('oscillating')) + + fan.oscillate(self.hass, FAN_ENTITY_ID, False) + self.hass.pool.block_till_done() + self.assertFalse(self.get_entity().attributes.get('oscillating')) + + def test_is_on(self): + """Test is on service call.""" + self.assertFalse(fan.is_on(self.hass, FAN_ENTITY_ID)) + + fan.turn_on(self.hass, FAN_ENTITY_ID) + self.hass.pool.block_till_done() + self.assertTrue(fan.is_on(self.hass, FAN_ENTITY_ID)) diff --git a/tests/components/fan/test_insteon_hub.py b/tests/components/fan/test_insteon_hub.py new file mode 100644 index 00000000000..dfdb4b7a9f0 --- /dev/null +++ b/tests/components/fan/test_insteon_hub.py @@ -0,0 +1,73 @@ +"""Tests for the insteon hub fan platform.""" +import unittest + +from homeassistant.const import (STATE_OFF, STATE_ON) +from homeassistant.components.fan import (SPEED_LOW, SPEED_MED, SPEED_HIGH, + ATTR_SPEED) +from homeassistant.components.fan.insteon_hub import (InsteonFanDevice, + SUPPORT_SET_SPEED) + + +class Node(object): + """Fake insteon node.""" + + def __init__(self, name, id, dev_cat, sub_cat): + """Initialize fake insteon node.""" + self.DeviceName = name + self.DeviceID = id + self.DevCat = dev_cat + self.SubCat = sub_cat + self.response = None + + def send_command(self, command, payload, level, wait): + """Send fake command.""" + return self.response + + +class TestInsteonHubFanDevice(unittest.TestCase): + """Test around insteon hub fan device methods.""" + + _NODE = Node('device', '12345', '1', '46') + + def setUp(self): + """Initialize test data.""" + self._DEVICE = InsteonFanDevice(self._NODE) + + def tearDown(self): + """Tear down test data.""" + self._DEVICE = None + + def test_properties(self): + """Test basic properties.""" + self.assertEqual(self._NODE.DeviceName, self._DEVICE.name) + self.assertEqual(self._NODE.DeviceID, self._DEVICE.unique_id) + self.assertEqual(SUPPORT_SET_SPEED, self._DEVICE.supported_features) + + for speed in [STATE_OFF, SPEED_LOW, SPEED_MED, SPEED_HIGH]: + self.assertIn(speed, self._DEVICE.speed_list) + + def test_turn_on(self): + """Test the turning on device.""" + self._NODE.response = { + 'status': 'succeeded' + } + self.assertEqual(STATE_OFF, self._DEVICE.state) + self._DEVICE.turn_on() + + self.assertEqual(STATE_ON, self._DEVICE.state) + + self._DEVICE.turn_on(SPEED_MED) + + self.assertEqual(STATE_ON, self._DEVICE.state) + self.assertEqual(SPEED_MED, self._DEVICE.state_attributes[ATTR_SPEED]) + + def test_turn_off(self): + """Test turning off device.""" + self._NODE.response = { + 'status': 'succeeded' + } + self.assertEqual(STATE_OFF, self._DEVICE.state) + self._DEVICE.turn_on() + self.assertEqual(STATE_ON, self._DEVICE.state) + self._DEVICE.turn_off() + self.assertEqual(STATE_OFF, self._DEVICE.state) diff --git a/tests/components/notify/test_demo.py b/tests/components/notify/test_demo.py index 3f7ffb576ed..f0a05a01c1f 100644 --- a/tests/components/notify/test_demo.py +++ b/tests/components/notify/test_demo.py @@ -22,6 +22,7 @@ class TestNotifyDemo(unittest.TestCase): } })) self.events = [] + self.calls = [] def record_event(event): """Record event to send notification.""" @@ -33,6 +34,10 @@ class TestNotifyDemo(unittest.TestCase): """"Stop down everything that was started.""" self.hass.stop() + def record_calls(self, *args): + """Helper for recording calls.""" + self.calls.append(args) + def test_sending_none_message(self): """Test send with None as message.""" notify.send_message(self.hass, None) @@ -93,3 +98,29 @@ data_template: 'sound': 'US-EN-Morgan-Freeman-Roommate-Is-Arriving.wav'}} } == self.events[0].data + + def test_targets_are_services(self): + """Test that all targets are exposed as individual services.""" + self.assertIsNotNone(self.hass.services.has_service("notify", "demo")) + service = "demo_test_target" + self.assertIsNotNone(self.hass.services.has_service("notify", service)) + + def test_messages_to_targets_route(self): + """Test message routing to specific target services.""" + self.hass.bus.listen_once("notify", self.record_calls) + + self.hass.services.call("notify", "demo_test_target", + {'message': 'my message', + 'title': 'my title', + 'data': {'hello': 'world'}}) + + self.hass.pool.block_till_done() + + data = self.calls[0][0].data + + assert { + 'message': 'my message', + 'target': 'test target', + 'title': 'my title', + 'data': {'hello': 'world'} + } == data diff --git a/tests/components/notify/test_group.py b/tests/components/notify/test_group.py new file mode 100644 index 00000000000..20e2259ca6e --- /dev/null +++ b/tests/components/notify/test_group.py @@ -0,0 +1,82 @@ +"""The tests for the notify.group platform.""" +import unittest + +import homeassistant.components.notify as notify +from homeassistant.components.notify import group + +from tests.common import get_test_home_assistant + + +class TestNotifyGroup(unittest.TestCase): + """Test the notify.group platform.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.events = [] + self.assertTrue(notify.setup(self.hass, { + 'notify': { + 'name': 'demo1', + 'platform': 'demo' + } + })) + self.assertTrue(notify.setup(self.hass, { + 'notify': { + 'name': 'demo2', + 'platform': 'demo' + } + })) + + self.service = group.get_service(self.hass, {'services': [ + {'service': 'demo1'}, + {'service': 'demo2', + 'data': {'target': 'unnamed device', + 'data': {'test': 'message'}}}]}) + + assert self.service is not None + + def record_event(event): + """Record event to send notification.""" + self.events.append(event) + + self.hass.bus.listen("notify", record_event) + + def tearDown(self): # pylint: disable=invalid-name + """"Stop everything that was started.""" + self.hass.stop() + + def test_send_message_to_group(self): + """Test sending a message to a notify group.""" + self.service.send_message('Hello', title='Test notification') + self.hass.pool.block_till_done() + self.assertTrue(len(self.events) == 2) + last_event = self.events[-1] + self.assertEqual(last_event.data[notify.ATTR_TITLE], + 'Test notification') + self.assertEqual(last_event.data[notify.ATTR_MESSAGE], 'Hello') + + def test_send_message_with_data(self): + """Test sending a message with to a notify group.""" + notify_data = {'hello': 'world'} + self.service.send_message('Hello', title='Test notification', + data=notify_data) + self.hass.pool.block_till_done() + last_event = self.events[-1] + self.assertEqual(last_event.data[notify.ATTR_TITLE], + 'Test notification') + self.assertEqual(last_event.data[notify.ATTR_MESSAGE], 'Hello') + self.assertEqual(last_event.data[notify.ATTR_DATA], notify_data) + + def test_entity_data_passes_through(self): + """Test sending a message with data to merge to a notify group.""" + notify_data = {'hello': 'world'} + self.service.send_message('Hello', title='Test notification', + data=notify_data) + self.hass.pool.block_till_done() + data = self.events[-1].data + assert { + 'message': 'Hello', + 'target': 'unnamed device', + 'title': 'Test notification', + 'data': {'hello': 'world', 'test': 'message'} + } == data diff --git a/tests/components/notify/test_html5.py b/tests/components/notify/test_html5.py new file mode 100644 index 00000000000..d3d20d01289 --- /dev/null +++ b/tests/components/notify/test_html5.py @@ -0,0 +1,351 @@ +"""Test HTML5 notify platform.""" +import json +import tempfile +from unittest.mock import patch, MagicMock + +from werkzeug.test import EnvironBuilder + +from homeassistant.components.http import request_class +from homeassistant.components.notify import html5 + +SUBSCRIPTION_1 = { + 'browser': 'chrome', + 'subscription': { + 'endpoint': 'https://google.com', + 'keys': {'auth': 'auth', 'p256dh': 'p256dh'} + }, +} +SUBSCRIPTION_2 = { + 'browser': 'firefox', + 'subscription': { + 'endpoint': 'https://example.com', + 'keys': { + 'auth': 'bla', + 'p256dh': 'bla', + }, + }, +} +SUBSCRIPTION_3 = { + 'browser': 'chrome', + 'subscription': { + 'endpoint': 'https://example.com/not_exist', + 'keys': { + 'auth': 'bla', + 'p256dh': 'bla', + }, + }, +} + + +class TestHtml5Notify(object): + """Tests for HTML5 notify platform.""" + + def test_get_service_with_no_json(self): + """Test empty json file.""" + hass = MagicMock() + + with tempfile.NamedTemporaryFile() as fp: + hass.config.path.return_value = fp.name + service = html5.get_service(hass, {}) + + assert service is not None + + def test_get_service_with_bad_json(self): + """Test .""" + hass = MagicMock() + + with tempfile.NamedTemporaryFile() as fp: + fp.write('I am not JSON'.encode('utf-8')) + fp.flush() + hass.config.path.return_value = fp.name + service = html5.get_service(hass, {}) + + assert service is None + + @patch('pywebpush.WebPusher') + def test_sending_message(self, mock_wp): + """Test sending message.""" + hass = MagicMock() + + data = { + 'device': SUBSCRIPTION_1 + } + + with tempfile.NamedTemporaryFile() as fp: + fp.write(json.dumps(data).encode('utf-8')) + fp.flush() + hass.config.path.return_value = fp.name + service = html5.get_service(hass, {'gcm_sender_id': '100'}) + + assert service is not None + + service.send_message('Hello', target=['device', 'non_existing'], + data={'icon': 'beer.png'}) + + assert len(mock_wp.mock_calls) == 2 + + # WebPusher constructor + assert mock_wp.mock_calls[0][1][0] == SUBSCRIPTION_1['subscription'] + + # Call to send + payload = json.loads(mock_wp.mock_calls[1][1][0]) + + assert payload['body'] == 'Hello' + assert payload['icon'] == 'beer.png' + + def test_registering_new_device_view(self): + """Test that the HTML view works.""" + hass = MagicMock() + + with tempfile.NamedTemporaryFile() as fp: + hass.config.path.return_value = fp.name + fp.close() + service = html5.get_service(hass, {}) + + assert service is not None + + # assert hass.called + assert len(hass.mock_calls) == 3 + + view = hass.mock_calls[1][1][0] + assert view.json_path == fp.name + assert view.registrations == {} + + builder = EnvironBuilder(method='POST', + data=json.dumps(SUBSCRIPTION_1)) + Request = request_class() + resp = view.post(Request(builder.get_environ())) + + expected = { + 'unnamed device': SUBSCRIPTION_1, + } + + assert resp.status_code == 200, resp.response + assert view.registrations == expected + with open(fp.name) as fpp: + assert json.load(fpp) == expected + + def test_registering_new_device_validation(self): + """Test various errors when registering a new device.""" + hass = MagicMock() + + with tempfile.NamedTemporaryFile() as fp: + hass.config.path.return_value = fp.name + service = html5.get_service(hass, {}) + + assert service is not None + + # assert hass.called + assert len(hass.mock_calls) == 3 + + view = hass.mock_calls[1][1][0] + + Request = request_class() + + builder = EnvironBuilder(method='POST', data=json.dumps({ + 'browser': 'invalid browser', + 'subscription': 'sub info', + })) + resp = view.post(Request(builder.get_environ())) + assert resp.status_code == 400, resp.response + + builder = EnvironBuilder(method='POST', data=json.dumps({ + 'browser': 'chrome', + })) + resp = view.post(Request(builder.get_environ())) + assert resp.status_code == 400, resp.response + + builder = EnvironBuilder(method='POST', data=json.dumps({ + 'browser': 'chrome', + 'subscription': 'sub info', + })) + with patch('homeassistant.components.notify.html5._save_config', + return_value=False): + resp = view.post(Request(builder.get_environ())) + assert resp.status_code == 400, resp.response + + def test_unregistering_device_view(self): + """Test that the HTML unregister view works.""" + hass = MagicMock() + + config = { + 'some device': SUBSCRIPTION_1, + 'other device': SUBSCRIPTION_2, + } + + with tempfile.NamedTemporaryFile() as fp: + hass.config.path.return_value = fp.name + fp.write(json.dumps(config).encode('utf-8')) + fp.flush() + service = html5.get_service(hass, {}) + + assert service is not None + + # assert hass.called + assert len(hass.mock_calls) == 3 + + view = hass.mock_calls[1][1][0] + assert view.json_path == fp.name + assert view.registrations == config + + builder = EnvironBuilder(method='DELETE', data=json.dumps({ + 'subscription': SUBSCRIPTION_1['subscription'], + })) + Request = request_class() + resp = view.delete(Request(builder.get_environ())) + + config.pop('some device') + + assert resp.status_code == 200, resp.response + assert view.registrations == config + with open(fp.name) as fpp: + assert json.load(fpp) == config + + def test_unregistering_device_view_handles_unknown_subscription(self): + """Test that the HTML unregister view handles unknown subscriptions.""" + hass = MagicMock() + + config = { + 'some device': SUBSCRIPTION_1, + 'other device': SUBSCRIPTION_2, + } + + with tempfile.NamedTemporaryFile() as fp: + hass.config.path.return_value = fp.name + fp.write(json.dumps(config).encode('utf-8')) + fp.flush() + service = html5.get_service(hass, {}) + + assert service is not None + + # assert hass.called + assert len(hass.mock_calls) == 3 + + view = hass.mock_calls[1][1][0] + assert view.json_path == fp.name + assert view.registrations == config + + builder = EnvironBuilder(method='DELETE', data=json.dumps({ + 'subscription': SUBSCRIPTION_3['subscription'] + })) + Request = request_class() + resp = view.delete(Request(builder.get_environ())) + + assert resp.status_code == 200, resp.response + assert view.registrations == config + with open(fp.name) as fpp: + assert json.load(fpp) == config + + def test_unregistering_device_view_handles_json_safe_error(self): + """Test that the HTML unregister view handles JSON write errors.""" + hass = MagicMock() + + config = { + 'some device': SUBSCRIPTION_1, + 'other device': SUBSCRIPTION_2, + } + + with tempfile.NamedTemporaryFile() as fp: + hass.config.path.return_value = fp.name + fp.write(json.dumps(config).encode('utf-8')) + fp.flush() + service = html5.get_service(hass, {}) + + assert service is not None + + # assert hass.called + assert len(hass.mock_calls) == 3 + + view = hass.mock_calls[1][1][0] + assert view.json_path == fp.name + assert view.registrations == config + + builder = EnvironBuilder(method='DELETE', data=json.dumps({ + 'subscription': SUBSCRIPTION_1['subscription'], + })) + Request = request_class() + + with patch('homeassistant.components.notify.html5._save_config', + return_value=False): + resp = view.delete(Request(builder.get_environ())) + + assert resp.status_code == 500, resp.response + assert view.registrations == config + with open(fp.name) as fpp: + assert json.load(fpp) == config + + def test_callback_view_no_jwt(self): + """Test that the notification callback view works without JWT.""" + hass = MagicMock() + + with tempfile.NamedTemporaryFile() as fp: + hass.config.path.return_value = fp.name + fp.close() + service = html5.get_service(hass, {}) + + assert service is not None + + # assert hass.called + assert len(hass.mock_calls) == 3 + + view = hass.mock_calls[2][1][0] + + builder = EnvironBuilder(method='POST', data=json.dumps({ + 'type': 'push', + 'tag': '3bc28d69-0921-41f1-ac6a-7a627ba0aa72' + })) + Request = request_class() + resp = view.post(Request(builder.get_environ())) + + assert resp.status_code == 401, resp.response + + @patch('pywebpush.WebPusher') + def test_callback_view_with_jwt(self, mock_wp): + """Test that the notification callback view works with JWT.""" + hass = MagicMock() + + data = { + 'device': SUBSCRIPTION_1, + } + + with tempfile.NamedTemporaryFile() as fp: + fp.write(json.dumps(data).encode('utf-8')) + fp.flush() + hass.config.path.return_value = fp.name + service = html5.get_service(hass, {'gcm_sender_id': '100'}) + + assert service is not None + + # assert hass.called + assert len(hass.mock_calls) == 3 + + service.send_message('Hello', target=['device'], + data={'icon': 'beer.png'}) + + assert len(mock_wp.mock_calls) == 2 + + # WebPusher constructor + assert mock_wp.mock_calls[0][1][0] == \ + SUBSCRIPTION_1['subscription'] + + # Call to send + push_payload = json.loads(mock_wp.mock_calls[1][1][0]) + + assert push_payload['body'] == 'Hello' + assert push_payload['icon'] == 'beer.png' + + view = hass.mock_calls[2][1][0] + view.registrations = data + + bearer_token = "Bearer {}".format(push_payload['data']['jwt']) + + builder = EnvironBuilder(method='POST', data=json.dumps({ + 'type': 'push', + }), headers={'Authorization': bearer_token}) + Request = request_class() + resp = view.post(Request(builder.get_environ())) + + assert resp.status_code == 200, resp.response + returned = resp.response[0].decode('utf-8') + expected = '{"event": "push", "status": "ok"}' + assert json.loads(returned) == json.loads(expected) diff --git a/tests/components/rollershutter/test_demo.py b/tests/components/rollershutter/test_demo.py new file mode 100644 index 00000000000..039221fad6e --- /dev/null +++ b/tests/components/rollershutter/test_demo.py @@ -0,0 +1,55 @@ +"""The tests for the Demo roller shutter platform.""" +import unittest +import homeassistant.util.dt as dt_util + +from homeassistant.components.rollershutter import demo +from tests.common import fire_time_changed, get_test_home_assistant + + +class TestRollershutterDemo(unittest.TestCase): + """Test the Demo roller shutter.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def tearDown(self): # pylint: disable=invalid-name + """Stop down everything that was started.""" + self.hass.stop() + + def test_move_up(self): + """Test moving the rollershutter up.""" + entity = demo.DemoRollershutter(self.hass, 'test', 100) + entity.move_up() + + fire_time_changed(self.hass, dt_util.utcnow()) + self.hass.pool.block_till_done() + self.assertEqual(90, entity.current_position) + + def test_move_down(self): + """Test moving the rollershutter down.""" + entity = demo.DemoRollershutter(self.hass, 'test', 0) + entity.move_down() + + fire_time_changed(self.hass, dt_util.utcnow()) + self.hass.pool.block_till_done() + self.assertEqual(10, entity.current_position) + + def test_move_position(self): + """Test moving the rollershutter to a specific position.""" + entity = demo.DemoRollershutter(self.hass, 'test', 0) + entity.move_position(10) + + fire_time_changed(self.hass, dt_util.utcnow()) + self.hass.pool.block_till_done() + self.assertEqual(10, entity.current_position) + + def test_stop(self): + """Test stopping the rollershutter.""" + entity = demo.DemoRollershutter(self.hass, 'test', 0) + entity.move_down() + entity.stop() + + fire_time_changed(self.hass, dt_util.utcnow()) + self.hass.pool.block_till_done() + self.assertEqual(0, entity.current_position) diff --git a/tests/components/sensor/test_mqtt_room.py b/tests/components/sensor/test_mqtt_room.py new file mode 100644 index 00000000000..f5e9202234e --- /dev/null +++ b/tests/components/sensor/test_mqtt_room.py @@ -0,0 +1,107 @@ +"""The tests for the MQTT room presence sensor.""" +import json +import datetime +import unittest +from unittest.mock import patch + +import homeassistant.components.sensor as sensor +from homeassistant.components.mqtt import (CONF_STATE_TOPIC, CONF_QOS, + DEFAULT_QOS) +from homeassistant.const import (CONF_NAME, CONF_PLATFORM) +from homeassistant.util import dt + +from tests.common import ( + get_test_home_assistant, mock_mqtt_component, fire_mqtt_message) + +DEVICE_ID = '123TESTMAC' +NAME = 'test_device' +BEDROOM = 'bedroom' +LIVING_ROOM = 'living_room' + +BEDROOM_TOPIC = "room_presence/{}".format(BEDROOM) +LIVING_ROOM_TOPIC = "room_presence/{}".format(LIVING_ROOM) + +SENSOR_STATE = "sensor.{}".format(NAME) + +CONF_DEVICE_ID = 'device_id' +CONF_TIMEOUT = 'timeout' + +NEAR_MESSAGE = { + 'id': DEVICE_ID, + 'name': NAME, + 'distance': 1 +} + +FAR_MESSAGE = { + 'id': DEVICE_ID, + 'name': NAME, + 'distance': 10 +} + +REALLY_FAR_MESSAGE = { + 'id': DEVICE_ID, + 'name': NAME, + 'distance': 20 +} + + +class TestMQTTRoomSensor(unittest.TestCase): + """Test the room presence sensor.""" + + def setup_method(self, method): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + mock_mqtt_component(self.hass) + self.assertTrue(sensor.setup(self.hass, { + sensor.DOMAIN: { + CONF_PLATFORM: 'mqtt_room', + CONF_NAME: NAME, + CONF_DEVICE_ID: DEVICE_ID, + CONF_STATE_TOPIC: 'room_presence', + CONF_QOS: DEFAULT_QOS, + CONF_TIMEOUT: 5 + }})) + + # Clear state between tests + self.hass.states.set(SENSOR_STATE, None) + + def teardown_method(self, method): + """Stop everything that was started.""" + self.hass.stop() + + def send_message(self, topic, message): + """Test the sending of a message.""" + fire_mqtt_message( + self.hass, topic, json.dumps(message)) + self.hass.pool.block_till_done() + + def assert_state(self, room): + """Test the assertion of a room state.""" + state = self.hass.states.get(SENSOR_STATE) + self.assertEqual(state.state, room) + + def assert_distance(self, distance): + """Test the assertion of a distance state.""" + state = self.hass.states.get(SENSOR_STATE) + self.assertEqual(state.attributes.get('distance'), distance) + + def test_room_update(self): + """Test the updating between rooms.""" + self.send_message(BEDROOM_TOPIC, FAR_MESSAGE) + self.assert_state(BEDROOM) + self.assert_distance(10) + + self.send_message(LIVING_ROOM_TOPIC, NEAR_MESSAGE) + self.assert_state(LIVING_ROOM) + self.assert_distance(1) + + self.send_message(BEDROOM_TOPIC, FAR_MESSAGE) + self.assert_state(LIVING_ROOM) + self.assert_distance(1) + + time = dt.utcnow() + datetime.timedelta(seconds=7) + with patch('homeassistant.helpers.condition.dt_util.utcnow', + return_value=time): + self.send_message(BEDROOM_TOPIC, FAR_MESSAGE) + self.assert_state(BEDROOM) + self.assert_distance(10) diff --git a/tests/components/sensor/test_template.py b/tests/components/sensor/test_template.py index 0170e6b3dfa..d85c9164851 100644 --- a/tests/components/sensor/test_template.py +++ b/tests/components/sensor/test_template.py @@ -1,5 +1,5 @@ """The test for the Template sensor platform.""" -import homeassistant.components.sensor as sensor +import homeassistant.bootstrap as bootstrap from tests.common import get_test_home_assistant @@ -17,7 +17,7 @@ class TestTemplateSensor: def test_template(self): """Test template.""" - assert sensor.setup(self.hass, { + assert bootstrap.setup_component(self.hass, 'sensor', { 'sensor': { 'platform': 'template', 'sensors': { @@ -39,7 +39,7 @@ class TestTemplateSensor: def test_template_syntax_error(self): """Test templating syntax error.""" - assert sensor.setup(self.hass, { + assert not bootstrap.setup_component(self.hass, 'sensor', { 'sensor': { 'platform': 'template', 'sensors': { @@ -50,15 +50,11 @@ class TestTemplateSensor: } } }) - - self.hass.states.set('sensor.test_state', 'Works') - self.hass.pool.block_till_done() - state = self.hass.states.get('sensor.test_template_sensor') - assert state.state == 'unknown' + assert self.hass.states.all() == [] def test_template_attribute_missing(self): """Test missing attribute template.""" - assert sensor.setup(self.hass, { + assert bootstrap.setup_component(self.hass, 'sensor', { 'sensor': { 'platform': 'template', 'sensors': { @@ -75,7 +71,7 @@ class TestTemplateSensor: def test_invalid_name_does_not_create(self): """Test invalid name.""" - assert sensor.setup(self.hass, { + assert not bootstrap.setup_component(self.hass, 'sensor', { 'sensor': { 'platform': 'template', 'sensors': { @@ -90,7 +86,7 @@ class TestTemplateSensor: def test_invalid_sensor_does_not_create(self): """Test invalid sensor.""" - assert sensor.setup(self.hass, { + assert not bootstrap.setup_component(self.hass, 'sensor', { 'sensor': { 'platform': 'template', 'sensors': { @@ -102,7 +98,7 @@ class TestTemplateSensor: def test_no_sensors_does_not_create(self): """Test no sensors.""" - assert sensor.setup(self.hass, { + assert not bootstrap.setup_component(self.hass, 'sensor', { 'sensor': { 'platform': 'template' } @@ -111,7 +107,7 @@ class TestTemplateSensor: def test_missing_template_does_not_create(self): """Test missing template.""" - assert sensor.setup(self.hass, { + assert not bootstrap.setup_component(self.hass, 'sensor', { 'sensor': { 'platform': 'template', 'sensors': { diff --git a/tests/components/sensor/test_wunderground.py b/tests/components/sensor/test_wunderground.py new file mode 100644 index 00000000000..3aea771012e --- /dev/null +++ b/tests/components/sensor/test_wunderground.py @@ -0,0 +1,138 @@ +"""The tests for the WUnderground platform.""" +import unittest + +from homeassistant.components.sensor import wunderground +from homeassistant.const import TEMP_CELSIUS +from homeassistant import core as ha + +VALID_CONFIG_PWS = { + 'platform': 'wunderground', + 'api_key': 'foo', + 'pws_id': 'bar', + 'monitored_conditions': [ + 'weather', 'feelslike_c' + ] +} + +VALID_CONFIG = { + 'platform': 'wunderground', + 'api_key': 'foo', + 'monitored_conditions': [ + 'weather', 'feelslike_c' + ] +} + +FEELS_LIKE = '40' +WEATHER = 'Clear' +ICON_URL = 'http://icons.wxug.com/i/c/k/clear.gif' + + +def mocked_requests_get(*args, **kwargs): + """Mock requests.get invocations.""" + class MockResponse: + """Class to represent a mocked response.""" + + def __init__(self, json_data, status_code): + """Initialize the mock response class.""" + self.json_data = json_data + self.status_code = status_code + + def json(self): + """Return the json of the response.""" + return self.json_data + + if str(args[0]).startswith('http://api.wunderground.com/api/foo/'): + return MockResponse({ + "response": { + "version": "0.1", + "termsofService": + "http://www.wunderground.com/weather/api/d/terms.html", + "features": { + "conditions": 1 + } + }, "current_observation": { + "image": { + "url": + 'http://icons.wxug.com/graphics/wu2/logo_130x80.png', + "title": "Weather Underground", + "link": "http://www.wunderground.com" + }, + "feelslike_c": FEELS_LIKE, + "weather": WEATHER, + "icon_url": ICON_URL + } + }, 200) + else: + return MockResponse({ + "response": { + "version": "0.1", + "termsofService": + "http://www.wunderground.com/weather/api/d/terms.html", + "features": {}, + "error": { + "type": "keynotfound", + "description": "this key does not exist" + } + } + }, 200) + + +class TestWundergroundSetup(unittest.TestCase): + """Test the WUnderground 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.DEVICES = [] + self.hass = ha.HomeAssistant() + self.key = 'foo' + self.config = VALID_CONFIG_PWS + self.lat = 37.8267 + self.lon = -122.423 + self.hass.config.latitude = self.lat + self.hass.config.longitude = self.lon + + @unittest.mock.patch('requests.get', side_effect=mocked_requests_get) + def test_setup(self, req_mock): + """Test that the component is loaded if passed in PWS Id.""" + self.assertTrue( + wunderground.setup_platform(self.hass, VALID_CONFIG_PWS, + self.add_devices, None)) + self.assertTrue( + wunderground.setup_platform(self.hass, VALID_CONFIG, + self.add_devices, + None)) + invalid_config = { + 'platform': 'wunderground', + 'api_key': 'BOB', + 'pws_id': 'bar', + 'monitored_conditions': [ + 'weather', 'feelslike_c' + ] + } + + self.assertFalse( + wunderground.setup_platform(self.hass, invalid_config, + self.add_devices, None)) + + @unittest.mock.patch('requests.get', side_effect=mocked_requests_get) + def test_sensor(self, req_mock): + """Test the WUnderground sensor class and methods.""" + wunderground.setup_platform(self.hass, VALID_CONFIG, self.add_devices, + None) + for device in self.DEVICES: + self.assertTrue(str(device.name).startswith('PWS_')) + if device.name == 'PWS_weather': + self.assertEqual(ICON_URL, device.entity_picture) + self.assertEqual(WEATHER, device.state) + self.assertIsNone(device.unit_of_measurement) + else: + self.assertIsNone(device.entity_picture) + self.assertEqual(FEELS_LIKE, device.state) + self.assertEqual(TEMP_CELSIUS, device.unit_of_measurement) diff --git a/tests/components/switch/test_template.py b/tests/components/switch/test_template.py index 1b8d6cf5ab9..2d8cf636217 100644 --- a/tests/components/switch/test_template.py +++ b/tests/components/switch/test_template.py @@ -1,6 +1,6 @@ """The tests for the Template switch platform.""" +import homeassistant.bootstrap as bootstrap import homeassistant.components as core -import homeassistant.components.switch as switch from homeassistant.const import ( STATE_ON, @@ -18,6 +18,7 @@ class TestTemplateSwitch: self.calls = [] def record_call(service): + """Track function calls..""" self.calls.append(service) self.hass.services.register('test', 'automation', record_call) @@ -28,7 +29,7 @@ class TestTemplateSwitch: def test_template_state_text(self): """"Test the state text of a template.""" - assert switch.setup(self.hass, { + assert bootstrap.setup_component(self.hass, 'switch', { 'switch': { 'platform': 'template', 'switches': { @@ -62,7 +63,7 @@ class TestTemplateSwitch: def test_template_state_boolean_on(self): """Test the setting of the state with boolean on.""" - assert switch.setup(self.hass, { + assert bootstrap.setup_component(self.hass, 'switch', { 'switch': { 'platform': 'template', 'switches': { @@ -87,7 +88,7 @@ class TestTemplateSwitch: def test_template_state_boolean_off(self): """Test the setting of the state with off.""" - assert switch.setup(self.hass, { + assert bootstrap.setup_component(self.hass, 'switch', { 'switch': { 'platform': 'template', 'switches': { @@ -112,7 +113,7 @@ class TestTemplateSwitch: def test_template_syntax_error(self): """Test templating syntax error.""" - assert switch.setup(self.hass, { + assert not bootstrap.setup_component(self.hass, 'switch', { 'switch': { 'platform': 'template', 'switches': { @@ -131,15 +132,11 @@ class TestTemplateSwitch: } } }) - - state = self.hass.states.set('switch.test_state', STATE_ON) - self.hass.pool.block_till_done() - state = self.hass.states.get('switch.test_template_switch') - assert state.state == 'unavailable' + assert self.hass.states.all() == [] def test_invalid_name_does_not_create(self): """Test invalid name.""" - assert switch.setup(self.hass, { + assert not bootstrap.setup_component(self.hass, 'switch', { 'switch': { 'platform': 'template', 'switches': { @@ -161,8 +158,8 @@ class TestTemplateSwitch: assert self.hass.states.all() == [] def test_invalid_switch_does_not_create(self): - """Test invalid name.""" - assert switch.setup(self.hass, { + """Test invalid switch.""" + assert not bootstrap.setup_component(self.hass, 'switch', { 'switch': { 'platform': 'template', 'switches': { @@ -174,7 +171,7 @@ class TestTemplateSwitch: def test_no_switches_does_not_create(self): """Test if there are no switches no creation.""" - assert switch.setup(self.hass, { + assert not bootstrap.setup_component(self.hass, 'switch', { 'switch': { 'platform': 'template' } @@ -183,7 +180,7 @@ class TestTemplateSwitch: def test_missing_template_does_not_create(self): """Test missing template.""" - assert switch.setup(self.hass, { + assert not bootstrap.setup_component(self.hass, 'switch', { 'switch': { 'platform': 'template', 'switches': { @@ -206,7 +203,7 @@ class TestTemplateSwitch: def test_missing_on_does_not_create(self): """Test missing on.""" - assert switch.setup(self.hass, { + assert not bootstrap.setup_component(self.hass, 'switch', { 'switch': { 'platform': 'template', 'switches': { @@ -229,7 +226,7 @@ class TestTemplateSwitch: def test_missing_off_does_not_create(self): """Test missing off.""" - assert switch.setup(self.hass, { + assert not bootstrap.setup_component(self.hass, 'switch', { 'switch': { 'platform': 'template', 'switches': { @@ -252,7 +249,7 @@ class TestTemplateSwitch: def test_on_action(self): """Test on action.""" - assert switch.setup(self.hass, { + assert bootstrap.setup_component(self.hass, 'switch', { 'switch': { 'platform': 'template', 'switches': { @@ -279,11 +276,11 @@ class TestTemplateSwitch: core.switch.turn_on(self.hass, 'switch.test_template_switch') self.hass.pool.block_till_done() - assert 1 == len(self.calls) + assert len(self.calls) == 1 def test_off_action(self): """Test off action.""" - assert switch.setup(self.hass, { + assert bootstrap.setup_component(self.hass, 'switch', { 'switch': { 'platform': 'template', 'switches': { @@ -311,4 +308,4 @@ class TestTemplateSwitch: core.switch.turn_off(self.hass, 'switch.test_template_switch') self.hass.pool.block_till_done() - assert 1 == len(self.calls) + assert len(self.calls) == 1 diff --git a/tests/components/test_emulated_hue.py b/tests/components/test_emulated_hue.py new file mode 100755 index 00000000000..c9efa6e9fda --- /dev/null +++ b/tests/components/test_emulated_hue.py @@ -0,0 +1,445 @@ +import time +import json +import threading +import asyncio + +import unittest +import requests + +from homeassistant import bootstrap, const, core +import homeassistant.components as core_components +from homeassistant.components import emulated_hue, http, light, mqtt +from homeassistant.const import STATE_ON, STATE_OFF +from homeassistant.components.emulated_hue import ( + HUE_API_STATE_ON, HUE_API_STATE_BRI +) + +from tests.common import get_test_instance_port, get_test_home_assistant + +HTTP_SERVER_PORT = get_test_instance_port() +BRIDGE_SERVER_PORT = get_test_instance_port() +MQTT_BROKER_PORT = get_test_instance_port() + +BRIDGE_URL_BASE = "http://127.0.0.1:{}".format(BRIDGE_SERVER_PORT) + "{}" +JSON_HEADERS = {const.HTTP_HEADER_CONTENT_TYPE: const.CONTENT_TYPE_JSON} + +mqtt_broker = None + + +def setUpModule(): + global mqtt_broker + + mqtt_broker = MQTTBroker('127.0.0.1', MQTT_BROKER_PORT) + mqtt_broker.start() + + +def tearDownModule(): + global mqtt_broker + + mqtt_broker.stop() + + +def setup_hass_instance(emulated_hue_config): + hass = get_test_home_assistant() + + # We need to do this to get access to homeassistant/turn_(on,off) + core_components.setup(hass, {core.DOMAIN: {}}) + + bootstrap.setup_component( + hass, http.DOMAIN, + {http.DOMAIN: {http.CONF_SERVER_PORT: HTTP_SERVER_PORT}}) + + bootstrap.setup_component(hass, emulated_hue.DOMAIN, emulated_hue_config) + + return hass + + +def start_hass_instance(hass): + hass.start() + time.sleep(0.05) + + +class TestEmulatedHue(unittest.TestCase): + hass = None + + @classmethod + def setUpClass(cls): + cls.hass = setup_hass_instance({ + emulated_hue.DOMAIN: { + emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT + }}) + + start_hass_instance(cls.hass) + + @classmethod + def tearDownClass(cls): + cls.hass.stop() + + def test_description_xml(self): + import xml.etree.ElementTree as ET + + result = requests.get( + BRIDGE_URL_BASE.format('/description.xml'), timeout=5) + + self.assertEqual(result.status_code, 200) + self.assertTrue('text/xml' in result.headers['content-type']) + + # Make sure the XML is parsable + try: + ET.fromstring(result.text) + except: + self.fail('description.xml is not valid XML!') + + def test_create_username(self): + request_json = {'devicetype': 'my_device'} + + result = requests.post( + BRIDGE_URL_BASE.format('/api'), data=json.dumps(request_json), + timeout=5) + + self.assertEqual(result.status_code, 200) + self.assertTrue('application/json' in result.headers['content-type']) + + resp_json = result.json() + success_json = resp_json[0] + + self.assertTrue('success' in success_json) + self.assertTrue('username' in success_json['success']) + + def test_valid_username_request(self): + request_json = {'invalid_key': 'my_device'} + + result = requests.post( + BRIDGE_URL_BASE.format('/api'), data=json.dumps(request_json), + timeout=5) + + self.assertEqual(result.status_code, 400) + + +class TestEmulatedHueExposedByDefault(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.hass = setup_hass_instance({ + emulated_hue.DOMAIN: { + emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT, + emulated_hue.CONF_EXPOSE_BY_DEFAULT: True + } + }) + + bootstrap.setup_component(cls.hass, mqtt.DOMAIN, { + 'mqtt': { + 'broker': '127.0.0.1', + 'port': MQTT_BROKER_PORT + } + }) + + bootstrap.setup_component(cls.hass, light.DOMAIN, { + 'light': [ + { + 'platform': 'mqtt', + 'name': 'Office light', + 'state_topic': 'office/rgb1/light/status', + 'command_topic': 'office/rgb1/light/switch', + 'brightness_state_topic': 'office/rgb1/brightness/status', + 'brightness_command_topic': 'office/rgb1/brightness/set', + 'optimistic': True + }, + { + 'platform': 'mqtt', + 'name': 'Bedroom light', + 'state_topic': 'bedroom/rgb1/light/status', + 'command_topic': 'bedroom/rgb1/light/switch', + 'brightness_state_topic': 'bedroom/rgb1/brightness/status', + 'brightness_command_topic': 'bedroom/rgb1/brightness/set', + 'optimistic': True + }, + { + 'platform': 'mqtt', + 'name': 'Kitchen light', + 'state_topic': 'kitchen/rgb1/light/status', + 'command_topic': 'kitchen/rgb1/light/switch', + 'brightness_state_topic': 'kitchen/rgb1/brightness/status', + 'brightness_command_topic': 'kitchen/rgb1/brightness/set', + 'optimistic': True + } + ] + }) + + start_hass_instance(cls.hass) + + # Kitchen light is explicitly excluded from being exposed + kitchen_light_entity = cls.hass.states.get('light.kitchen_light') + attrs = dict(kitchen_light_entity.attributes) + attrs[emulated_hue.ATTR_EMULATED_HUE] = False + cls.hass.states.set( + kitchen_light_entity.entity_id, kitchen_light_entity.state, + attributes=attrs) + + @classmethod + def tearDownClass(cls): + cls.hass.stop() + + def test_discover_lights(self): + result = requests.get( + BRIDGE_URL_BASE.format('/api/username/lights'), timeout=5) + + self.assertEqual(result.status_code, 200) + self.assertTrue('application/json' in result.headers['content-type']) + + result_json = result.json() + + # Make sure the lights we added to the config are there + self.assertTrue('light.office_light' in result_json) + self.assertTrue('light.bedroom_light' in result_json) + self.assertTrue('light.kitchen_light' not in result_json) + + def test_get_light_state(self): + # Turn office light on and set to 127 brightness + self.hass.services.call( + light.DOMAIN, const.SERVICE_TURN_ON, + { + const.ATTR_ENTITY_ID: 'light.office_light', + light.ATTR_BRIGHTNESS: 127 + }, + blocking=True) + + office_json = self.perform_get_light_state('light.office_light', 200) + + self.assertEqual(office_json['state'][HUE_API_STATE_ON], True) + self.assertEqual(office_json['state'][HUE_API_STATE_BRI], 127) + + # Turn bedroom light off + self.hass.services.call( + light.DOMAIN, const.SERVICE_TURN_OFF, + { + const.ATTR_ENTITY_ID: 'light.bedroom_light' + }, + blocking=True) + + bedroom_json = self.perform_get_light_state('light.bedroom_light', 200) + + self.assertEqual(bedroom_json['state'][HUE_API_STATE_ON], False) + self.assertEqual(bedroom_json['state'][HUE_API_STATE_BRI], 0) + + # Make sure kitchen light isn't accessible + kitchen_url = '/api/username/lights/{}'.format('light.kitchen_light') + kitchen_result = requests.get( + BRIDGE_URL_BASE.format(kitchen_url), timeout=5) + + self.assertEqual(kitchen_result.status_code, 404) + + def test_put_light_state(self): + self.perform_put_test_on_office_light() + + # Turn the bedroom light on first + self.hass.services.call( + light.DOMAIN, const.SERVICE_TURN_ON, + {const.ATTR_ENTITY_ID: 'light.bedroom_light', + light.ATTR_BRIGHTNESS: 153}, + blocking=True) + + bedroom_light = self.hass.states.get('light.bedroom_light') + self.assertEqual(bedroom_light.state, STATE_ON) + self.assertEqual(bedroom_light.attributes[light.ATTR_BRIGHTNESS], 153) + + # Go through the API to turn it off + bedroom_result = self.perform_put_light_state( + 'light.bedroom_light', False) + + bedroom_result_json = bedroom_result.json() + + self.assertEqual(bedroom_result.status_code, 200) + self.assertTrue( + 'application/json' in bedroom_result.headers['content-type']) + + self.assertEqual(len(bedroom_result_json), 1) + + # Check to make sure the state changed + bedroom_light = self.hass.states.get('light.bedroom_light') + self.assertEqual(bedroom_light.state, STATE_OFF) + + # Make sure we can't change the kitchen light state + kitchen_result = self.perform_put_light_state( + 'light.kitchen_light', True) + self.assertEqual(kitchen_result.status_code, 404) + + def test_put_with_form_urlencoded_content_type(self): + # Needed for Alexa + self.perform_put_test_on_office_light( + 'application/x-www-form-urlencoded') + + # Make sure we fail gracefully when we can't parse the data + data = {'key1': 'value1', 'key2': 'value2'} + result = requests.put( + BRIDGE_URL_BASE.format( + '/api/username/lights/{}/state'.format("light.office_light")), + data=data) + + self.assertEqual(result.status_code, 400) + + def test_entity_not_found(self): + result = requests.get( + BRIDGE_URL_BASE.format( + '/api/username/lights/{}'.format("not.existant_entity")), + timeout=5) + + self.assertEqual(result.status_code, 404) + + result = requests.put( + BRIDGE_URL_BASE.format( + '/api/username/lights/{}/state'.format("non.existant_entity")), + timeout=5) + + self.assertEqual(result.status_code, 404) + + def test_allowed_methods(self): + result = requests.get( + BRIDGE_URL_BASE.format( + '/api/username/lights/{}/state'.format("light.office_light"))) + + self.assertEqual(result.status_code, 405) + + result = requests.put( + BRIDGE_URL_BASE.format( + '/api/username/lights/{}'.format("light.office_light")), + data={'key1': 'value1'}) + + self.assertEqual(result.status_code, 405) + + result = requests.put( + BRIDGE_URL_BASE.format('/api/username/lights'), + data={'key1': 'value1'}) + + self.assertEqual(result.status_code, 405) + + def test_proper_put_state_request(self): + # Test proper on value parsing + result = requests.put( + BRIDGE_URL_BASE.format( + '/api/username/lights/{}/state'.format("light.office_light")), + data=json.dumps({HUE_API_STATE_ON: 1234})) + + self.assertEqual(result.status_code, 400) + + # Test proper brightness value parsing + result = requests.put( + BRIDGE_URL_BASE.format( + '/api/username/lights/{}/state'.format("light.office_light")), + data=json.dumps({ + HUE_API_STATE_ON: True, + HUE_API_STATE_BRI: 'Hello world!' + })) + + self.assertEqual(result.status_code, 400) + + def perform_put_test_on_office_light(self, + content_type='application/json'): + # Turn the office light off first + self.hass.services.call( + light.DOMAIN, const.SERVICE_TURN_OFF, + {const.ATTR_ENTITY_ID: 'light.office_light'}, + blocking=True) + + office_light = self.hass.states.get('light.office_light') + self.assertEqual(office_light.state, STATE_OFF) + + # Go through the API to turn it on + office_result = self.perform_put_light_state( + 'light.office_light', True, 56, content_type) + + office_result_json = office_result.json() + + self.assertEqual(office_result.status_code, 200) + self.assertTrue( + 'application/json' in office_result.headers['content-type']) + + self.assertEqual(len(office_result_json), 2) + + # Check to make sure the state changed + office_light = self.hass.states.get('light.office_light') + self.assertEqual(office_light.state, STATE_ON) + self.assertEqual(office_light.attributes[light.ATTR_BRIGHTNESS], 56) + + def perform_get_light_state(self, entity_id, expected_status): + result = requests.get( + BRIDGE_URL_BASE.format( + '/api/username/lights/{}'.format(entity_id)), timeout=5) + + self.assertEqual(result.status_code, expected_status) + + if expected_status == 200: + self.assertTrue( + 'application/json' in result.headers['content-type']) + + return result.json() + + return None + + def perform_put_light_state(self, entity_id, is_on, brightness=None, + content_type='application/json'): + url = BRIDGE_URL_BASE.format( + '/api/username/lights/{}/state'.format(entity_id)) + + req_headers = {'Content-Type': content_type} + + data = {HUE_API_STATE_ON: is_on} + + if brightness is not None: + data[HUE_API_STATE_BRI] = brightness + + result = requests.put( + url, data=json.dumps(data), timeout=5, headers=req_headers) + return result + + +class MQTTBroker(object): + """Encapsulates an embedded MQTT broker.""" + + def __init__(self, host, port): + """Initialize a new instance.""" + from hbmqtt.broker import Broker + + self._loop = asyncio.new_event_loop() + + hbmqtt_config = { + 'listeners': { + 'default': { + 'max-connections': 50000, + 'type': 'tcp', + 'bind': '{}:{}'.format(host, port) + } + }, + 'auth': { + 'plugins': ['auth.anonymous'], + 'allow-anonymous': True + } + } + + self._broker = Broker(config=hbmqtt_config, loop=self._loop) + + self._thread = threading.Thread(target=self._run_loop) + self._started_ev = threading.Event() + + def start(self): + """Start the broker.""" + self._thread.start() + self._started_ev.wait() + + def stop(self): + """Stop the broker.""" + self._loop.call_soon_threadsafe(asyncio.async, self._broker.shutdown()) + self._loop.call_soon_threadsafe(self._loop.stop) + self._thread.join() + + def _run_loop(self): + asyncio.set_event_loop(self._loop) + self._loop.run_until_complete(self._broker_coroutine()) + + self._started_ev.set() + + self._loop.run_forever() + self._loop.close() + + @asyncio.coroutine + def _broker_coroutine(self): + yield from self._broker.start() diff --git a/tests/components/test_graphite.py b/tests/components/test_graphite.py index 9e9ea837dfe..bb60d81a155 100644 --- a/tests/components/test_graphite.py +++ b/tests/components/test_graphite.py @@ -2,15 +2,15 @@ import socket import unittest from unittest import mock +from unittest.mock import patch import homeassistant.core as ha import homeassistant.components.graphite as graphite from homeassistant.const import ( - EVENT_STATE_CHANGED, - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, + EVENT_STATE_CHANGED, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, STATE_ON, STATE_OFF) - from tests.common import get_test_home_assistant +from homeassistant import bootstrap class TestGraphite(unittest.TestCase): @@ -19,22 +19,22 @@ class TestGraphite(unittest.TestCase): def setup_method(self, method): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - self.hass.config.latitude = 32.87336 - self.hass.config.longitude = 117.22743 self.gf = graphite.GraphiteFeeder(self.hass, 'foo', 123, 'ha') def teardown_method(self, method): """Stop everything that was started.""" self.hass.stop() - @mock.patch('homeassistant.components.graphite.GraphiteFeeder') - def test_minimal_config(self, mock_gf): - """Test setup with minimal configuration.""" - self.assertTrue(graphite.setup(self.hass, {})) - mock_gf.assert_called_once_with(self.hass, 'localhost', 2003, 'ha') + @patch('socket.socket') + def test_setup(self, mock_socket): + """Test setup.""" + assert bootstrap.setup_component(self.hass, 'graphite', + {'graphite': {}}) + mock_socket.assert_called_once_with(socket.AF_INET, socket.SOCK_STREAM) - @mock.patch('homeassistant.components.graphite.GraphiteFeeder') - def test_full_config(self, mock_gf): + @patch('socket.socket') + @patch('homeassistant.components.graphite.GraphiteFeeder') + def test_full_config(self, mock_gf, mock_socket): """Test setup with full configuration.""" config = { 'graphite': { @@ -43,20 +43,25 @@ class TestGraphite(unittest.TestCase): 'prefix': 'me', } } + self.assertTrue(graphite.setup(self.hass, config)) mock_gf.assert_called_once_with(self.hass, 'foo', 123, 'me') + mock_socket.assert_called_once_with(socket.AF_INET, socket.SOCK_STREAM) - @mock.patch('homeassistant.components.graphite.GraphiteFeeder') - def test_config_bad_port(self, mock_gf): + @patch('socket.socket') + @patch('homeassistant.components.graphite.GraphiteFeeder') + def test_config_port(self, mock_gf, mock_socket): """Test setup with invalid port.""" config = { 'graphite': { 'host': 'foo', - 'port': 'wrong', + 'port': 2003, } } - self.assertFalse(graphite.setup(self.hass, config)) - self.assertFalse(mock_gf.called) + + self.assertTrue(graphite.setup(self.hass, config)) + self.assertTrue(mock_gf.called) + mock_socket.assert_called_once_with(socket.AF_INET, socket.SOCK_STREAM) def test_subscribe(self): """Test the subscription.""" @@ -87,7 +92,7 @@ class TestGraphite(unittest.TestCase): self.gf.event_listener('foo') mock_queue.put.assert_called_once_with('foo') - @mock.patch('time.time') + @patch('time.time') def test_report_attributes(self, mock_time): """Test the reporting with attributes.""" mock_time.return_value = 12345 @@ -96,19 +101,21 @@ class TestGraphite(unittest.TestCase): 'baz': True, 'bat': 'NaN', } + expected = [ 'ha.entity.state 0.000000 12345', 'ha.entity.foo 1.000000 12345', 'ha.entity.bar 2.000000 12345', 'ha.entity.baz 1.000000 12345', ] + state = mock.MagicMock(state=0, attributes=attrs) with mock.patch.object(self.gf, '_send_to_graphite') as mock_send: self.gf._report_attributes('entity', state) actual = mock_send.call_args_list[0][0][0].split('\n') self.assertEqual(sorted(expected), sorted(actual)) - @mock.patch('time.time') + @patch('time.time') def test_report_with_string_state(self, mock_time): """Test the reporting with strings.""" mock_time.return_value = 12345 @@ -116,13 +123,14 @@ class TestGraphite(unittest.TestCase): 'ha.entity.foo 1.000000 12345', 'ha.entity.state 1.000000 12345', ] + state = mock.MagicMock(state='above_horizon', attributes={'foo': 1.0}) with mock.patch.object(self.gf, '_send_to_graphite') as mock_send: self.gf._report_attributes('entity', state) actual = mock_send.call_args_list[0][0][0].split('\n') self.assertEqual(sorted(expected), sorted(actual)) - @mock.patch('time.time') + @patch('time.time') def test_report_with_binary_state(self, mock_time): """Test the reporting with binary state.""" mock_time.return_value = 12345 @@ -142,7 +150,7 @@ class TestGraphite(unittest.TestCase): actual = mock_send.call_args_list[0][0][0].split('\n') self.assertEqual(sorted(expected), sorted(actual)) - @mock.patch('time.time') + @patch('time.time') def test_send_to_graphite_errors(self, mock_time): """Test the sending with errors.""" mock_time.return_value = 12345 @@ -153,7 +161,7 @@ class TestGraphite(unittest.TestCase): mock_send.side_effect = socket.gaierror self.gf._report_attributes('entity', state) - @mock.patch('socket.socket') + @patch('socket.socket') def test_send_to_graphite(self, mock_socket): """Test the sending of data.""" self.gf._send_to_graphite('foo') diff --git a/tests/components/test_http.py b/tests/components/test_http.py index 7a6d7af673f..ef491a91b36 100644 --- a/tests/components/test_http.py +++ b/tests/components/test_http.py @@ -75,8 +75,8 @@ class TestHttp: def test_access_with_password_in_header(self, caplog): """Test access with password in URL.""" # Hide logging from requests package that we use to test logging - caplog.setLevel(logging.WARNING, - logger='requests.packages.urllib3.connectionpool') + caplog.set_level(logging.WARNING, + logger='requests.packages.urllib3.connectionpool') req = requests.get( _url(const.URL_API), @@ -84,7 +84,7 @@ class TestHttp: assert req.status_code == 200 - logs = caplog.text() + logs = caplog.text # assert const.URL_API in logs assert API_PASSWORD not in logs @@ -99,15 +99,15 @@ class TestHttp: def test_access_with_password_in_url(self, caplog): """Test access with password in URL.""" # Hide logging from requests package that we use to test logging - caplog.setLevel(logging.WARNING, - logger='requests.packages.urllib3.connectionpool') + caplog.set_level(logging.WARNING, + logger='requests.packages.urllib3.connectionpool') req = requests.get(_url(const.URL_API), params={'api_password': API_PASSWORD}) assert req.status_code == 200 - logs = caplog.text() + logs = caplog.text # assert const.URL_API in logs assert API_PASSWORD not in logs diff --git a/tests/components/test_splunk.py b/tests/components/test_splunk.py index e4e9cf96ee0..4b7dd5f0732 100644 --- a/tests/components/test_splunk.py +++ b/tests/components/test_splunk.py @@ -19,6 +19,7 @@ class TestSplunk(unittest.TestCase): 'use_ssl': 'False', } } + hass = mock.MagicMock() self.assertTrue(splunk.setup(hass, config)) self.assertTrue(hass.bus.listen.called) @@ -33,6 +34,7 @@ class TestSplunk(unittest.TestCase): 'token': 'secret', } } + hass = mock.MagicMock() self.assertTrue(splunk.setup(hass, config)) self.assertTrue(hass.bus.listen.called) @@ -48,8 +50,10 @@ class TestSplunk(unittest.TestCase): 'splunk': { 'host': 'host', 'token': 'secret', + 'port': 8088, } } + self.hass = mock.MagicMock() splunk.setup(self.hass, config) self.handler_method = self.hass.bus.listen.call_args_list[0][0][1] @@ -65,14 +69,16 @@ class TestSplunk(unittest.TestCase): '1.0': 1.0, STATE_ON: 1, STATE_OFF: 0, - 'foo': 'foo'} + 'foo': 'foo', + } + for in_, out in valid.items(): state = mock.MagicMock(state=in_, domain='fake', object_id='entity', attributes={}) - event = mock.MagicMock(data={'new_state': state}, - time_fired=12345) + event = mock.MagicMock(data={'new_state': state}, time_fired=12345) + body = [{ 'domain': 'fake', 'entity_id': 'entity', @@ -80,6 +86,7 @@ class TestSplunk(unittest.TestCase): 'time': '12345', 'value': out, }] + payload = {'host': 'http://host:8088/services/collector/event', 'event': body} self.handler_method(event) diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 7f94ab53b23..14d80d9104d 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -47,6 +47,33 @@ def test_longitude(): schema(value) +def test_port(): + """Test TCP/UDP network port.""" + schema = vol.Schema(cv.port) + + for value in ('invalid', None, -1, 0, 80000, '81000'): + with pytest.raises(vol.MultipleInvalid): + schema(value) + + for value in ('1000', 21, 24574): + schema(value) + + +def test_url(): + """Test URL.""" + schema = vol.Schema(cv.url) + + for value in ('invalid', None, 100, 'htp://ha.io', 'http//ha.io', + 'http://??,**', 'https://??,**'): + with pytest.raises(vol.MultipleInvalid): + schema(value) + + for value in ('http://localhost', 'https://localhost/test/index.html', + 'http://home-assistant.io', 'http://home-assistant.io/test/', + 'https://community.home-assistant.io/'): + assert schema(value) + + def test_platform_config(): """Test platform config validation.""" for value in ( diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py new file mode 100644 index 00000000000..fbd80760c12 --- /dev/null +++ b/tests/scripts/test_check_config.py @@ -0,0 +1,154 @@ +"""Test check_config script.""" +import unittest +import logging +import os + +import homeassistant.scripts.check_config as check_config +from tests.common import patch_yaml_files, get_test_config_dir + +_LOGGER = logging.getLogger(__name__) + +BASE_CONFIG = ( + 'homeassistant:\n' + ' name: Home\n' + ' latitude: -26.107361\n' + ' longitude: 28.054500\n' + ' elevation: 1600\n' + ' unit_system: metric\n' + ' time_zone: GMT\n' + '\n\n' +) + + +def change_yaml_files(check_dict): + """Change the ['yaml_files'] property and remove the config path. + + Also removes other files like service.yaml that gets loaded + """ + root = get_test_config_dir() + keys = check_dict['yaml_files'].keys() + check_dict['yaml_files'] = [] + for key in sorted(keys): + if not key.startswith('/'): + check_dict['yaml_files'].append(key) + if key.startswith(root): + check_dict['yaml_files'].append('...' + key[len(root):]) + + +def tearDownModule(self): # pylint: disable=invalid-name + """Clean files.""" + # .HA_VERSION created during bootstrap's config update + path = get_test_config_dir('.HA_VERSION') + if os.path.isfile(path): + os.remove(path) + + +class TestCheckConfig(unittest.TestCase): + """Tests for the homeassistant.scripts.check_config module.""" + + # pylint: disable=no-self-use,invalid-name + def test_config_platform_valid(self): + """Test a valid platform setup.""" + files = { + 'light.yaml': BASE_CONFIG + 'light:\n platform: hue', + } + with patch_yaml_files(files): + res = check_config.check(get_test_config_dir('light.yaml')) + change_yaml_files(res) + self.assertDictEqual({ + 'components': {'light': [{'platform': 'hue'}]}, + 'except': {}, + 'secret_cache': {}, + 'secrets': {}, + 'yaml_files': ['.../light.yaml'] + }, res) + + def test_config_component_platform_fail_validation(self): + """Test errors if component & platform not found.""" + files = { + 'component.yaml': BASE_CONFIG + 'http:\n password: err123', + } + with patch_yaml_files(files): + res = check_config.check(get_test_config_dir('component.yaml')) + change_yaml_files(res) + self.assertDictEqual({ + 'components': {}, + 'except': {'http': {'password': 'err123'}}, + 'secret_cache': {}, + 'secrets': {}, + 'yaml_files': ['.../component.yaml'] + }, res) + + files = { + 'platform.yaml': (BASE_CONFIG + 'mqtt:\n\n' + 'light:\n platform: mqtt_json'), + } + with patch_yaml_files(files): + res = check_config.check(get_test_config_dir('platform.yaml')) + change_yaml_files(res) + self.assertDictEqual({ + 'components': {'mqtt': {'keepalive': 60, 'port': 1883, + 'protocol': '3.1.1'}}, + 'except': {'light.mqtt_json': {'platform': 'mqtt_json'}}, + 'secret_cache': {}, + 'secrets': {}, + 'yaml_files': ['.../platform.yaml'] + }, res) + + def test_component_platform_not_found(self): + """Test errors if component or platform not found.""" + files = { + 'badcomponent.yaml': BASE_CONFIG + 'beer:', + 'badplatform.yaml': BASE_CONFIG + 'light:\n platform: beer', + } + with patch_yaml_files(files): + res = check_config.check(get_test_config_dir('badcomponent.yaml')) + change_yaml_files(res) + self.assertDictEqual({ + 'components': {}, + 'except': {check_config.ERROR_STR: + ['Component not found: beer']}, + 'secret_cache': {}, + 'secrets': {}, + 'yaml_files': ['.../badcomponent.yaml'] + }, res) + + res = check_config.check(get_test_config_dir('badplatform.yaml')) + change_yaml_files(res) + self.assertDictEqual({ + 'components': {}, + 'except': {check_config.ERROR_STR: + ['Platform not found: light.beer']}, + 'secret_cache': {}, + 'secrets': {}, + 'yaml_files': ['.../badplatform.yaml'] + }, res) + + def test_secrets(self): + """Test secrets config checking method.""" + files = { + get_test_config_dir('secret.yaml'): ( + BASE_CONFIG + + 'http:\n' + ' api_password: !secret http_pw'), + 'secrets.yaml': ('logger: debug\n' + 'http_pw: abc123'), + } + self.maxDiff = None + + with patch_yaml_files(files): + res = check_config.check(get_test_config_dir('secret.yaml')) + change_yaml_files(res) + + # convert secrets OrderedDict to dict for assertequal + for key, val in res['secret_cache'].items(): + res['secret_cache'][key] = dict(val) + + self.assertDictEqual({ + 'components': {'http': {'api_password': 'abc123', + 'server_port': 8123}}, + 'except': {}, + 'secret_cache': {'secrets.yaml': {'http_pw': 'abc123'}}, + 'secrets': {'http_pw': 'abc123'}, + 'yaml_files': ['.../secret.yaml', 'secrets.yaml'] + }, res) diff --git a/tests/util/test_yaml.py b/tests/util/test_yaml.py index 7bede7edca9..4ce0def08ac 100644 --- a/tests/util/test_yaml.py +++ b/tests/util/test_yaml.py @@ -3,6 +3,7 @@ import io import unittest import os import tempfile +from homeassistant.exceptions import HomeAssistantError from homeassistant.util import yaml import homeassistant.config as config_util from tests.common import get_test_config_dir @@ -165,9 +166,16 @@ class TestSecrets(unittest.TestCase): def setUp(self): # pylint: disable=invalid-name """Create & load secrets file.""" config_dir = get_test_config_dir() + yaml.clear_secret_cache() self._yaml_path = os.path.join(config_dir, config_util.YAML_CONFIG_FILE) - self._secret_path = os.path.join(config_dir, 'secrets.yaml') + self._secret_path = os.path.join(config_dir, yaml._SECRET_YAML) + self._sub_folder_path = os.path.join(config_dir, 'subFolder') + if not os.path.exists(self._sub_folder_path): + os.makedirs(self._sub_folder_path) + self._unrelated_path = os.path.join(config_dir, 'unrelated') + if not os.path.exists(self._unrelated_path): + os.makedirs(self._unrelated_path) load_yaml(self._secret_path, 'http_pw: pwhttp\n' @@ -185,7 +193,11 @@ class TestSecrets(unittest.TestCase): def tearDown(self): # pylint: disable=invalid-name """Clean up secrets.""" - for path in [self._yaml_path, self._secret_path]: + yaml.clear_secret_cache() + for path in [self._yaml_path, self._secret_path, + os.path.join(self._sub_folder_path, 'sub.yaml'), + os.path.join(self._sub_folder_path, yaml._SECRET_YAML), + os.path.join(self._unrelated_path, yaml._SECRET_YAML)]: if os.path.isfile(path): os.remove(path) @@ -199,6 +211,43 @@ class TestSecrets(unittest.TestCase): 'password': 'pw1'} self.assertEqual(expected, self._yaml['component']) + def test_secrets_from_parent_folder(self): + """Test loading secrets from parent foler.""" + expected = {'api_password': 'pwhttp'} + self._yaml = load_yaml(os.path.join(self._sub_folder_path, 'sub.yaml'), + 'http:\n' + ' api_password: !secret http_pw\n' + 'component:\n' + ' username: !secret comp1_un\n' + ' password: !secret comp1_pw\n' + '') + + self.assertEqual(expected, self._yaml['http']) + + def test_secret_overrides_parent(self): + """Test loading current directory secret overrides the parent.""" + expected = {'api_password': 'override'} + load_yaml(os.path.join(self._sub_folder_path, yaml._SECRET_YAML), + 'http_pw: override') + self._yaml = load_yaml(os.path.join(self._sub_folder_path, 'sub.yaml'), + 'http:\n' + ' api_password: !secret http_pw\n' + 'component:\n' + ' username: !secret comp1_un\n' + ' password: !secret comp1_pw\n' + '') + + self.assertEqual(expected, self._yaml['http']) + + def test_secrets_from_unrelated_fails(self): + """Test loading secrets from unrelated folder fails.""" + load_yaml(os.path.join(self._unrelated_path, yaml._SECRET_YAML), + 'test: failure') + with self.assertRaises(HomeAssistantError): + load_yaml(os.path.join(self._sub_folder_path, 'sub.yaml'), + 'http:\n' + ' api_password: !secret test') + def test_secrets_keyring(self): """Test keyring fallback & get_password.""" yaml.keyring = None # Ensure its not there