diff --git a/.coveragerc b/.coveragerc index b091b376579..96936655c51 100644 --- a/.coveragerc +++ b/.coveragerc @@ -11,6 +11,9 @@ omit = homeassistant/components/abode.py homeassistant/components/*/abode.py + homeassistant/components/ads/__init__.py + homeassistant/components/*/ads.py + homeassistant/components/alarmdecoder.py homeassistant/components/*/alarmdecoder.py @@ -85,7 +88,7 @@ omit = homeassistant/components/hive.py homeassistant/components/*/hive.py - homeassistant/components/homematic.py + homeassistant/components/homematic/__init__.py homeassistant/components/*/homematic.py homeassistant/components/insteon_local.py @@ -261,8 +264,10 @@ omit = homeassistant/components/*/zoneminder.py homeassistant/components/alarm_control_panel/alarmdotcom.py + homeassistant/components/alarm_control_panel/canary.py homeassistant/components/alarm_control_panel/concord232.py homeassistant/components/alarm_control_panel/egardia.py + homeassistant/components/alarm_control_panel/ialarm.py homeassistant/components/alarm_control_panel/manual_mqtt.py homeassistant/components/alarm_control_panel/nx584.py homeassistant/components/alarm_control_panel/simplisafe.py @@ -279,14 +284,16 @@ omit = homeassistant/components/binary_sensor/rest.py homeassistant/components/binary_sensor/tapsaff.py homeassistant/components/browser.py + homeassistant/components/calendar/caldav.py homeassistant/components/calendar/todoist.py homeassistant/components/camera/bloomsky.py + homeassistant/components/camera/canary.py homeassistant/components/camera/ffmpeg.py homeassistant/components/camera/foscam.py homeassistant/components/camera/mjpeg.py - homeassistant/components/camera/rpi_camera.py homeassistant/components/camera/onvif.py homeassistant/components/camera/ring.py + homeassistant/components/camera/rpi_camera.py homeassistant/components/camera/synology.py homeassistant/components/camera/yi.py homeassistant/components/climate/ephember.py @@ -294,6 +301,7 @@ omit = homeassistant/components/climate/flexit.py homeassistant/components/climate/heatmiser.py homeassistant/components/climate/homematic.py + homeassistant/components/climate/honeywell.py homeassistant/components/climate/knx.py homeassistant/components/climate/oem.py homeassistant/components/climate/proliphix.py @@ -331,10 +339,10 @@ omit = homeassistant/components/device_tracker/sky_hub.py homeassistant/components/device_tracker/snmp.py homeassistant/components/device_tracker/swisscom.py - homeassistant/components/device_tracker/thomson.py - homeassistant/components/device_tracker/tomato.py homeassistant/components/device_tracker/tado.py + homeassistant/components/device_tracker/thomson.py homeassistant/components/device_tracker/tile.py + homeassistant/components/device_tracker/tomato.py homeassistant/components/device_tracker/tplink.py homeassistant/components/device_tracker/trackr.py homeassistant/components/device_tracker/ubus.py @@ -352,8 +360,8 @@ omit = homeassistant/components/keyboard.py homeassistant/components/keyboard_remote.py homeassistant/components/light/avion.py - homeassistant/components/light/blinkt.py homeassistant/components/light/blinksticklight.py + homeassistant/components/light/blinkt.py homeassistant/components/light/decora.py homeassistant/components/light/decora_wifi.py homeassistant/components/light/flux_led.py @@ -364,8 +372,8 @@ omit = homeassistant/components/light/limitlessled.py homeassistant/components/light/mystrom.py homeassistant/components/light/osramlightify.py - homeassistant/components/light/rpi_gpio_pwm.py homeassistant/components/light/piglow.py + homeassistant/components/light/rpi_gpio_pwm.py homeassistant/components/light/sensehat.py homeassistant/components/light/tikteck.py homeassistant/components/light/tplink.py @@ -376,9 +384,9 @@ omit = homeassistant/components/light/yeelightsunflower.py homeassistant/components/light/zengge.py homeassistant/components/lirc.py + homeassistant/components/lock/lockitron.py homeassistant/components/lock/nello.py homeassistant/components/lock/nuki.py - homeassistant/components/lock/lockitron.py homeassistant/components/lock/sesame.py homeassistant/components/media_extractor.py homeassistant/components/media_player/anthemav.py @@ -420,11 +428,13 @@ omit = homeassistant/components/media_player/sonos.py homeassistant/components/media_player/spotify.py homeassistant/components/media_player/squeezebox.py + homeassistant/components/media_player/ue_smart_radio.py homeassistant/components/media_player/vizio.py homeassistant/components/media_player/vlc.py homeassistant/components/media_player/volumio.py homeassistant/components/media_player/yamaha.py homeassistant/components/media_player/yamaha_musiccast.py + homeassistant/components/media_player/ziggo_mediabox_xl.py homeassistant/components/mycroft.py homeassistant/components/notify/aws_lambda.py homeassistant/components/notify/aws_sns.py @@ -472,6 +482,7 @@ omit = homeassistant/components/scene/hunterdouglas_powerview.py homeassistant/components/scene/lifx_cloud.py homeassistant/components/sensor/airvisual.py + homeassistant/components/sensor/alpha_vantage.py homeassistant/components/sensor/arest.py homeassistant/components/sensor/arwn.py homeassistant/components/sensor/bbox.py @@ -482,8 +493,8 @@ omit = homeassistant/components/sensor/bom.py homeassistant/components/sensor/broadlink.py homeassistant/components/sensor/buienradar.py - homeassistant/components/sensor/citybikes.py homeassistant/components/sensor/cert_expiry.py + homeassistant/components/sensor/citybikes.py homeassistant/components/sensor/comed_hourly_pricing.py homeassistant/components/sensor/cpuspeed.py homeassistant/components/sensor/crimereports.py @@ -511,6 +522,7 @@ omit = homeassistant/components/sensor/fixer.py homeassistant/components/sensor/fritzbox_callmonitor.py homeassistant/components/sensor/fritzbox_netmonitor.py + homeassistant/components/sensor/gearbest.py homeassistant/components/sensor/geizhals.py homeassistant/components/sensor/gitter.py homeassistant/components/sensor/glances.py @@ -621,8 +633,8 @@ omit = homeassistant/components/switch/rest.py homeassistant/components/switch/rpi_rf.py homeassistant/components/switch/snmp.py - homeassistant/components/switch/tplink.py homeassistant/components/switch/telnet.py + homeassistant/components/switch/tplink.py homeassistant/components/switch/transmission.py homeassistant/components/switch/xiaomi_miio.py homeassistant/components/telegram_bot/* @@ -631,7 +643,9 @@ omit = homeassistant/components/tts/baidu.py homeassistant/components/tts/microsoft.py homeassistant/components/tts/picotts.py + homeassistant/components/vacuum/mqtt.py homeassistant/components/vacuum/roomba.py + homeassistant/components/vacuum/xiaomi_miio.py homeassistant/components/weather/bom.py homeassistant/components/weather/buienradar.py homeassistant/components/weather/metoffice.py @@ -640,7 +654,6 @@ omit = homeassistant/components/weather/zamg.py homeassistant/components/zeroconf.py homeassistant/components/zwave/util.py - homeassistant/components/vacuum/mqtt.py [report] # Regexes for lines to exclude from consideration diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000000..214efef6e4d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Ensure Docker script files uses LF to support Docker for Windows. +setup_docker_prereqs eol=lf +/virtualization/Docker/scripts/* eol=lf \ No newline at end of file diff --git a/CODEOWNERS b/CODEOWNERS index fe415a619db..ac0f794482a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -54,6 +54,7 @@ homeassistant/components/media_player/kodi.py @armills homeassistant/components/media_player/monoprice.py @etsinko homeassistant/components/media_player/yamaha_musiccast.py @jalmeroth homeassistant/components/sensor/airvisual.py @bachya +homeassistant/components/sensor/gearbest.py @HerrHofrat homeassistant/components/sensor/irish_rail_transport.py @ttroy50 homeassistant/components/sensor/miflora.py @danielhiversen homeassistant/components/sensor/sytadin.py @gautric diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index a8852b910c2..b7301e13bea 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -230,7 +230,7 @@ def closefds_osx(min_fd: int, max_fd: int) -> None: def cmdline() -> List[str]: """Collect path and arguments to re-execute the current hass instance.""" - if sys.argv[0].endswith(os.path.sep + '__main__.py'): + if os.path.basename(sys.argv[0]) == '__main__.py': modulepath = os.path.dirname(sys.argv[0]) os.environ['PYTHONPATH'] = os.path.dirname(modulepath) return [sys.executable] + [arg for arg in sys.argv if diff --git a/homeassistant/components/ads/__init__.py b/homeassistant/components/ads/__init__.py new file mode 100644 index 00000000000..3d9de28ded3 --- /dev/null +++ b/homeassistant/components/ads/__init__.py @@ -0,0 +1,217 @@ +""" +ADS Component. + +For more details about this component, please refer to the documentation. +https://home-assistant.io/components/ads/ + +""" +import os +import threading +import struct +import logging +import ctypes +from collections import namedtuple +import voluptuous as vol +from homeassistant.const import CONF_DEVICE, CONF_PORT, CONF_IP_ADDRESS, \ + EVENT_HOMEASSISTANT_STOP +from homeassistant.config import load_yaml_config_file +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pyads==2.2.6'] + +_LOGGER = logging.getLogger(__name__) + +DATA_ADS = 'data_ads' + +# Supported Types +ADSTYPE_INT = 'int' +ADSTYPE_UINT = 'uint' +ADSTYPE_BYTE = 'byte' +ADSTYPE_BOOL = 'bool' + +DOMAIN = 'ads' + +# config variable names +CONF_ADS_VAR = 'adsvar' +CONF_ADS_VAR_BRIGHTNESS = 'adsvar_brightness' +CONF_ADS_TYPE = 'adstype' +CONF_ADS_FACTOR = 'factor' +CONF_ADS_VALUE = 'value' + +SERVICE_WRITE_DATA_BY_NAME = 'write_data_by_name' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_DEVICE): cv.string, + vol.Required(CONF_PORT): cv.port, + vol.Optional(CONF_IP_ADDRESS): cv.string, + }) +}, extra=vol.ALLOW_EXTRA) + +SCHEMA_SERVICE_WRITE_DATA_BY_NAME = vol.Schema({ + vol.Required(CONF_ADS_VAR): cv.string, + vol.Required(CONF_ADS_TYPE): vol.In([ADSTYPE_INT, ADSTYPE_UINT, + ADSTYPE_BYTE]), + vol.Required(CONF_ADS_VALUE): cv.match_all +}) + + +def setup(hass, config): + """Set up the ADS component.""" + import pyads + conf = config[DOMAIN] + + # get ads connection parameters from config + net_id = conf.get(CONF_DEVICE) + ip_address = conf.get(CONF_IP_ADDRESS) + port = conf.get(CONF_PORT) + + # create a new ads connection + client = pyads.Connection(net_id, port, ip_address) + + # add some constants to AdsHub + AdsHub.ADS_TYPEMAP = { + ADSTYPE_BOOL: pyads.PLCTYPE_BOOL, + ADSTYPE_BYTE: pyads.PLCTYPE_BYTE, + ADSTYPE_INT: pyads.PLCTYPE_INT, + ADSTYPE_UINT: pyads.PLCTYPE_UINT, + } + + AdsHub.PLCTYPE_BOOL = pyads.PLCTYPE_BOOL + AdsHub.PLCTYPE_BYTE = pyads.PLCTYPE_BYTE + AdsHub.PLCTYPE_INT = pyads.PLCTYPE_INT + AdsHub.PLCTYPE_UINT = pyads.PLCTYPE_UINT + AdsHub.ADSError = pyads.ADSError + + # connect to ads client and try to connect + try: + ads = AdsHub(client) + except pyads.pyads.ADSError: + _LOGGER.error( + 'Could not connect to ADS host (netid=%s, port=%s)', net_id, port + ) + return False + + # add ads hub to hass data collection, listen to shutdown + hass.data[DATA_ADS] = ads + hass.bus.listen(EVENT_HOMEASSISTANT_STOP, ads.shutdown) + + def handle_write_data_by_name(call): + """Write a value to the connected ADS device.""" + ads_var = call.data.get(CONF_ADS_VAR) + ads_type = call.data.get(CONF_ADS_TYPE) + value = call.data.get(CONF_ADS_VALUE) + + try: + ads.write_by_name(ads_var, value, ads.ADS_TYPEMAP[ads_type]) + except pyads.ADSError as err: + _LOGGER.error(err) + + # load descriptions from services.yaml + descriptions = load_yaml_config_file( + os.path.join(os.path.dirname(__file__), 'services.yaml')) + + hass.services.register( + DOMAIN, SERVICE_WRITE_DATA_BY_NAME, handle_write_data_by_name, + descriptions[SERVICE_WRITE_DATA_BY_NAME], + schema=SCHEMA_SERVICE_WRITE_DATA_BY_NAME + ) + + return True + + +# tuple to hold data needed for notification +NotificationItem = namedtuple( + 'NotificationItem', 'hnotify huser name plc_datatype callback' +) + + +class AdsHub: + """Representation of a PyADS connection.""" + + def __init__(self, ads_client): + """Initialize the ADS Hub.""" + self._client = ads_client + self._client.open() + + # all ADS devices are registered here + self._devices = [] + self._notification_items = {} + self._lock = threading.Lock() + + def shutdown(self, *args, **kwargs): + """Shutdown ADS connection.""" + _LOGGER.debug('Shutting down ADS') + for notification_item in self._notification_items.values(): + self._client.del_device_notification( + notification_item.hnotify, + notification_item.huser + ) + _LOGGER.debug( + 'Deleting device notification %d, %d', + notification_item.hnotify, notification_item.huser + ) + self._client.close() + + def register_device(self, device): + """Register a new device.""" + self._devices.append(device) + + def write_by_name(self, name, value, plc_datatype): + """Write a value to the device.""" + with self._lock: + return self._client.write_by_name(name, value, plc_datatype) + + def read_by_name(self, name, plc_datatype): + """Read a value from the device.""" + with self._lock: + return self._client.read_by_name(name, plc_datatype) + + def add_device_notification(self, name, plc_datatype, callback): + """Add a notification to the ADS devices.""" + from pyads import NotificationAttrib + attr = NotificationAttrib(ctypes.sizeof(plc_datatype)) + + with self._lock: + hnotify, huser = self._client.add_device_notification( + name, attr, self._device_notification_callback + ) + hnotify = int(hnotify) + + _LOGGER.debug( + 'Added Device Notification %d for variable %s', hnotify, name + ) + + self._notification_items[hnotify] = NotificationItem( + hnotify, huser, name, plc_datatype, callback + ) + + def _device_notification_callback(self, addr, notification, huser): + """Handle device notifications.""" + contents = notification.contents + + hnotify = int(contents.hNotification) + _LOGGER.debug('Received Notification %d', hnotify) + data = contents.data + + try: + notification_item = self._notification_items[hnotify] + except KeyError: + _LOGGER.debug('Unknown Device Notification handle: %d', hnotify) + return + + # parse data to desired datatype + if notification_item.plc_datatype == self.PLCTYPE_BOOL: + value = bool(struct.unpack(' dt_util.utcnow() + if state == STATE_ALARM_TRIGGERED: + pending_time += self._delay_time_by_state[self._previous_state] + return pending_time + + def _within_pending_time(self, state): + return self._state_ts + self._pending_time(state) > dt_util.utcnow() @property def code_format(self): @@ -185,26 +239,35 @@ class ManualAlarm(alarm.AlarmControlPanel): self._update_state(STATE_ALARM_ARMED_CUSTOM_BYPASS) def alarm_trigger(self, code=None): - """Send alarm trigger command. No code needed.""" - self._pre_trigger_state = self._state + """ + Send alarm trigger command. + No code needed, a trigger time of zero for the current state + disables the alarm. + """ + if not self._trigger_time_by_state[self._active_state]: + return self._update_state(STATE_ALARM_TRIGGERED) def _update_state(self, state): + if self._state == state: + return + + self._previous_state = self._state self._state = state self._state_ts = dt_util.utcnow() self.schedule_update_ha_state() - pending_time = self._pending_time_by_state[state] - - if state == STATE_ALARM_TRIGGERED and self._trigger_time: + pending_time = self._pending_time(state) + if state == STATE_ALARM_TRIGGERED: track_point_in_time( self._hass, self.async_update_ha_state, self._state_ts + pending_time) + trigger_time = self._trigger_time_by_state[self._previous_state] track_point_in_time( self._hass, self.async_update_ha_state, - self._state_ts + self._trigger_time + pending_time) + self._state_ts + pending_time + trigger_time) elif state in SUPPORTED_PENDING_STATES and pending_time: track_point_in_time( self._hass, self.async_update_ha_state, @@ -212,7 +275,14 @@ class ManualAlarm(alarm.AlarmControlPanel): def _validate_code(self, code, state): """Validate given code.""" - check = self._code is None or code == self._code + if self._code is None: + return True + if isinstance(self._code, str): + alarm_code = self._code + else: + alarm_code = self._code.render(from_state=self._state, + to_state=state) + check = not alarm_code or code == alarm_code if not check: _LOGGER.warning("Invalid code given for %s", state) return check @@ -223,6 +293,7 @@ class ManualAlarm(alarm.AlarmControlPanel): state_attr = {} if self.state == STATE_ALARM_PENDING: + state_attr[ATTR_PRE_PENDING_STATE] = self._previous_state state_attr[ATTR_POST_PENDING_STATE] = self._state return state_attr diff --git a/homeassistant/components/alarm_control_panel/manual_mqtt.py b/homeassistant/components/alarm_control_panel/manual_mqtt.py index 44247616b59..9e388806e73 100644 --- a/homeassistant/components/alarm_control_panel/manual_mqtt.py +++ b/homeassistant/components/alarm_control_panel/manual_mqtt.py @@ -16,8 +16,8 @@ import homeassistant.util.dt as dt_util from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, - CONF_PLATFORM, CONF_NAME, CONF_CODE, CONF_PENDING_TIME, CONF_TRIGGER_TIME, - CONF_DISARM_AFTER_TRIGGER) + CONF_PLATFORM, CONF_NAME, CONF_CODE, CONF_DELAY_TIME, CONF_PENDING_TIME, + CONF_TRIGGER_TIME, CONF_DISARM_AFTER_TRIGGER) import homeassistant.components.mqtt as mqtt from homeassistant.helpers.event import async_track_state_change @@ -26,28 +26,44 @@ from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import track_point_in_time +CONF_CODE_TEMPLATE = 'code_template' + CONF_PAYLOAD_DISARM = 'payload_disarm' CONF_PAYLOAD_ARM_HOME = 'payload_arm_home' CONF_PAYLOAD_ARM_AWAY = 'payload_arm_away' CONF_PAYLOAD_ARM_NIGHT = 'payload_arm_night' DEFAULT_ALARM_NAME = 'HA Alarm' -DEFAULT_PENDING_TIME = 60 -DEFAULT_TRIGGER_TIME = 120 +DEFAULT_DELAY_TIME = datetime.timedelta(seconds=0) +DEFAULT_PENDING_TIME = datetime.timedelta(seconds=60) +DEFAULT_TRIGGER_TIME = datetime.timedelta(seconds=120) DEFAULT_DISARM_AFTER_TRIGGER = False DEFAULT_ARM_AWAY = 'ARM_AWAY' DEFAULT_ARM_HOME = 'ARM_HOME' DEFAULT_ARM_NIGHT = 'ARM_NIGHT' DEFAULT_DISARM = 'DISARM' -SUPPORTED_PENDING_STATES = [STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, - STATE_ALARM_ARMED_NIGHT, STATE_ALARM_TRIGGERED] +SUPPORTED_STATES = [STATE_ALARM_DISARMED, STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_TRIGGERED] +SUPPORTED_PRETRIGGER_STATES = [state for state in SUPPORTED_STATES + if state != STATE_ALARM_TRIGGERED] + +SUPPORTED_PENDING_STATES = [state for state in SUPPORTED_STATES + if state != STATE_ALARM_DISARMED] + +ATTR_PRE_PENDING_STATE = 'pre_pending_state' ATTR_POST_PENDING_STATE = 'post_pending_state' def _state_validator(config): config = copy.deepcopy(config) + for state in SUPPORTED_PRETRIGGER_STATES: + if CONF_DELAY_TIME not in config[state]: + config[state][CONF_DELAY_TIME] = config[CONF_DELAY_TIME] + if CONF_TRIGGER_TIME not in config[state]: + config[state][CONF_TRIGGER_TIME] = config[CONF_TRIGGER_TIME] for state in SUPPORTED_PENDING_STATES: if CONF_PENDING_TIME not in config[state]: config[state][CONF_PENDING_TIME] = config[CONF_PENDING_TIME] @@ -55,27 +71,44 @@ def _state_validator(config): return config -STATE_SETTING_SCHEMA = vol.Schema({ - vol.Optional(CONF_PENDING_TIME): - vol.All(vol.Coerce(int), vol.Range(min=0)) -}) +def _state_schema(state): + schema = {} + if state in SUPPORTED_PRETRIGGER_STATES: + schema[vol.Optional(CONF_DELAY_TIME)] = vol.All( + cv.time_period, cv.positive_timedelta) + schema[vol.Optional(CONF_TRIGGER_TIME)] = vol.All( + cv.time_period, cv.positive_timedelta) + if state in SUPPORTED_PENDING_STATES: + schema[vol.Optional(CONF_PENDING_TIME)] = vol.All( + cv.time_period, cv.positive_timedelta) + return vol.Schema(schema) + DEPENDENCIES = ['mqtt'] PLATFORM_SCHEMA = vol.Schema(vol.All(mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ vol.Required(CONF_PLATFORM): 'manual_mqtt', vol.Optional(CONF_NAME, default=DEFAULT_ALARM_NAME): cv.string, - vol.Optional(CONF_CODE): cv.string, + vol.Exclusive(CONF_CODE, 'code validation'): cv.string, + vol.Exclusive(CONF_CODE_TEMPLATE, 'code validation'): cv.template, + vol.Optional(CONF_DELAY_TIME, default=DEFAULT_DELAY_TIME): + vol.All(cv.time_period, cv.positive_timedelta), vol.Optional(CONF_PENDING_TIME, default=DEFAULT_PENDING_TIME): - vol.All(vol.Coerce(int), vol.Range(min=0)), + vol.All(cv.time_period, cv.positive_timedelta), vol.Optional(CONF_TRIGGER_TIME, default=DEFAULT_TRIGGER_TIME): - vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.All(cv.time_period, cv.positive_timedelta), vol.Optional(CONF_DISARM_AFTER_TRIGGER, default=DEFAULT_DISARM_AFTER_TRIGGER): cv.boolean, - vol.Optional(STATE_ALARM_ARMED_AWAY, default={}): STATE_SETTING_SCHEMA, - vol.Optional(STATE_ALARM_ARMED_HOME, default={}): STATE_SETTING_SCHEMA, - vol.Optional(STATE_ALARM_ARMED_NIGHT, default={}): STATE_SETTING_SCHEMA, - vol.Optional(STATE_ALARM_TRIGGERED, default={}): STATE_SETTING_SCHEMA, + vol.Optional(STATE_ALARM_ARMED_AWAY, default={}): + _state_schema(STATE_ALARM_ARMED_AWAY), + vol.Optional(STATE_ALARM_ARMED_HOME, default={}): + _state_schema(STATE_ALARM_ARMED_HOME), + vol.Optional(STATE_ALARM_ARMED_NIGHT, default={}): + _state_schema(STATE_ALARM_ARMED_NIGHT), + vol.Optional(STATE_ALARM_DISARMED, default={}): + _state_schema(STATE_ALARM_DISARMED), + vol.Optional(STATE_ALARM_TRIGGERED, default={}): + _state_schema(STATE_ALARM_TRIGGERED), vol.Required(mqtt.CONF_COMMAND_TOPIC): mqtt.valid_publish_topic, vol.Required(mqtt.CONF_STATE_TOPIC): mqtt.valid_subscribe_topic, vol.Optional(CONF_PAYLOAD_ARM_AWAY, default=DEFAULT_ARM_AWAY): cv.string, @@ -93,8 +126,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): hass, 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_CODE_TEMPLATE), config.get(CONF_DISARM_AFTER_TRIGGER, DEFAULT_DISARM_AFTER_TRIGGER), config.get(mqtt.CONF_STATE_TOPIC), config.get(mqtt.CONF_COMMAND_TOPIC), @@ -111,13 +143,15 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): Representation of an alarm status. 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 the previous state - or disarm if `disarm_after_trigger` is true. + When triggered, will be pending for the triggering state's 'delay_time' + plus the triggered state's 'pending_time'. + After that will be triggered for 'trigger_time', after that we return to + the previous state or disarm if `disarm_after_trigger` is true. + A trigger_time of zero disables the alarm_trigger service. """ - def __init__(self, hass, name, code, pending_time, - trigger_time, disarm_after_trigger, + def __init__(self, hass, name, code, code_template, + disarm_after_trigger, state_topic, command_topic, qos, payload_disarm, payload_arm_home, payload_arm_away, payload_arm_night, config): @@ -125,17 +159,24 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): self._state = STATE_ALARM_DISARMED self._hass = hass self._name = name - self._code = str(code) if code else None - self._pending_time = datetime.timedelta(seconds=pending_time) - self._trigger_time = datetime.timedelta(seconds=trigger_time) + if code_template: + self._code = code_template + self._code.hass = hass + else: + self._code = code or None self._disarm_after_trigger = disarm_after_trigger - self._pre_trigger_state = self._state + self._previous_state = self._state self._state_ts = None - self._pending_time_by_state = {} - for state in SUPPORTED_PENDING_STATES: - self._pending_time_by_state[state] = datetime.timedelta( - seconds=config[state][CONF_PENDING_TIME]) + self._delay_time_by_state = { + state: config[state][CONF_DELAY_TIME] + for state in SUPPORTED_PRETRIGGER_STATES} + self._trigger_time_by_state = { + state: config[state][CONF_TRIGGER_TIME] + for state in SUPPORTED_PRETRIGGER_STATES} + self._pending_time_by_state = { + state: config[state][CONF_PENDING_TIME] + for state in SUPPORTED_PENDING_STATES} self._state_topic = state_topic self._command_topic = command_topic @@ -158,15 +199,16 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): @property def state(self): """Return the state of the device.""" - if self._state == STATE_ALARM_TRIGGERED and self._trigger_time: + if self._state == STATE_ALARM_TRIGGERED: if self._within_pending_time(self._state): return STATE_ALARM_PENDING - elif (self._state_ts + self._pending_time_by_state[self._state] + - self._trigger_time) < dt_util.utcnow(): + trigger_time = self._trigger_time_by_state[self._previous_state] + if (self._state_ts + self._pending_time(self._state) + + trigger_time) < dt_util.utcnow(): if self._disarm_after_trigger: return STATE_ALARM_DISARMED else: - self._state = self._pre_trigger_state + self._state = self._previous_state return self._state if self._state in SUPPORTED_PENDING_STATES and \ @@ -175,9 +217,21 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): return self._state - def _within_pending_time(self, state): + @property + def _active_state(self): + if self.state == STATE_ALARM_PENDING: + return self._previous_state + else: + return self._state + + def _pending_time(self, state): pending_time = self._pending_time_by_state[state] - return self._state_ts + pending_time > dt_util.utcnow() + if state == STATE_ALARM_TRIGGERED: + pending_time += self._delay_time_by_state[self._previous_state] + return pending_time + + def _within_pending_time(self, state): + return self._state_ts + self._pending_time(state) > dt_util.utcnow() @property def code_format(self): @@ -215,26 +269,35 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): self._update_state(STATE_ALARM_ARMED_NIGHT) def alarm_trigger(self, code=None): - """Send alarm trigger command. No code needed.""" - self._pre_trigger_state = self._state + """ + Send alarm trigger command. + No code needed, a trigger time of zero for the current state + disables the alarm. + """ + if not self._trigger_time_by_state[self._active_state]: + return self._update_state(STATE_ALARM_TRIGGERED) def _update_state(self, state): + if self._state == state: + return + + self._previous_state = self._state self._state = state self._state_ts = dt_util.utcnow() self.schedule_update_ha_state() - pending_time = self._pending_time_by_state[state] - - if state == STATE_ALARM_TRIGGERED and self._trigger_time: + pending_time = self._pending_time(state) + if state == STATE_ALARM_TRIGGERED: track_point_in_time( self._hass, self.async_update_ha_state, self._state_ts + pending_time) + trigger_time = self._trigger_time_by_state[self._previous_state] track_point_in_time( self._hass, self.async_update_ha_state, - self._state_ts + self._trigger_time + pending_time) + self._state_ts + pending_time + trigger_time) elif state in SUPPORTED_PENDING_STATES and pending_time: track_point_in_time( self._hass, self.async_update_ha_state, @@ -242,7 +305,14 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): def _validate_code(self, code, state): """Validate given code.""" - check = self._code is None or code == self._code + if self._code is None: + return True + if isinstance(self._code, str): + alarm_code = self._code + else: + alarm_code = self._code.render(from_state=self._state, + to_state=state) + check = not alarm_code or code == alarm_code if not check: _LOGGER.warning("Invalid code given for %s", state) return check @@ -253,6 +323,7 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): state_attr = {} if self.state == STATE_ALARM_PENDING: + state_attr[ATTR_PRE_PENDING_STATE] = self._previous_state state_attr[ATTR_POST_PENDING_STATE] = self._state return state_attr diff --git a/homeassistant/components/alarm_control_panel/totalconnect.py b/homeassistant/components/alarm_control_panel/totalconnect.py index 6f22d6a358c..5c1323989d4 100644 --- a/homeassistant/components/alarm_control_panel/totalconnect.py +++ b/homeassistant/components/alarm_control_panel/totalconnect.py @@ -14,7 +14,9 @@ from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, - STATE_ALARM_ARMING, STATE_ALARM_DISARMING, STATE_UNKNOWN, CONF_NAME) + STATE_ALARM_ARMING, STATE_ALARM_DISARMING, STATE_UNKNOWN, CONF_NAME, + STATE_ALARM_ARMED_CUSTOM_BYPASS) + REQUIREMENTS = ['total_connect_client==0.16'] @@ -76,6 +78,8 @@ class TotalConnect(alarm.AlarmControlPanel): state = STATE_ALARM_ARMED_AWAY elif status == self._client.ARMED_STAY_NIGHT: state = STATE_ALARM_ARMED_NIGHT + elif status == self._client.ARMED_CUSTOM_BYPASS: + state = STATE_ALARM_ARMED_CUSTOM_BYPASS elif status == self._client.ARMING: state = STATE_ALARM_ARMING elif status == self._client.DISARMING: diff --git a/homeassistant/components/alarmdecoder.py b/homeassistant/components/alarmdecoder.py index 011cc3ad21d..6e30a83d96a 100644 --- a/homeassistant/components/alarmdecoder.py +++ b/homeassistant/components/alarmdecoder.py @@ -4,16 +4,13 @@ Support for AlarmDecoder devices. For more details about this component, please refer to the documentation at https://home-assistant.io/components/alarmdecoder/ """ -import asyncio import logging import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant.core import callback from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.helpers.discovery import async_load_platform -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.discovery import load_platform REQUIREMENTS = ['alarmdecoder==0.12.3'] @@ -71,9 +68,9 @@ ZONE_SCHEMA = vol.Schema({ CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Required(CONF_DEVICE): vol.Any(DEVICE_SOCKET_SCHEMA, - DEVICE_SERIAL_SCHEMA, - DEVICE_USB_SCHEMA), + vol.Required(CONF_DEVICE): vol.Any( + DEVICE_SOCKET_SCHEMA, DEVICE_SERIAL_SCHEMA, + DEVICE_USB_SCHEMA), vol.Optional(CONF_PANEL_DISPLAY, default=DEFAULT_PANEL_DISPLAY): cv.boolean, vol.Optional(CONF_ZONES): {vol.Coerce(int): ZONE_SCHEMA}, @@ -81,8 +78,7 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) -@asyncio.coroutine -def async_setup(hass, config): +def setup(hass, config): """Set up for the AlarmDecoder devices.""" from alarmdecoder import AlarmDecoder from alarmdecoder.devices import (SocketDevice, SerialDevice, USBDevice) @@ -99,32 +95,25 @@ def async_setup(hass, config): path = DEFAULT_DEVICE_PATH baud = DEFAULT_DEVICE_BAUD - sync_connect = asyncio.Future(loop=hass.loop) - - def handle_open(device): - """Handle the successful connection.""" - _LOGGER.info("Established a connection with the alarmdecoder") - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_alarmdecoder) - sync_connect.set_result(True) - - @callback def stop_alarmdecoder(event): """Handle the shutdown of AlarmDecoder.""" _LOGGER.debug("Shutting down alarmdecoder") controller.close() - @callback def handle_message(sender, message): """Handle message from AlarmDecoder.""" - async_dispatcher_send(hass, SIGNAL_PANEL_MESSAGE, message) + hass.helpers.dispatcher.dispatcher_send( + SIGNAL_PANEL_MESSAGE, message) def zone_fault_callback(sender, zone): """Handle zone fault from AlarmDecoder.""" - async_dispatcher_send(hass, SIGNAL_ZONE_FAULT, zone) + hass.helpers.dispatcher.dispatcher_send( + SIGNAL_ZONE_FAULT, zone) def zone_restore_callback(sender, zone): """Handle zone restore from AlarmDecoder.""" - async_dispatcher_send(hass, SIGNAL_ZONE_RESTORE, zone) + hass.helpers.dispatcher.dispatcher_send( + SIGNAL_ZONE_RESTORE, zone) controller = False if device_type == 'socket': @@ -139,7 +128,6 @@ def async_setup(hass, config): AlarmDecoder(USBDevice.find()) return False - controller.on_open += handle_open controller.on_message += handle_message controller.on_zone_fault += zone_fault_callback controller.on_zone_restore += zone_restore_callback @@ -148,21 +136,16 @@ def async_setup(hass, config): controller.open(baud) - result = yield from sync_connect + _LOGGER.debug("Established a connection with the alarmdecoder") + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_alarmdecoder) - if not result: - return False - - hass.async_add_job( - async_load_platform(hass, 'alarm_control_panel', DOMAIN, conf, - config)) + load_platform(hass, 'alarm_control_panel', DOMAIN, conf, config) if zones: - hass.async_add_job(async_load_platform( - hass, 'binary_sensor', DOMAIN, {CONF_ZONES: zones}, config)) + load_platform( + hass, 'binary_sensor', DOMAIN, {CONF_ZONES: zones}, config) if display: - hass.async_add_job(async_load_platform( - hass, 'sensor', DOMAIN, conf, config)) + load_platform(hass, 'sensor', DOMAIN, conf, config) return True diff --git a/homeassistant/components/apple_tv.py b/homeassistant/components/apple_tv.py index c8eb1841c0d..bb6bfa0e9db 100644 --- a/homeassistant/components/apple_tv.py +++ b/homeassistant/components/apple_tv.py @@ -18,7 +18,7 @@ from homeassistant.helpers import discovery from homeassistant.components.discovery import SERVICE_APPLE_TV import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyatv==0.3.8'] +REQUIREMENTS = ['pyatv==0.3.9'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index 9e48a30d04a..a0c141914ed 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -34,6 +34,7 @@ DEVICE_CLASSES = [ 'plug', # On means plugged in, Off means unplugged 'power', # Power, over-current, etc 'presence', # On means home, Off means away + 'problem', # On means there is a problem, Off means the status is OK 'safety', # Generic on=unsafe, off=safe 'smoke', # Smoke detector 'sound', # On means sound detected, Off means no sound diff --git a/homeassistant/components/binary_sensor/ads.py b/homeassistant/components/binary_sensor/ads.py new file mode 100644 index 00000000000..e6b86ed97e6 --- /dev/null +++ b/homeassistant/components/binary_sensor/ads.py @@ -0,0 +1,87 @@ +""" +Support for ADS binary sensors. + +For more details about this platform, please refer to the documentation. +https://home-assistant.io/components/binary_sensor.ads/ + +""" +import asyncio +import logging +import voluptuous as vol +from homeassistant.components.binary_sensor import BinarySensorDevice, \ + PLATFORM_SCHEMA, DEVICE_CLASSES_SCHEMA +from homeassistant.components.ads import DATA_ADS, CONF_ADS_VAR +from homeassistant.const import CONF_NAME, CONF_DEVICE_CLASS +import homeassistant.helpers.config_validation as cv + + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['ads'] +DEFAULT_NAME = 'ADS binary sensor' + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ADS_VAR): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Binary Sensor platform for ADS.""" + ads_hub = hass.data.get(DATA_ADS) + + ads_var = config.get(CONF_ADS_VAR) + name = config.get(CONF_NAME) + device_class = config.get(CONF_DEVICE_CLASS) + + ads_sensor = AdsBinarySensor(ads_hub, name, ads_var, device_class) + add_devices([ads_sensor]) + + +class AdsBinarySensor(BinarySensorDevice): + """Representation of ADS binary sensors.""" + + def __init__(self, ads_hub, name, ads_var, device_class): + """Initialize AdsBinarySensor entity.""" + self._name = name + self._state = False + self._device_class = device_class or 'moving' + self._ads_hub = ads_hub + self.ads_var = ads_var + + @asyncio.coroutine + def async_added_to_hass(self): + """Register device notification.""" + def update(name, value): + """Handle device notifications.""" + _LOGGER.debug('Variable %s changed its value to %d', + name, value) + self._state = value + self.schedule_update_ha_state() + + self.hass.async_add_job( + self._ads_hub.add_device_notification, + self.ads_var, self._ads_hub.PLCTYPE_BOOL, update + ) + + @property + def name(self): + """Return the default name of the binary sensor.""" + return self._name + + @property + def device_class(self): + """Return the device class.""" + return self._device_class + + @property + def is_on(self): + """Return if the binary sensor is on.""" + return self._state + + @property + def should_poll(self): + """Return False because entity pushes its state to HA.""" + return False diff --git a/homeassistant/components/binary_sensor/alarmdecoder.py b/homeassistant/components/binary_sensor/alarmdecoder.py index bc05e4d84d8..f42d0de4bb0 100644 --- a/homeassistant/components/binary_sensor/alarmdecoder.py +++ b/homeassistant/components/binary_sensor/alarmdecoder.py @@ -7,39 +7,29 @@ https://home-assistant.io/components/binary_sensor.alarmdecoder/ import asyncio import logging -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.components.binary_sensor import BinarySensorDevice - -from homeassistant.components.alarmdecoder import (ZONE_SCHEMA, - CONF_ZONES, - CONF_ZONE_NAME, - CONF_ZONE_TYPE, - SIGNAL_ZONE_FAULT, - SIGNAL_ZONE_RESTORE) - +from homeassistant.components.alarmdecoder import ( + ZONE_SCHEMA, CONF_ZONES, CONF_ZONE_NAME, CONF_ZONE_TYPE, + SIGNAL_ZONE_FAULT, SIGNAL_ZONE_RESTORE) DEPENDENCIES = ['alarmdecoder'] _LOGGER = logging.getLogger(__name__) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the AlarmDecoder binary sensor devices.""" configured_zones = discovery_info[CONF_ZONES] devices = [] - for zone_num in configured_zones: device_config_data = ZONE_SCHEMA(configured_zones[zone_num]) zone_type = device_config_data[CONF_ZONE_TYPE] zone_name = device_config_data[CONF_ZONE_NAME] - device = AlarmDecoderBinarySensor( - hass, zone_num, zone_name, zone_type) + device = AlarmDecoderBinarySensor(zone_num, zone_name, zone_type) devices.append(device) - async_add_devices(devices) + add_devices(devices) return True @@ -47,7 +37,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class AlarmDecoderBinarySensor(BinarySensorDevice): """Representation of an AlarmDecoder binary sensor.""" - def __init__(self, hass, zone_number, zone_name, zone_type): + def __init__(self, zone_number, zone_name, zone_type): """Initialize the binary_sensor.""" self._zone_number = zone_number self._zone_type = zone_type @@ -55,16 +45,14 @@ class AlarmDecoderBinarySensor(BinarySensorDevice): self._name = zone_name self._type = zone_type - _LOGGER.debug("Setup up zone: %s", self._name) - @asyncio.coroutine def async_added_to_hass(self): """Register callbacks.""" - async_dispatcher_connect( - self.hass, SIGNAL_ZONE_FAULT, self._fault_callback) + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_ZONE_FAULT, self._fault_callback) - async_dispatcher_connect( - self.hass, SIGNAL_ZONE_RESTORE, self._restore_callback) + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_ZONE_RESTORE, self._restore_callback) @property def name(self): @@ -97,16 +85,14 @@ class AlarmDecoderBinarySensor(BinarySensorDevice): """Return the class of this sensor, from DEVICE_CLASSES.""" return self._zone_type - @callback def _fault_callback(self, zone): """Update the zone's state, if needed.""" if zone is None or int(zone) == self._zone_number: self._state = 1 - self.async_schedule_update_ha_state() + self.schedule_update_ha_state() - @callback def _restore_callback(self, zone): """Update the zone's state, if needed.""" if zone is None or int(zone) == self._zone_number: self._state = 0 - self.async_schedule_update_ha_state() + self.schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/isy994.py b/homeassistant/components/binary_sensor/isy994.py index fd6269e3630..a5b61c9ffed 100644 --- a/homeassistant/components/binary_sensor/isy994.py +++ b/homeassistant/components/binary_sensor/isy994.py @@ -4,24 +4,31 @@ Support for ISY994 binary sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.isy994/ """ + +import asyncio import logging +from datetime import timedelta from typing import Callable # noqa +from homeassistant.core import callback from homeassistant.components.binary_sensor import BinarySensorDevice, DOMAIN import homeassistant.components.isy994 as isy from homeassistant.const import STATE_ON, STATE_OFF from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) -VALUE_TO_STATE = { - False: STATE_OFF, - True: STATE_ON, -} - UOM = ['2', '78'] STATES = [STATE_OFF, STATE_ON, 'true', 'false'] +ISY_DEVICE_TYPES = { + 'moisture': ['16.8', '16.13', '16.14'], + 'opening': ['16.9', '16.6', '16.7', '16.2', '16.17', '16.20', '16.21'], + 'motion': ['16.1', '16.4', '16.5', '16.3'] +} + # pylint: disable=unused-argument def setup_platform(hass, config: ConfigType, @@ -32,10 +39,46 @@ def setup_platform(hass, config: ConfigType, return False devices = [] + devices_by_nid = {} + child_nodes = [] for node in isy.filter_nodes(isy.SENSOR_NODES, units=UOM, states=STATES): - devices.append(ISYBinarySensorDevice(node)) + if node.parent_node is None: + device = ISYBinarySensorDevice(node) + devices.append(device) + devices_by_nid[node.nid] = device + else: + # We'll process the child nodes last, to ensure all parent nodes + # have been processed + child_nodes.append(node) + + for node in child_nodes: + try: + parent_device = devices_by_nid[node.parent_node.nid] + except KeyError: + _LOGGER.error("Node %s has a parent node %s, but no device " + "was created for the parent. Skipping.", + node.nid, node.parent_nid) + else: + device_type = _detect_device_type(node) + if device_type in ['moisture', 'opening']: + subnode_id = int(node.nid[-1]) + # Leak and door/window sensors work the same way with negative + # nodes and heartbeat nodes + if subnode_id == 4: + # Subnode 4 is the heartbeat node, which we will represent + # as a separate binary_sensor + device = ISYBinarySensorHeartbeat(node, parent_device) + parent_device.add_heartbeat_device(device) + devices.append(device) + elif subnode_id == 2: + parent_device.add_negative_node(node) + else: + # We don't yet have any special logic for other sensor types, + # so add the nodes as individual devices + device = ISYBinarySensorDevice(node) + devices.append(device) for program in isy.PROGRAMS.get(DOMAIN, []): try: @@ -48,23 +91,281 @@ def setup_platform(hass, config: ConfigType, add_devices(devices) +def _detect_device_type(node) -> str: + try: + device_type = node.type + except AttributeError: + # The type attribute didn't exist in the ISY's API response + return None + + split_type = device_type.split('.') + for device_class, ids in ISY_DEVICE_TYPES.items(): + if '{}.{}'.format(split_type[0], split_type[1]) in ids: + return device_class + + return None + + +def _is_val_unknown(val): + """Determine if a number value represents UNKNOWN from PyISY.""" + return val == -1*float('inf') + + class ISYBinarySensorDevice(isy.ISYDevice, BinarySensorDevice): - """Representation of an ISY994 binary sensor device.""" + """Representation of an ISY994 binary sensor device. + + Often times, a single device is represented by multiple nodes in the ISY, + allowing for different nuances in how those devices report their on and + off events. This class turns those multiple nodes in to a single Hass + entity and handles both ways that ISY binary sensors can work. + """ def __init__(self, node) -> None: """Initialize the ISY994 binary sensor device.""" - isy.ISYDevice.__init__(self, node) + super().__init__(node) + self._negative_node = None + self._heartbeat_device = None + self._device_class_from_type = _detect_device_type(self._node) + # pylint: disable=protected-access + if _is_val_unknown(self._node.status._val): + self._computed_state = None + else: + self._computed_state = bool(self._node.status._val) + + @asyncio.coroutine + def async_added_to_hass(self) -> None: + """Subscribe to the node and subnode event emitters.""" + yield from super().async_added_to_hass() + + self._node.controlEvents.subscribe(self._positive_node_control_handler) + + if self._negative_node is not None: + self._negative_node.controlEvents.subscribe( + self._negative_node_control_handler) + + def add_heartbeat_device(self, device) -> None: + """Register a heartbeat device for this sensor. + + The heartbeat node beats on its own, but we can gain a little + reliability by considering any node activity for this sensor + to be a heartbeat as well. + """ + self._heartbeat_device = device + + def _heartbeat(self) -> None: + """Send a heartbeat to our heartbeat device, if we have one.""" + if self._heartbeat_device is not None: + self._heartbeat_device.heartbeat() + + def add_negative_node(self, child) -> None: + """Add a negative node to this binary sensor device. + + The negative node is a node that can receive the 'off' events + for the sensor, depending on device configuration and type. + """ + self._negative_node = child + + if not _is_val_unknown(self._negative_node): + # If the negative node has a value, it means the negative node is + # in use for this device. Therefore, we cannot determine the state + # of the sensor until we receive our first ON event. + self._computed_state = None + + def _negative_node_control_handler(self, event: object) -> None: + """Handle an "On" control event from the "negative" node.""" + if event == 'DON': + _LOGGER.debug("Sensor %s turning Off via the Negative node " + "sending a DON command", self.name) + self._computed_state = False + self.schedule_update_ha_state() + self._heartbeat() + + def _positive_node_control_handler(self, event: object) -> None: + """Handle On and Off control event coming from the primary node. + + Depending on device configuration, sometimes only On events + will come to this node, with the negative node representing Off + events + """ + if event == 'DON': + _LOGGER.debug("Sensor %s turning On via the Primary node " + "sending a DON command", self.name) + self._computed_state = True + self.schedule_update_ha_state() + self._heartbeat() + if event == 'DOF': + _LOGGER.debug("Sensor %s turning Off via the Primary node " + "sending a DOF command", self.name) + self._computed_state = False + self.schedule_update_ha_state() + self._heartbeat() + + # pylint: disable=unused-argument + def on_update(self, event: object) -> None: + """Ignore primary node status updates. + + We listen directly to the Control events on all nodes for this + device. + """ + pass + + @property + def value(self) -> object: + """Get the current value of the device. + + Insteon leak sensors set their primary node to On when the state is + DRY, not WET, so we invert the binary state if the user indicates + that it is a moisture sensor. + """ + if self._computed_state is None: + # Do this first so we don't invert None on moisture sensors + return None + + if self.device_class == 'moisture': + return not self._computed_state + + return self._computed_state + + @property + def is_on(self) -> bool: + """Get whether the ISY994 binary sensor device is on. + + Note: This method will return false if the current state is UNKNOWN + """ + return bool(self.value) + + @property + def state(self): + """Return the state of the binary sensor.""" + if self._computed_state is None: + return None + return STATE_ON if self.is_on else STATE_OFF + + @property + def device_class(self) -> str: + """Return the class of this device. + + This was discovered by parsing the device type code during init + """ + return self._device_class_from_type + + +class ISYBinarySensorHeartbeat(isy.ISYDevice, BinarySensorDevice): + """Representation of the battery state of an ISY994 sensor.""" + + def __init__(self, node, parent_device) -> None: + """Initialize the ISY994 binary sensor device.""" + super().__init__(node) + self._computed_state = None + self._parent_device = parent_device + self._heartbeat_timer = None + + @asyncio.coroutine + def async_added_to_hass(self) -> None: + """Subscribe to the node and subnode event emitters.""" + yield from super().async_added_to_hass() + + self._node.controlEvents.subscribe( + self._heartbeat_node_control_handler) + + # Start the timer on bootup, so we can change from UNKNOWN to ON + self._restart_timer() + + def _heartbeat_node_control_handler(self, event: object) -> None: + """Update the heartbeat timestamp when an On event is sent.""" + if event == 'DON': + self.heartbeat() + + def heartbeat(self): + """Mark the device as online, and restart the 25 hour timer. + + This gets called when the heartbeat node beats, but also when the + parent sensor sends any events, as we can trust that to mean the device + is online. This mitigates the risk of false positives due to a single + missed heartbeat event. + """ + self._computed_state = False + self._restart_timer() + self.schedule_update_ha_state() + + def _restart_timer(self): + """Restart the 25 hour timer.""" + try: + self._heartbeat_timer() + self._heartbeat_timer = None + except TypeError: + # No heartbeat timer is active + pass + + # pylint: disable=unused-argument + @callback + def timer_elapsed(now) -> None: + """Heartbeat missed; set state to indicate dead battery.""" + self._computed_state = True + self._heartbeat_timer = None + self.schedule_update_ha_state() + + point_in_time = dt_util.utcnow() + timedelta(hours=25) + _LOGGER.debug("Timer starting. Now: %s Then: %s", + dt_util.utcnow(), point_in_time) + + self._heartbeat_timer = async_track_point_in_utc_time( + self.hass, timer_elapsed, point_in_time) + + # pylint: disable=unused-argument + def on_update(self, event: object) -> None: + """Ignore node status updates. + + We listen directly to the Control events for this device. + """ + pass + + @property + def value(self) -> object: + """Get the current value of this sensor.""" + return self._computed_state + + @property + def is_on(self) -> bool: + """Get whether the ISY994 binary sensor device is on. + + Note: This method will return false if the current state is UNKNOWN + """ + return bool(self.value) + + @property + def state(self): + """Return the state of the binary sensor.""" + if self._computed_state is None: + return None + return STATE_ON if self.is_on else STATE_OFF + + @property + def device_class(self) -> str: + """Get the class of this device.""" + return 'battery' + + @property + def device_state_attributes(self): + """Get the state attributes for the device.""" + attr = super().device_state_attributes + attr['parent_entity_id'] = self._parent_device.entity_id + return attr + + +class ISYBinarySensorProgram(isy.ISYDevice, BinarySensorDevice): + """Representation of an ISY994 binary sensor program. + + This does not need all of the subnode logic in the device version of binary + sensors. + """ + + def __init__(self, name, node) -> None: + """Initialize the ISY994 binary sensor program.""" + super().__init__(node) + self._name = name @property def is_on(self) -> bool: """Get whether the ISY994 binary sensor device is on.""" return bool(self.value) - - -class ISYBinarySensorProgram(ISYBinarySensorDevice): - """Representation of an ISY994 binary sensor program.""" - - def __init__(self, name, node) -> None: - """Initialize the ISY994 binary sensor program.""" - ISYBinarySensorDevice.__init__(self, node) - self._name = name diff --git a/homeassistant/components/binary_sensor/vera.py b/homeassistant/components/binary_sensor/vera.py index e16f4e17fa0..e87886376bc 100644 --- a/homeassistant/components/binary_sensor/vera.py +++ b/homeassistant/components/binary_sensor/vera.py @@ -19,8 +19,8 @@ _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices, discovery_info=None): """Perform the setup for Vera controller devices.""" add_devices( - VeraBinarySensor(device, VERA_CONTROLLER) - for device in VERA_DEVICES['binary_sensor']) + VeraBinarySensor(device, hass.data[VERA_CONTROLLER]) + for device in hass.data[VERA_DEVICES]['binary_sensor']) class VeraBinarySensor(VeraDevice, BinarySensorDevice): diff --git a/homeassistant/components/calendar/caldav.py b/homeassistant/components/calendar/caldav.py new file mode 100644 index 00000000000..1647b9522b8 --- /dev/null +++ b/homeassistant/components/calendar/caldav.py @@ -0,0 +1,230 @@ +""" +Support for WebDav Calendar. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/calendar.caldav/ +""" +import logging +import re +from datetime import datetime, timedelta + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.calendar import ( + CalendarEventDevice, PLATFORM_SCHEMA) +from homeassistant.const import ( + CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME) +from homeassistant.util import dt, Throttle + +REQUIREMENTS = ['caldav==0.5.0'] + +_LOGGER = logging.getLogger(__name__) + +CONF_DEVICE_ID = 'device_id' +CONF_CALENDARS = 'calendars' +CONF_CUSTOM_CALENDARS = 'custom_calendars' +CONF_CALENDAR = 'calendar' +CONF_ALL_DAY = 'all_day' +CONF_SEARCH = 'search' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_URL): vol.Url, + vol.Optional(CONF_CALENDARS, default=[]): + vol.All(cv.ensure_list, vol.Schema([ + cv.string + ])), + vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string, + vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string, + vol.Optional(CONF_CUSTOM_CALENDARS, default=[]): + vol.All(cv.ensure_list, vol.Schema([ + vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_CALENDAR): cv.string, + vol.Required(CONF_SEARCH): cv.string + }) + ])) +}) + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) + + +def setup_platform(hass, config, add_devices, disc_info=None): + """Set up the WebDav Calendar platform.""" + import caldav + + client = caldav.DAVClient(config.get(CONF_URL), + None, + config.get(CONF_USERNAME), + config.get(CONF_PASSWORD)) + + # Retrieve all the remote calendars + calendars = client.principal().calendars() + + calendar_devices = [] + for calendar in list(calendars): + # If a calendar name was given in the configuration, + # ignore all the others + if (config.get(CONF_CALENDARS) + and calendar.name not in config.get(CONF_CALENDARS)): + _LOGGER.debug("Ignoring calendar '%s'", calendar.name) + continue + + # Create additional calendars based on custom filtering + # rules + for cust_calendar in config.get(CONF_CUSTOM_CALENDARS): + # Check that the base calendar matches + if cust_calendar.get(CONF_CALENDAR) != calendar.name: + continue + + device_data = { + CONF_NAME: cust_calendar.get(CONF_NAME), + CONF_DEVICE_ID: "{} {}".format( + cust_calendar.get(CONF_CALENDAR), + cust_calendar.get(CONF_NAME)), + } + + calendar_devices.append( + WebDavCalendarEventDevice(hass, + device_data, + calendar, + cust_calendar.get(CONF_ALL_DAY), + cust_calendar.get(CONF_SEARCH)) + ) + + # Create a default calendar if there was no custom one + if not config.get(CONF_CUSTOM_CALENDARS): + device_data = { + CONF_NAME: calendar.name, + CONF_DEVICE_ID: calendar.name + } + calendar_devices.append( + WebDavCalendarEventDevice(hass, device_data, calendar) + ) + + # Finally add all the calendars we've created + add_devices(calendar_devices) + + +class WebDavCalendarEventDevice(CalendarEventDevice): + """A device for getting the next Task from a WebDav Calendar.""" + + def __init__(self, + hass, + device_data, + calendar, + all_day=False, + search=None): + """Create the WebDav Calendar Event Device.""" + self.data = WebDavCalendarData(calendar, all_day, search) + super().__init__(hass, device_data) + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + if self.data.event is None: + # No tasks, we don't REALLY need to show anything. + return {} + + attributes = super().device_state_attributes + return attributes + + +class WebDavCalendarData(object): + """Class to utilize the calendar dav client object to get next event.""" + + def __init__(self, calendar, include_all_day, search): + """Set up how we are going to search the WebDav calendar.""" + self.calendar = calendar + self.include_all_day = include_all_day + self.search = search + self.event = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data.""" + # We have to retrieve the results for the whole day as the server + # won't return events that have already started + results = self.calendar.date_search( + dt.start_of_local_day(), + dt.start_of_local_day() + timedelta(days=1) + ) + + # dtstart can be a date or datetime depending if the event lasts a + # whole day. Convert everything to datetime to be able to sort it + results.sort(key=lambda x: self.to_datetime( + x.instance.vevent.dtstart.value + )) + + vevent = next(( + event.instance.vevent for event in results + if (self.is_matching(event.instance.vevent, self.search) + and (not self.is_all_day(event.instance.vevent) + or self.include_all_day) + and not self.is_over(event.instance.vevent))), None) + + # If no matching event could be found + if vevent is None: + _LOGGER.debug( + "No matching event found in the %d results for %s", + len(results), + self.calendar.name, + ) + self.event = None + return True + + # Populate the entity attributes with the event values + self.event = { + "summary": vevent.summary.value, + "start": self.get_hass_date(vevent.dtstart.value), + "end": self.get_hass_date(vevent.dtend.value), + "location": self.get_attr_value(vevent, "location"), + "description": self.get_attr_value(vevent, "description") + } + return True + + @staticmethod + def is_matching(vevent, search): + """Return if the event matches the filter critera.""" + if search is None: + return True + + pattern = re.compile(search) + return (hasattr(vevent, "summary") + and pattern.match(vevent.summary.value) + or hasattr(vevent, "location") + and pattern.match(vevent.location.value) + or hasattr(vevent, "description") + and pattern.match(vevent.description.value)) + + @staticmethod + def is_all_day(vevent): + """Return if the event last the whole day.""" + return not isinstance(vevent.dtstart.value, datetime) + + @staticmethod + def is_over(vevent): + """Return if the event is over.""" + return dt.now() > WebDavCalendarData.to_datetime(vevent.dtend.value) + + @staticmethod + def get_hass_date(obj): + """Return if the event matches.""" + if isinstance(obj, datetime): + return {"dateTime": obj.isoformat()} + + return {"date": obj.isoformat()} + + @staticmethod + def to_datetime(obj): + """Return a datetime.""" + if isinstance(obj, datetime): + return obj + return dt.as_local(dt.dt.datetime.combine(obj, dt.dt.time.min)) + + @staticmethod + def get_attr_value(obj, attribute): + """Return the value of the attribute if defined.""" + if hasattr(obj, attribute): + return getattr(obj, attribute).value + return None diff --git a/homeassistant/components/camera/canary.py b/homeassistant/components/camera/canary.py new file mode 100644 index 00000000000..302758eee94 --- /dev/null +++ b/homeassistant/components/camera/canary.py @@ -0,0 +1,95 @@ +""" +Support for Canary camera. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.canary/ +""" +import logging + +import requests + +from homeassistant.components.camera import Camera +from homeassistant.components.canary import DATA_CANARY, DEFAULT_TIMEOUT + +DEPENDENCIES = ['canary'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_MOTION_START_TIME = "motion_start_time" +ATTR_MOTION_END_TIME = "motion_end_time" + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Canary sensors.""" + data = hass.data[DATA_CANARY] + devices = [] + + for location in data.locations: + entries = data.get_motion_entries(location.location_id) + if entries: + devices.append(CanaryCamera(data, location.location_id, + DEFAULT_TIMEOUT)) + + add_devices(devices, True) + + +class CanaryCamera(Camera): + """An implementation of a Canary security camera.""" + + def __init__(self, data, location_id, timeout): + """Initialize a Canary security camera.""" + super().__init__() + self._data = data + self._location_id = location_id + self._timeout = timeout + + self._location = None + self._motion_entry = None + self._image_content = None + + def camera_image(self): + """Update the status of the camera and return bytes of camera image.""" + self.update() + return self._image_content + + @property + def name(self): + """Return the name of this device.""" + return self._location.name + + @property + def is_recording(self): + """Return true if the device is recording.""" + return self._location.is_recording + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + if self._motion_entry is None: + return None + + return { + ATTR_MOTION_START_TIME: self._motion_entry.start_time, + ATTR_MOTION_END_TIME: self._motion_entry.end_time, + } + + def update(self): + """Update the status of the camera.""" + self._data.update() + self._location = self._data.get_location(self._location_id) + + entries = self._data.get_motion_entries(self._location_id) + if entries: + current = entries[0] + previous = self._motion_entry + + if previous is None or previous.entry_id != current.entry_id: + self._motion_entry = current + self._image_content = requests.get( + current.thumbnails[0].image_url, + timeout=self._timeout).content + + @property + def motion_detection_enabled(self): + """Return the camera motion detection status.""" + return not self._location.is_recording diff --git a/homeassistant/components/canary.py b/homeassistant/components/canary.py new file mode 100644 index 00000000000..8ab7218e201 --- /dev/null +++ b/homeassistant/components/canary.py @@ -0,0 +1,117 @@ +""" +Support for Canary. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/canary/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol +from requests import ConnectTimeout, HTTPError + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_TIMEOUT +from homeassistant.helpers import discovery +from homeassistant.util import Throttle + +REQUIREMENTS = ['py-canary==0.2.3'] + +_LOGGER = logging.getLogger(__name__) + +NOTIFICATION_ID = 'canary_notification' +NOTIFICATION_TITLE = 'Canary Setup' + +DOMAIN = 'canary' +DATA_CANARY = 'canary' +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) +DEFAULT_TIMEOUT = 10 + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + }), +}, extra=vol.ALLOW_EXTRA) + +CANARY_COMPONENTS = [ + 'alarm_control_panel', 'camera', 'sensor' +] + + +def setup(hass, config): + """Set up the Canary component.""" + conf = config[DOMAIN] + username = conf.get(CONF_USERNAME) + password = conf.get(CONF_PASSWORD) + timeout = conf.get(CONF_TIMEOUT) + + try: + hass.data[DATA_CANARY] = CanaryData(username, password, timeout) + except (ConnectTimeout, HTTPError) as ex: + _LOGGER.error("Unable to connect to Canary service: %s", str(ex)) + hass.components.persistent_notification.create( + 'Error: {}
' + 'You will need to restart hass after fixing.' + ''.format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + return False + + for component in CANARY_COMPONENTS: + discovery.load_platform(hass, component, DOMAIN, {}, config) + + return True + + +class CanaryData(object): + """Get the latest data and update the states.""" + + def __init__(self, username, password, timeout): + """Init the Canary data object.""" + from canary.api import Api + self._api = Api(username, password, timeout) + + self._locations_by_id = {} + self._readings_by_device_id = {} + self._entries_by_location_id = {} + + self.update() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self, **kwargs): + """Get the latest data from py-canary.""" + for location in self._api.get_locations(): + location_id = location.location_id + + self._locations_by_id[location_id] = location + self._entries_by_location_id[location_id] = self._api.get_entries( + location_id, entry_type="motion", limit=1) + + for device in location.devices: + if device.is_online: + self._readings_by_device_id[device.device_id] = \ + self._api.get_latest_readings(device.device_id) + + @property + def locations(self): + """Return a list of locations.""" + return self._locations_by_id.values() + + def get_motion_entries(self, location_id): + """Return a list of motion entries based on location_id.""" + return self._entries_by_location_id.get(location_id, []) + + def get_location(self, location_id): + """Return a location based on location_id.""" + return self._locations_by_id.get(location_id, []) + + def get_readings(self, device_id): + """Return a list of readings based on device_id.""" + return self._readings_by_device_id.get(device_id, []) + + def set_location_mode(self, location_id, mode_name, is_private=False): + """Set location mode.""" + self._api.set_location_mode(location_id, mode_name, is_private) + self.update(no_throttle=True) diff --git a/homeassistant/components/climate/generic_thermostat.py b/homeassistant/components/climate/generic_thermostat.py index 987708834cc..6574a4d5396 100644 --- a/homeassistant/components/climate/generic_thermostat.py +++ b/homeassistant/components/climate/generic_thermostat.py @@ -13,7 +13,8 @@ from homeassistant.core import callback from homeassistant.core import DOMAIN as HA_DOMAIN from homeassistant.components.climate import ( STATE_HEAT, STATE_COOL, STATE_IDLE, ClimateDevice, PLATFORM_SCHEMA, - STATE_AUTO, SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE) + STATE_AUTO, ATTR_OPERATION_MODE, SUPPORT_OPERATION_MODE, + SUPPORT_TARGET_TEMPERATURE) from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, STATE_ON, STATE_OFF, ATTR_TEMPERATURE, CONF_NAME, ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF) @@ -40,7 +41,7 @@ CONF_MIN_DUR = 'min_cycle_duration' CONF_COLD_TOLERANCE = 'cold_tolerance' CONF_HOT_TOLERANCE = 'hot_tolerance' CONF_KEEP_ALIVE = 'keep_alive' - +CONF_INITIAL_OPERATION_MODE = 'initial_operation_mode' SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -58,6 +59,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_TARGET_TEMP): vol.Coerce(float), vol.Optional(CONF_KEEP_ALIVE): vol.All( cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_INITIAL_OPERATION_MODE): + vol.In([STATE_AUTO, STATE_OFF]) }) @@ -75,11 +78,12 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): cold_tolerance = config.get(CONF_COLD_TOLERANCE) hot_tolerance = config.get(CONF_HOT_TOLERANCE) keep_alive = config.get(CONF_KEEP_ALIVE) + initial_operation_mode = config.get(CONF_INITIAL_OPERATION_MODE) async_add_devices([GenericThermostat( hass, name, heater_entity_id, sensor_entity_id, min_temp, max_temp, target_temp, ac_mode, min_cycle_duration, cold_tolerance, - hot_tolerance, keep_alive)]) + hot_tolerance, keep_alive, initial_operation_mode)]) class GenericThermostat(ClimateDevice): @@ -87,7 +91,8 @@ class GenericThermostat(ClimateDevice): def __init__(self, hass, name, heater_entity_id, sensor_entity_id, min_temp, max_temp, target_temp, ac_mode, min_cycle_duration, - cold_tolerance, hot_tolerance, keep_alive): + cold_tolerance, hot_tolerance, keep_alive, + initial_operation_mode): """Initialize the thermostat.""" self.hass = hass self._name = name @@ -97,7 +102,11 @@ class GenericThermostat(ClimateDevice): self._cold_tolerance = cold_tolerance self._hot_tolerance = hot_tolerance self._keep_alive = keep_alive - self._enabled = True + self._initial_operation_mode = initial_operation_mode + if initial_operation_mode == STATE_OFF: + self._enabled = False + else: + self._enabled = True self._active = False self._cur_temp = None @@ -122,14 +131,20 @@ class GenericThermostat(ClimateDevice): @asyncio.coroutine def async_added_to_hass(self): """Run when entity about to be added.""" - # If we have an old state and no target temp, restore - if self._target_temp is None: - old_state = yield from async_get_last_state(self.hass, - self.entity_id) - if old_state is not None: + # Check If we have an old state + old_state = yield from async_get_last_state(self.hass, + self.entity_id) + if old_state is not None: + # If we have no initial temperature, restore + if self._target_temp is None: self._target_temp = float( old_state.attributes[ATTR_TEMPERATURE]) + # If we have no initial operation mode, restore + if self._initial_operation_mode is None: + if old_state.attributes[ATTR_OPERATION_MODE] == STATE_OFF: + self._enabled = False + @property def should_poll(self): """Return the polling state.""" diff --git a/homeassistant/components/climate/tado.py b/homeassistant/components/climate/tado.py index d58acac5373..a8054b838ef 100644 --- a/homeassistant/components/climate/tado.py +++ b/homeassistant/components/climate/tado.py @@ -59,8 +59,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): climate_devices = [] for zone in zones: - climate_devices.append(create_climate_device( - tado, hass, zone, zone['name'], zone['id'])) + device = create_climate_device( + tado, hass, zone, zone['name'], zone['id']) + if not device: + continue + climate_devices.append(device) if climate_devices: add_devices(climate_devices, True) @@ -75,8 +78,11 @@ def create_climate_device(tado, hass, zone, name, zone_id): if ac_mode: temperatures = capabilities['HEAT']['temperatures'] - else: + elif 'temperatures' in capabilities: temperatures = capabilities['temperatures'] + else: + _LOGGER.debug("Received zone %s has no temperature; not adding", name) + return min_temp = float(temperatures['celsius']['min']) max_temp = float(temperatures['celsius']['max']) diff --git a/homeassistant/components/climate/vera.py b/homeassistant/components/climate/vera.py index 4644f86cba2..c9d22e41d81 100644 --- a/homeassistant/components/climate/vera.py +++ b/homeassistant/components/climate/vera.py @@ -32,8 +32,8 @@ SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE | def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Set up of Vera thermostats.""" add_devices_callback( - VeraThermostat(device, VERA_CONTROLLER) for - device in VERA_DEVICES['climate']) + VeraThermostat(device, hass.data[VERA_CONTROLLER]) for + device in hass.data[VERA_DEVICES]['climate']) class VeraThermostat(VeraDevice, ClimateDevice): diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 9bd91d22beb..58a2152f898 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -16,7 +16,7 @@ from homeassistant.components.alexa import smart_home from . import http_api, iot from .const import CONFIG_DIR, DOMAIN, SERVERS -REQUIREMENTS = ['warrant==0.5.0'] +REQUIREMENTS = ['warrant==0.6.1'] _LOGGER = logging.getLogger(__name__) @@ -27,7 +27,7 @@ CONF_RELAYER = 'relayer' CONF_USER_POOL_ID = 'user_pool_id' MODE_DEV = 'development' -DEFAULT_MODE = MODE_DEV +DEFAULT_MODE = 'production' DEPENDENCIES = ['http'] ALEXA_SCHEMA = vol.Schema({ @@ -42,10 +42,10 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_MODE, default=DEFAULT_MODE): vol.In([MODE_DEV] + list(SERVERS)), # Change to optional when we include real servers - vol.Required(CONF_COGNITO_CLIENT_ID): str, - vol.Required(CONF_USER_POOL_ID): str, - vol.Required(CONF_REGION): str, - vol.Required(CONF_RELAYER): str, + vol.Optional(CONF_COGNITO_CLIENT_ID): str, + vol.Optional(CONF_USER_POOL_ID): str, + vol.Optional(CONF_REGION): str, + vol.Optional(CONF_RELAYER): str, vol.Optional(CONF_ALEXA): ALEXA_SCHEMA }), }, extra=vol.ALLOW_EXTRA) @@ -117,10 +117,6 @@ class Cloud: @property def subscription_expired(self): """Return a boolen if the subscription has expired.""" - # For now, don't enforce subscriptions to exist - if 'custom:sub-exp' not in self.claims: - return False - return dt_util.utcnow() > self.expiration_date @property diff --git a/homeassistant/components/cloud/auth_api.py b/homeassistant/components/cloud/auth_api.py index 95bf5596835..9cad3ec77f3 100644 --- a/homeassistant/components/cloud/auth_api.py +++ b/homeassistant/components/cloud/auth_api.py @@ -68,11 +68,14 @@ def register(cloud, email, password): from botocore.exceptions import ClientError cognito = _cognito(cloud) + # Workaround for bug in Warrant. PR with fix: + # https://github.com/capless/warrant/pull/82 + cognito.add_base_attributes() try: if cloud.cognito_email_based: - cognito.register(email, password, email=email) + cognito.register(email, password) else: - cognito.register(_generate_username(email), password, email=email) + cognito.register(_generate_username(email), password) except ClientError as err: raise _map_aws_exception(err) diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 440e4179eea..b13ec6d1e45 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -4,13 +4,12 @@ CONFIG_DIR = '.cloud' REQUEST_TIMEOUT = 10 SERVERS = { - # Example entry: - # 'production': { - # 'cognito_client_id': '', - # 'user_pool_id': '', - # 'region': '', - # 'relayer': '' - # } + 'production': { + 'cognito_client_id': '60i2uvhvbiref2mftj7rgcrt9u', + 'user_pool_id': 'us-east-1_87ll5WOP8', + 'region': 'us-east-1', + 'relayer': 'wss://cloud.hass.io:8000/websocket' + } } MESSAGE_EXPIRATION = """ diff --git a/homeassistant/components/config/automation.py b/homeassistant/components/config/automation.py index 64eccfaa2b8..6ede91e9b66 100644 --- a/homeassistant/components/config/automation.py +++ b/homeassistant/components/config/automation.py @@ -1,4 +1,4 @@ -"""Provide configuration end points for Z-Wave.""" +"""Provide configuration end points for Automations.""" import asyncio from homeassistant.components.config import EditIdBasedConfigView diff --git a/homeassistant/components/cover/isy994.py b/homeassistant/components/cover/isy994.py index 1e83038278c..4dd1c9be364 100644 --- a/homeassistant/components/cover/isy994.py +++ b/homeassistant/components/cover/isy994.py @@ -69,7 +69,10 @@ class ISYCoverDevice(isy.ISYDevice, CoverDevice): @property def state(self) -> str: """Get the state of the ISY994 cover device.""" - return VALUE_TO_STATE.get(self.value, STATE_OPEN) + if self.is_unknown(): + return None + else: + return VALUE_TO_STATE.get(self.value, STATE_OPEN) def open_cover(self, **kwargs) -> None: """Send the open cover command to the ISY994 cover device.""" diff --git a/homeassistant/components/cover/tellstick.py b/homeassistant/components/cover/tellstick.py new file mode 100755 index 00000000000..56a5a24b409 --- /dev/null +++ b/homeassistant/components/cover/tellstick.py @@ -0,0 +1,65 @@ +""" +Support for Tellstick covers. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.tellstick/ +""" + + +from homeassistant.components.cover import CoverDevice +from homeassistant.components.tellstick import ( + DEFAULT_SIGNAL_REPETITIONS, ATTR_DISCOVER_DEVICES, ATTR_DISCOVER_CONFIG, + DATA_TELLSTICK, TellstickDevice) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Tellstick covers.""" + if (discovery_info is None or + discovery_info[ATTR_DISCOVER_DEVICES] is None): + return + + signal_repetitions = discovery_info.get( + ATTR_DISCOVER_CONFIG, DEFAULT_SIGNAL_REPETITIONS) + + add_devices([TellstickCover(hass.data[DATA_TELLSTICK][tellcore_id], + signal_repetitions) + for tellcore_id in discovery_info[ATTR_DISCOVER_DEVICES]], + True) + + +class TellstickCover(TellstickDevice, CoverDevice): + """Representation of a Tellstick cover.""" + + @property + def is_closed(self): + """Return the current position of the cover is not possible.""" + return None + + @property + def assumed_state(self): + """Return True if unable to access real state of the entity.""" + return True + + def close_cover(self, **kwargs): + """Close the cover.""" + self._tellcore_device.down() + + def open_cover(self, **kwargs): + """Open the cover.""" + self._tellcore_device.up() + + def stop_cover(self, **kwargs): + """Stop the cover.""" + self._tellcore_device.stop() + + def _parse_tellcore_data(self, tellcore_data): + """Turn the value received from tellcore into something useful.""" + pass + + def _parse_ha_data(self, kwargs): + """Turn the value from HA into something useful.""" + pass + + def _update_model(self, new_state, data): + """Update the device entity state to match the arguments.""" + pass diff --git a/homeassistant/components/cover/vera.py b/homeassistant/components/cover/vera.py index 05be125ec6f..6cf269b75b3 100644 --- a/homeassistant/components/cover/vera.py +++ b/homeassistant/components/cover/vera.py @@ -18,8 +18,8 @@ _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Vera covers.""" add_devices( - VeraCover(device, VERA_CONTROLLER) for - device in VERA_DEVICES['cover']) + VeraCover(device, hass.data[VERA_CONTROLLER]) for + device in hass.data[VERA_DEVICES]['cover']) class VeraCover(VeraDevice, CoverDevice): diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 0b18cc72f6e..28505900f14 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -53,6 +53,7 @@ YAML_DEVICES = 'known_devices.yaml' CONF_TRACK_NEW = 'track_new_devices' DEFAULT_TRACK_NEW = True +CONF_NEW_DEVICE_DEFAULTS = 'new_device_defaults' CONF_CONSIDER_HOME = 'consider_home' DEFAULT_CONSIDER_HOME = timedelta(seconds=180) @@ -81,12 +82,18 @@ ATTR_VENDOR = 'vendor' SOURCE_TYPE_GPS = 'gps' SOURCE_TYPE_ROUTER = 'router' +NEW_DEVICE_DEFAULTS_SCHEMA = vol.Any(None, vol.Schema({ + vol.Optional(CONF_TRACK_NEW, default=DEFAULT_TRACK_NEW): cv.boolean, + vol.Optional(CONF_AWAY_HIDE, default=DEFAULT_AWAY_HIDE): cv.boolean, +})) PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({ vol.Optional(CONF_SCAN_INTERVAL): cv.time_period, vol.Optional(CONF_TRACK_NEW, default=DEFAULT_TRACK_NEW): cv.boolean, vol.Optional(CONF_CONSIDER_HOME, default=DEFAULT_CONSIDER_HOME): vol.All( - cv.time_period, cv.positive_timedelta) + cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_NEW_DEVICE_DEFAULTS, + default={}): NEW_DEVICE_DEFAULTS_SCHEMA }) @@ -125,9 +132,11 @@ def async_setup(hass: HomeAssistantType, config: ConfigType): conf = conf[0] if conf else {} consider_home = conf.get(CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME) track_new = conf.get(CONF_TRACK_NEW, DEFAULT_TRACK_NEW) + defaults = conf.get(CONF_NEW_DEVICE_DEFAULTS, {}) devices = yield from async_load_config(yaml_path, hass, consider_home) - tracker = DeviceTracker(hass, consider_home, track_new, devices) + tracker = DeviceTracker( + hass, consider_home, track_new, defaults, devices) @asyncio.coroutine def async_setup_platform(p_type, p_config, disc_info=None): @@ -211,13 +220,15 @@ class DeviceTracker(object): """Representation of a device tracker.""" def __init__(self, hass: HomeAssistantType, consider_home: timedelta, - track_new: bool, devices: Sequence) -> None: + track_new: bool, defaults: dict, + devices: Sequence) -> None: """Initialize a device tracker.""" self.hass = hass self.devices = {dev.dev_id: dev for dev in devices} self.mac_to_dev = {dev.mac: dev for dev in devices if dev.mac} self.consider_home = consider_home - self.track_new = track_new + self.track_new = defaults.get(CONF_TRACK_NEW, track_new) + self.defaults = defaults self.group = None self._is_updating = asyncio.Lock(loop=hass.loop) @@ -274,7 +285,8 @@ class DeviceTracker(object): device = Device( self.hass, self.consider_home, self.track_new, dev_id, mac, (host_name or dev_id).replace('_', ' '), - picture=picture, icon=icon) + picture=picture, icon=icon, + hide_if_away=self.defaults.get(CONF_AWAY_HIDE, DEFAULT_AWAY_HIDE)) self.devices[dev_id] = device if mac is not None: self.mac_to_dev[mac] = device diff --git a/homeassistant/components/device_tracker/meraki.py b/homeassistant/components/device_tracker/meraki.py new file mode 100644 index 00000000000..9437486a0aa --- /dev/null +++ b/homeassistant/components/device_tracker/meraki.py @@ -0,0 +1,136 @@ +""" +Support for the Meraki CMX location service. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.meraki/ + +""" +import asyncio +import logging +import json + +import voluptuous as vol +import homeassistant.helpers.config_validation as cv +from homeassistant.const import (HTTP_BAD_REQUEST, HTTP_UNPROCESSABLE_ENTITY) +from homeassistant.core import callback +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.device_tracker import ( + PLATFORM_SCHEMA, SOURCE_TYPE_ROUTER) + +CONF_VALIDATOR = 'validator' +CONF_SECRET = 'secret' +DEPENDENCIES = ['http'] +URL = '/api/meraki' +VERSION = '2.0' + + +_LOGGER = logging.getLogger(__name__) + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_VALIDATOR): cv.string, + vol.Required(CONF_SECRET): cv.string +}) + + +@asyncio.coroutine +def async_setup_scanner(hass, config, async_see, discovery_info=None): + """Set up an endpoint for the Meraki tracker.""" + hass.http.register_view( + MerakiView(config, async_see)) + + return True + + +class MerakiView(HomeAssistantView): + """View to handle Meraki requests.""" + + url = URL + name = 'api:meraki' + + def __init__(self, config, async_see): + """Initialize Meraki URL endpoints.""" + self.async_see = async_see + self.validator = config[CONF_VALIDATOR] + self.secret = config[CONF_SECRET] + + @asyncio.coroutine + def get(self, request): + """Meraki message received as GET.""" + return self.validator + + @asyncio.coroutine + def post(self, request): + """Meraki CMX message received.""" + try: + data = yield from request.json() + except ValueError: + return self.json_message('Invalid JSON', HTTP_BAD_REQUEST) + _LOGGER.debug("Meraki Data from Post: %s", json.dumps(data)) + if not data.get('secret', False): + _LOGGER.error("secret invalid") + return self.json_message('No secret', HTTP_UNPROCESSABLE_ENTITY) + if data['secret'] != self.secret: + _LOGGER.error("Invalid Secret received from Meraki") + return self.json_message('Invalid secret', + HTTP_UNPROCESSABLE_ENTITY) + elif data['version'] != VERSION: + _LOGGER.error("Invalid API version: %s", data['version']) + return self.json_message('Invalid version', + HTTP_UNPROCESSABLE_ENTITY) + else: + _LOGGER.debug('Valid Secret') + if data['type'] not in ('DevicesSeen', 'BluetoothDevicesSeen'): + _LOGGER.error("Unknown Device %s", data['type']) + return self.json_message('Invalid device type', + HTTP_UNPROCESSABLE_ENTITY) + _LOGGER.debug("Processing %s", data['type']) + if len(data["data"]["observations"]) == 0: + _LOGGER.debug("No observations found") + return + self._handle(request.app['hass'], data) + + @callback + def _handle(self, hass, data): + for i in data["data"]["observations"]: + data["data"]["secret"] = "hidden" + + lat = i["location"]["lat"] + lng = i["location"]["lng"] + try: + accuracy = int(float(i["location"]["unc"])) + except ValueError: + accuracy = 0 + + mac = i["clientMac"] + _LOGGER.debug("clientMac: %s", mac) + + if lat == "NaN" or lng == "NaN": + _LOGGER.debug( + "No coordinates received, skipping location for: " + mac + ) + gps_location = None + accuracy = None + else: + gps_location = (lat, lng) + + attrs = {} + if i.get('os', False): + attrs['os'] = i['os'] + if i.get('manufacturer', False): + attrs['manufacturer'] = i['manufacturer'] + if i.get('ipv4', False): + attrs['ipv4'] = i['ipv4'] + if i.get('ipv6', False): + attrs['ipv6'] = i['ipv6'] + if i.get('seenTime', False): + attrs['seenTime'] = i['seenTime'] + if i.get('ssid', False): + attrs['ssid'] = i['ssid'] + hass.async_add_job(self.async_see( + gps=gps_location, + mac=mac, + source_type=SOURCE_TYPE_ROUTER, + gps_accuracy=accuracy, + attributes=attrs + )) diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 5d362f21cef..dde33aa10a2 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -36,6 +36,7 @@ SERVICE_APPLE_TV = 'apple_tv' SERVICE_WINK = 'wink' SERVICE_XIAOMI_GW = 'xiaomi_gw' SERVICE_TELLDUSLIVE = 'tellstick' +SERVICE_HUE = 'philips_hue' SERVICE_HANDLERS = { SERVICE_HASS_IOS_APP: ('ios', None), @@ -48,7 +49,7 @@ SERVICE_HANDLERS = { SERVICE_WINK: ('wink', None), SERVICE_XIAOMI_GW: ('xiaomi_aqara', None), SERVICE_TELLDUSLIVE: ('tellduslive', None), - 'philips_hue': ('light', 'hue'), + SERVICE_HUE: ('hue', None), 'google_cast': ('media_player', 'cast'), 'panasonic_viera': ('media_player', 'panasonic_viera'), 'plex_mediaserver': ('media_player', 'plex'), diff --git a/homeassistant/components/ecobee.py b/homeassistant/components/ecobee.py index a7246319e76..b4bb977ee70 100644 --- a/homeassistant/components/ecobee.py +++ b/homeassistant/components/ecobee.py @@ -16,7 +16,7 @@ from homeassistant.const import CONF_API_KEY from homeassistant.util import Throttle from homeassistant.util.json import save_json -REQUIREMENTS = ['python-ecobee-api==0.0.12'] +REQUIREMENTS = ['python-ecobee-api==0.0.14'] _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 56af5e07123..cd206135dde 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -23,7 +23,7 @@ from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import callback from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20171204.0', 'user-agents==1.1.0'] +REQUIREMENTS = ['home-assistant-frontend==20171216.0', 'user-agents==1.1.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] @@ -35,7 +35,7 @@ CONF_EXTRA_HTML_URL = 'extra_html_url' CONF_EXTRA_HTML_URL_ES5 = 'extra_html_url_es5' CONF_FRONTEND_REPO = 'development_repo' CONF_JS_VERSION = 'javascript_version' -JS_DEFAULT_OPTION = 'es5' +JS_DEFAULT_OPTION = 'auto' JS_OPTIONS = ['es5', 'latest', 'auto'] DEFAULT_THEME_COLOR = '#03A9F4' @@ -299,11 +299,16 @@ def async_setup(hass, config): hass.data[DATA_JS_VERSION] = js_version = conf.get(CONF_JS_VERSION) if is_dev: - hass.http.register_static_path( - "/home-assistant-polymer", repo_path, False) + for subpath in ["src", "build-translations", "build-temp", "build", + "hass_frontend", "bower_components", "panels"]: + hass.http.register_static_path( + "/home-assistant-polymer/{}".format(subpath), + os.path.join(repo_path, subpath), + False) + hass.http.register_static_path( "/static/translations", - os.path.join(repo_path, "build-translations"), False) + os.path.join(repo_path, "build-translations/output"), False) sw_path_es5 = os.path.join(repo_path, "build-es5/service_worker.js") sw_path_latest = os.path.join(repo_path, "build/service_worker.js") static_path = os.path.join(repo_path, 'hass_frontend') diff --git a/homeassistant/components/homematic.py b/homeassistant/components/homematic/__init__.py similarity index 80% rename from homeassistant/components/homematic.py rename to homeassistant/components/homematic/__init__.py index 5e8cd3dc58e..a11c8c0f22c 100644 --- a/homeassistant/components/homematic.py +++ b/homeassistant/components/homematic/__init__.py @@ -5,25 +5,26 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/homematic/ """ import asyncio -import os -import logging from datetime import timedelta from functools import partial +import logging +import os +import socket import voluptuous as vol -import homeassistant.helpers.config_validation as cv +from homeassistant.config import load_yaml_config_file from homeassistant.const import ( - EVENT_HOMEASSISTANT_STOP, STATE_UNKNOWN, CONF_USERNAME, CONF_PASSWORD, - CONF_PLATFORM, CONF_HOSTS, CONF_NAME, ATTR_ENTITY_ID) + EVENT_HOMEASSISTANT_STOP, CONF_USERNAME, CONF_PASSWORD, CONF_PLATFORM, + CONF_HOSTS, CONF_HOST, ATTR_ENTITY_ID, STATE_UNKNOWN) from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import track_time_interval -from homeassistant.config import load_yaml_config_file - -REQUIREMENTS = ['pyhomematic==0.1.35'] +import homeassistant.helpers.config_validation as cv +from homeassistant.loader import bind_hass +REQUIREMENTS = ['pyhomematic==0.1.36'] DOMAIN = 'homematic' +_LOGGER = logging.getLogger(__name__) SCAN_INTERVAL_HUB = timedelta(seconds=300) SCAN_INTERVAL_VARIABLES = timedelta(seconds=30) @@ -41,9 +42,11 @@ ATTR_CHANNEL = 'channel' ATTR_NAME = 'name' ATTR_ADDRESS = 'address' ATTR_VALUE = 'value' -ATTR_PROXY = 'proxy' +ATTR_INTERFACE = 'interface' ATTR_ERRORCODE = 'error' ATTR_MESSAGE = 'message' +ATTR_MODE = 'mode' +ATTR_TIME = 'time' EVENT_KEYPRESS = 'homematic.keypress' EVENT_IMPULSE = 'homematic.impulse' @@ -51,8 +54,9 @@ EVENT_ERROR = 'homematic.error' SERVICE_VIRTUALKEY = 'virtualkey' SERVICE_RECONNECT = 'reconnect' -SERVICE_SET_VAR_VALUE = 'set_var_value' -SERVICE_SET_DEV_VALUE = 'set_dev_value' +SERVICE_SET_VARIABLE_VALUE = 'set_variable_value' +SERVICE_SET_DEVICE_VALUE = 'set_device_value' +SERVICE_SET_INSTALL_MODE = 'set_install_mode' HM_DEVICE_TYPES = { DISCOVER_SWITCHES: [ @@ -90,12 +94,14 @@ HM_ATTRIBUTE_SUPPORT = { 'RSSI_DEVICE': ['rssi', {}], 'VALVE_STATE': ['valve', {}], 'BATTERY_STATE': ['battery', {}], - 'CONTROL_MODE': ['mode', {0: 'Auto', - 1: 'Manual', - 2: 'Away', - 3: 'Boost', - 4: 'Comfort', - 5: 'Lowering'}], + 'CONTROL_MODE': ['mode', { + 0: 'Auto', + 1: 'Manual', + 2: 'Away', + 3: 'Boost', + 4: 'Comfort', + 5: 'Lowering' + }], 'POWER': ['power', {}], 'CURRENT': ['current', {}], 'VOLTAGE': ['voltage', {}], @@ -114,8 +120,6 @@ HM_IMPULSE_EVENTS = [ 'SEQUENCE_OK', ] -_LOGGER = logging.getLogger(__name__) - CONF_RESOLVENAMES_OPTIONS = [ 'metadata', 'json', @@ -124,12 +128,12 @@ CONF_RESOLVENAMES_OPTIONS = [ ] DATA_HOMEMATIC = 'homematic' -DATA_DEVINIT = 'homematic_devinit' DATA_STORE = 'homematic_store' +DATA_CONF = 'homematic_conf' +CONF_INTERFACES = 'interfaces' CONF_LOCAL_IP = 'local_ip' CONF_LOCAL_PORT = 'local_port' -CONF_IP = 'ip' CONF_PORT = 'port' CONF_PATH = 'path' CONF_CALLBACK_IP = 'callback_ip' @@ -146,37 +150,33 @@ DEFAULT_PORT = 2001 DEFAULT_PATH = '' DEFAULT_USERNAME = 'Admin' DEFAULT_PASSWORD = '' -DEFAULT_VARIABLES = False -DEFAULT_DEVICES = True -DEFAULT_PRIMARY = False DEVICE_SCHEMA = vol.Schema({ vol.Required(CONF_PLATFORM): 'homematic', vol.Required(ATTR_NAME): cv.string, vol.Required(ATTR_ADDRESS): cv.string, - vol.Required(ATTR_PROXY): cv.string, + vol.Required(ATTR_INTERFACE): cv.string, vol.Optional(ATTR_CHANNEL, default=1): vol.Coerce(int), vol.Optional(ATTR_PARAM): cv.string, }) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Required(CONF_HOSTS): {cv.match_all: { - vol.Required(CONF_IP): cv.string, + vol.Optional(CONF_INTERFACES, default={}): {cv.match_all: { + vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_PATH, default=DEFAULT_PATH): cv.string, - vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, - vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, - vol.Optional(CONF_VARIABLES, default=DEFAULT_VARIABLES): - cv.boolean, vol.Optional(CONF_RESOLVENAMES, default=DEFAULT_RESOLVENAMES): vol.In(CONF_RESOLVENAMES_OPTIONS), - vol.Optional(CONF_DEVICES, default=DEFAULT_DEVICES): cv.boolean, - vol.Optional(CONF_PRIMARY, default=DEFAULT_PRIMARY): cv.boolean, vol.Optional(CONF_CALLBACK_IP): cv.string, vol.Optional(CONF_CALLBACK_PORT): cv.port, }}, + vol.Optional(CONF_HOSTS, default={}): {cv.match_all: { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, + }}, vol.Optional(CONF_LOCAL_IP, default=DEFAULT_LOCAL_IP): cv.string, vol.Optional(CONF_LOCAL_PORT, default=DEFAULT_LOCAL_PORT): cv.port, }), @@ -186,61 +186,88 @@ SCHEMA_SERVICE_VIRTUALKEY = vol.Schema({ vol.Required(ATTR_ADDRESS): vol.All(cv.string, vol.Upper), vol.Required(ATTR_CHANNEL): vol.Coerce(int), vol.Required(ATTR_PARAM): cv.string, - vol.Optional(ATTR_PROXY): cv.string, + vol.Optional(ATTR_INTERFACE): cv.string, }) -SCHEMA_SERVICE_SET_VAR_VALUE = vol.Schema({ +SCHEMA_SERVICE_SET_VARIABLE_VALUE = vol.Schema({ vol.Required(ATTR_NAME): cv.string, vol.Required(ATTR_VALUE): cv.match_all, vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, }) -SCHEMA_SERVICE_SET_DEV_VALUE = vol.Schema({ +SCHEMA_SERVICE_SET_DEVICE_VALUE = vol.Schema({ vol.Required(ATTR_ADDRESS): vol.All(cv.string, vol.Upper), vol.Required(ATTR_CHANNEL): vol.Coerce(int), vol.Required(ATTR_PARAM): vol.All(cv.string, vol.Upper), vol.Required(ATTR_VALUE): cv.match_all, - vol.Optional(ATTR_PROXY): cv.string, + vol.Optional(ATTR_INTERFACE): cv.string, }) SCHEMA_SERVICE_RECONNECT = vol.Schema({}) +SCHEMA_SERVICE_SET_INSTALL_MODE = vol.Schema({ + vol.Required(ATTR_INTERFACE): cv.string, + vol.Optional(ATTR_TIME, default=60): cv.positive_int, + vol.Optional(ATTR_MODE, default=1): + vol.All(vol.Coerce(int), vol.In([1, 2])), + vol.Optional(ATTR_ADDRESS): vol.All(cv.string, vol.Upper), +}) -def virtualkey(hass, address, channel, param, proxy=None): + +@bind_hass +def virtualkey(hass, address, channel, param, interface=None): """Send virtual keypress to homematic controlller.""" data = { ATTR_ADDRESS: address, ATTR_CHANNEL: channel, ATTR_PARAM: param, - ATTR_PROXY: proxy, + ATTR_INTERFACE: interface, } hass.services.call(DOMAIN, SERVICE_VIRTUALKEY, data) -def set_var_value(hass, entity_id, value): +@bind_hass +def set_variable_value(hass, entity_id, value): """Change value of a Homematic system variable.""" data = { ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value, } - hass.services.call(DOMAIN, SERVICE_SET_VAR_VALUE, data) + hass.services.call(DOMAIN, SERVICE_SET_VARIABLE_VALUE, data) -def set_dev_value(hass, address, channel, param, value, proxy=None): - """Call setValue XML-RPC method of supplied proxy.""" +@bind_hass +def set_device_value(hass, address, channel, param, value, interface=None): + """Call setValue XML-RPC method of supplied interface.""" data = { ATTR_ADDRESS: address, ATTR_CHANNEL: channel, ATTR_PARAM: param, ATTR_VALUE: value, - ATTR_PROXY: proxy, + ATTR_INTERFACE: interface, } - hass.services.call(DOMAIN, SERVICE_SET_DEV_VALUE, data) + hass.services.call(DOMAIN, SERVICE_SET_DEVICE_VALUE, data) +@bind_hass +def set_install_mode(hass, interface, mode=None, time=None, address=None): + """Call setInstallMode XML-RPC method of supplied inteface.""" + data = { + key: value for key, value in ( + (ATTR_INTERFACE, interface), + (ATTR_MODE, mode), + (ATTR_TIME, time), + (ATTR_ADDRESS, address) + ) if value + } + + hass.services.call(DOMAIN, SERVICE_SET_INSTALL_MODE, data) + + +@bind_hass def reconnect(hass): """Reconnect to CCU/Homegear.""" hass.services.call(DOMAIN, SERVICE_RECONNECT, {}) @@ -250,31 +277,32 @@ def setup(hass, config): """Set up the Homematic component.""" from pyhomematic import HMConnection - hass.data[DATA_DEVINIT] = {} + conf = config[DOMAIN] + hass.data[DATA_CONF] = remotes = {} hass.data[DATA_STORE] = set() # Create hosts-dictionary for pyhomematic - remotes = {} - hosts = {} - for rname, rconfig in config[DOMAIN][CONF_HOSTS].items(): - server = rconfig.get(CONF_IP) + for rname, rconfig in conf[CONF_INTERFACES].items(): + remotes[rname] = { + 'ip': socket.gethostbyname(rconfig.get(CONF_HOST)), + 'port': rconfig.get(CONF_PORT), + 'path': rconfig.get(CONF_PATH), + 'resolvenames': rconfig.get(CONF_RESOLVENAMES), + 'username': rconfig.get(CONF_USERNAME), + 'password': rconfig.get(CONF_PASSWORD), + 'callbackip': rconfig.get(CONF_CALLBACK_IP), + 'callbackport': rconfig.get(CONF_CALLBACK_PORT), + 'connect': True, + } - remotes[rname] = {} - remotes[rname][CONF_IP] = server - remotes[rname][CONF_PORT] = rconfig.get(CONF_PORT) - remotes[rname][CONF_PATH] = rconfig.get(CONF_PATH) - remotes[rname][CONF_RESOLVENAMES] = rconfig.get(CONF_RESOLVENAMES) - remotes[rname][CONF_USERNAME] = rconfig.get(CONF_USERNAME) - remotes[rname][CONF_PASSWORD] = rconfig.get(CONF_PASSWORD) - remotes[rname]['callbackip'] = rconfig.get(CONF_CALLBACK_IP) - remotes[rname]['callbackport'] = rconfig.get(CONF_CALLBACK_PORT) - - if server not in hosts or rconfig.get(CONF_PRIMARY): - hosts[server] = { - CONF_VARIABLES: rconfig.get(CONF_VARIABLES), - CONF_NAME: rname, - } - hass.data[DATA_DEVINIT][rname] = rconfig.get(CONF_DEVICES) + for sname, sconfig in conf[CONF_HOSTS].items(): + remotes[sname] = { + 'ip': socket.gethostbyname(sconfig.get(CONF_HOST)), + 'port': DEFAULT_PORT, + 'username': sconfig.get(CONF_USERNAME), + 'password': sconfig.get(CONF_PASSWORD), + 'connect': False, + } # Create server thread bound_system_callback = partial(_system_callback_handler, hass, config) @@ -295,9 +323,8 @@ def setup(hass, config): # Init homematic hubs entity_hubs = [] - for _, hub_data in hosts.items(): - entity_hubs.append(HMHub( - hass, homematic, hub_data[CONF_NAME], hub_data[CONF_VARIABLES])) + for hub_name in conf[CONF_HOSTS].keys(): + entity_hubs.append(HMHub(hass, homematic, hub_name)) # Register HomeMatic services descriptions = load_yaml_config_file( @@ -331,8 +358,7 @@ def setup(hass, config): hass.services.register( DOMAIN, SERVICE_VIRTUALKEY, _hm_service_virtualkey, - descriptions[DOMAIN][SERVICE_VIRTUALKEY], - schema=SCHEMA_SERVICE_VIRTUALKEY) + descriptions[SERVICE_VIRTUALKEY], schema=SCHEMA_SERVICE_VIRTUALKEY) def _service_handle_value(service): """Service to call setValue method for HomeMatic system variable.""" @@ -354,9 +380,9 @@ def setup(hass, config): hub.hm_set_variable(name, value) hass.services.register( - DOMAIN, SERVICE_SET_VAR_VALUE, _service_handle_value, - descriptions[DOMAIN][SERVICE_SET_VAR_VALUE], - schema=SCHEMA_SERVICE_SET_VAR_VALUE) + DOMAIN, SERVICE_SET_VARIABLE_VALUE, _service_handle_value, + descriptions[SERVICE_SET_VARIABLE_VALUE], + schema=SCHEMA_SERVICE_SET_VARIABLE_VALUE) def _service_handle_reconnect(service): """Service to reconnect all HomeMatic hubs.""" @@ -364,8 +390,7 @@ def setup(hass, config): hass.services.register( DOMAIN, SERVICE_RECONNECT, _service_handle_reconnect, - descriptions[DOMAIN][SERVICE_RECONNECT], - schema=SCHEMA_SERVICE_RECONNECT) + descriptions[SERVICE_RECONNECT], schema=SCHEMA_SERVICE_RECONNECT) def _service_handle_device(service): """Service to call setValue method for HomeMatic devices.""" @@ -383,9 +408,23 @@ def setup(hass, config): hmdevice.setValue(param, value, channel) hass.services.register( - DOMAIN, SERVICE_SET_DEV_VALUE, _service_handle_device, - descriptions[DOMAIN][SERVICE_SET_DEV_VALUE], - schema=SCHEMA_SERVICE_SET_DEV_VALUE) + DOMAIN, SERVICE_SET_DEVICE_VALUE, _service_handle_device, + descriptions[SERVICE_SET_DEVICE_VALUE], + schema=SCHEMA_SERVICE_SET_DEVICE_VALUE) + + def _service_handle_install_mode(service): + """Service to set interface into install mode.""" + interface = service.data.get(ATTR_INTERFACE) + mode = service.data.get(ATTR_MODE) + time = service.data.get(ATTR_TIME) + address = service.data.get(ATTR_ADDRESS) + + homematic.setInstallMode(interface, t=time, mode=mode, address=address) + + hass.services.register( + DOMAIN, SERVICE_SET_INSTALL_MODE, _service_handle_install_mode, + descriptions[SERVICE_SET_INSTALL_MODE], + schema=SCHEMA_SERVICE_SET_INSTALL_MODE) return True @@ -395,10 +434,10 @@ def _system_callback_handler(hass, config, src, *args): # New devices available at hub if src == 'newDevices': (interface_id, dev_descriptions) = args - proxy = interface_id.split('-')[-1] + interface = interface_id.split('-')[-1] # Device support active? - if not hass.data[DATA_DEVINIT][proxy]: + if not hass.data[DATA_CONF][interface]['connect']: return addresses = [] @@ -410,9 +449,9 @@ def _system_callback_handler(hass, config, src, *args): # Register EVENTS # Search all devices with an EVENTNODE that includes data - bound_event_callback = partial(_hm_event_handler, hass, proxy) + bound_event_callback = partial(_hm_event_handler, hass, interface) for dev in addresses: - hmdevice = hass.data[DATA_HOMEMATIC].devices[proxy].get(dev) + hmdevice = hass.data[DATA_HOMEMATIC].devices[interface].get(dev) if hmdevice.EVENTNODE: hmdevice.setEventCallback( @@ -429,7 +468,7 @@ def _system_callback_handler(hass, config, src, *args): ('climate', DISCOVER_CLIMATE)): # Get all devices of a specific type found_devices = _get_devices( - hass, discovery_type, addresses, proxy) + hass, discovery_type, addresses, interface) # When devices of this type are found # they are setup in HASS and an discovery event is fired @@ -448,12 +487,12 @@ def _system_callback_handler(hass, config, src, *args): }) -def _get_devices(hass, discovery_type, keys, proxy): +def _get_devices(hass, discovery_type, keys, interface): """Get the HomeMatic devices for given discovery_type.""" device_arr = [] for key in keys: - device = hass.data[DATA_HOMEMATIC].devices[proxy][key] + device = hass.data[DATA_HOMEMATIC].devices[interface][key] class_name = device.__class__.__name__ metadata = {} @@ -485,7 +524,7 @@ def _get_devices(hass, discovery_type, keys, proxy): device_dict = { CONF_PLATFORM: "homematic", ATTR_ADDRESS: key, - ATTR_PROXY: proxy, + ATTR_INTERFACE: interface, ATTR_NAME: name, ATTR_CHANNEL: channel } @@ -521,12 +560,12 @@ def _create_ha_name(name, channel, param, count): return "{} {} {}".format(name, channel, param) -def _hm_event_handler(hass, proxy, device, caller, attribute, value): +def _hm_event_handler(hass, interface, device, caller, attribute, value): """Handle all pyhomematic device events.""" try: channel = int(device.split(":")[1]) address = device.split(":")[0] - hmdevice = hass.data[DATA_HOMEMATIC].devices[proxy].get(address) + hmdevice = hass.data[DATA_HOMEMATIC].devices[interface].get(address) except (TypeError, ValueError): _LOGGER.error("Event handling channel convert error!") return @@ -561,14 +600,14 @@ def _hm_event_handler(hass, proxy, device, caller, attribute, value): def _device_from_servicecall(hass, service): """Extract HomeMatic device from service call.""" address = service.data.get(ATTR_ADDRESS) - proxy = service.data.get(ATTR_PROXY) + interface = service.data.get(ATTR_INTERFACE) if address == 'BIDCOS-RF': address = 'BidCoS-RF' - if proxy: - return hass.data[DATA_HOMEMATIC].devices[proxy].get(address) + if interface: + return hass.data[DATA_HOMEMATIC].devices[interface].get(address) - for _, devices in hass.data[DATA_HOMEMATIC].devices.items(): + for devices in hass.data[DATA_HOMEMATIC].devices.values(): if address in devices: return devices[address] @@ -576,25 +615,23 @@ def _device_from_servicecall(hass, service): class HMHub(Entity): """The HomeMatic hub. (CCU2/HomeGear).""" - def __init__(self, hass, homematic, name, use_variables): + def __init__(self, hass, homematic, name): """Initialize HomeMatic hub.""" self.hass = hass self.entity_id = "{}.{}".format(DOMAIN, name.lower()) self._homematic = homematic self._variables = {} self._name = name - self._state = STATE_UNKNOWN - self._use_variables = use_variables + self._state = None # Load data - track_time_interval( - self.hass, self._update_hub, SCAN_INTERVAL_HUB) + self.hass.helpers.event.track_time_interval( + self._update_hub, SCAN_INTERVAL_HUB) self.hass.add_job(self._update_hub, None) - if self._use_variables: - track_time_interval( - self.hass, self._update_variables, SCAN_INTERVAL_VARIABLES) - self.hass.add_job(self._update_variables, None) + self.hass.helpers.event.track_time_interval( + self._update_variables, SCAN_INTERVAL_VARIABLES) + self.hass.add_job(self._update_variables, None) @property def name(self): @@ -672,7 +709,7 @@ class HMDevice(Entity): """Initialize a generic HomeMatic device.""" self._name = config.get(ATTR_NAME) self._address = config.get(ATTR_ADDRESS) - self._proxy = config.get(ATTR_PROXY) + self._interface = config.get(ATTR_INTERFACE) self._channel = config.get(ATTR_CHANNEL) self._state = config.get(ATTR_PARAM) self._data = {} @@ -700,11 +737,6 @@ class HMDevice(Entity): """Return the name of the device.""" return self._name - @property - def assumed_state(self): - """Return true if unable to access real state of the device.""" - return not self._available - @property def available(self): """Return true if device is available.""" @@ -728,7 +760,7 @@ class HMDevice(Entity): # Static attributes attr['id'] = self._hmdevice.ADDRESS - attr['proxy'] = self._proxy + attr['interface'] = self._interface return attr @@ -739,7 +771,8 @@ class HMDevice(Entity): # Initialize self._homematic = self.hass.data[DATA_HOMEMATIC] - self._hmdevice = self._homematic.devices[self._proxy][self._address] + self._hmdevice = \ + self._homematic.devices[self._interface][self._address] self._connected = True try: diff --git a/homeassistant/components/homematic/services.yaml b/homeassistant/components/homematic/services.yaml new file mode 100644 index 00000000000..bf4d99af9e7 --- /dev/null +++ b/homeassistant/components/homematic/services.yaml @@ -0,0 +1,68 @@ +# Describes the format for available component services + +virtualkey: + description: Press a virtual key from CCU/Homegear or simulate keypress. + fields: + address: + description: Address of homematic device or BidCoS-RF for virtual remote. + example: BidCoS-RF + channel: + description: Channel for calling a keypress. + example: 1 + param: + description: Event to send i.e. PRESS_LONG, PRESS_SHORT. + example: PRESS_LONG + interface: + description: (Optional) for set a interface value. + example: Interfaces name from config + +set_variable_value: + description: Set the name of a node. + fields: + entity_id: + description: Name(s) of homematic central to set value. + example: 'homematic.ccu2' + name: + description: Name of the variable to set. + example: 'testvariable' + value: + description: New value + example: 1 + +set_device_value: + description: Set a device property on RPC XML interface. + fields: + address: + description: Address of homematic device or BidCoS-RF for virtual remote + example: BidCoS-RF + channel: + description: Channel for calling a keypress + example: 1 + param: + description: Event to send i.e. PRESS_LONG, PRESS_SHORT + example: PRESS_LONG + interface: + description: (Optional) for set a interface value + example: Interfaces name from config + value: + description: New value + example: 1 + +reconnect: + description: Reconnect to all Homematic Hubs. + +set_install_mode: + description: Set a RPC XML interface into installation mode. + fields: + interface: + description: Select the given interface into install mode + example: Interfaces name from config + mode: + description: (Default 1) 1= Normal mode / 2= Remove exists old links + example: 1 + time: + description: (Default 60) Time in seconds to run in install mode + example: 1 + address: + description: (Optional) Address of homematic device or BidCoS-RF to learn + example: LEQ3948571 diff --git a/homeassistant/components/hue.py b/homeassistant/components/hue.py new file mode 100644 index 00000000000..3dad4429b53 --- /dev/null +++ b/homeassistant/components/hue.py @@ -0,0 +1,244 @@ +""" +This component provides basic support for the Philips Hue system. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/hue/ +""" +import json +import logging +import os +import socket + +import voluptuous as vol + +from homeassistant.components.discovery import SERVICE_HUE +from homeassistant.config import load_yaml_config_file +from homeassistant.const import CONF_FILENAME, CONF_HOST +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import discovery + +REQUIREMENTS = ['phue==1.0'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "hue" +SERVICE_HUE_SCENE = "hue_activate_scene" + +CONF_BRIDGES = "bridges" + +CONF_ALLOW_UNREACHABLE = 'allow_unreachable' +DEFAULT_ALLOW_UNREACHABLE = False + +PHUE_CONFIG_FILE = 'phue.conf' + +CONF_ALLOW_IN_EMULATED_HUE = "allow_in_emulated_hue" +DEFAULT_ALLOW_IN_EMULATED_HUE = True + +CONF_ALLOW_HUE_GROUPS = "allow_hue_groups" +DEFAULT_ALLOW_HUE_GROUPS = True + +BRIDGE_CONFIG_SCHEMA = vol.Schema([{ + vol.Optional(CONF_HOST): cv.string, + vol.Optional(CONF_FILENAME, default=PHUE_CONFIG_FILE): cv.string, + vol.Optional(CONF_ALLOW_UNREACHABLE, + default=DEFAULT_ALLOW_UNREACHABLE): cv.boolean, + vol.Optional(CONF_ALLOW_IN_EMULATED_HUE, + default=DEFAULT_ALLOW_IN_EMULATED_HUE): cv.boolean, + vol.Optional(CONF_ALLOW_HUE_GROUPS, + default=DEFAULT_ALLOW_HUE_GROUPS): cv.boolean, +}]) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_BRIDGES, default=[]): BRIDGE_CONFIG_SCHEMA, + }), +}, extra=vol.ALLOW_EXTRA) + +ATTR_GROUP_NAME = "group_name" +ATTR_SCENE_NAME = "scene_name" +SCENE_SCHEMA = vol.Schema({ + vol.Required(ATTR_GROUP_NAME): cv.string, + vol.Required(ATTR_SCENE_NAME): cv.string, +}) + +CONFIG_INSTRUCTIONS = """ +Press the button on the bridge to register Philips Hue with Home Assistant. + +![Location of button on bridge](/static/images/config_philips_hue.jpg) +""" + + +def setup(hass, config): + """Set up the Hue platform.""" + config = config.get(DOMAIN) + if config is None: + config = {} + + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {} + + discovery.listen( + hass, + SERVICE_HUE, + lambda service, discovery_info: + bridge_discovered(hass, service, discovery_info)) + + bridges = config.get(CONF_BRIDGES, []) + for bridge in bridges: + filename = bridge.get(CONF_FILENAME) + allow_unreachable = bridge.get(CONF_ALLOW_UNREACHABLE) + allow_in_emulated_hue = bridge.get(CONF_ALLOW_IN_EMULATED_HUE) + allow_hue_groups = bridge.get(CONF_ALLOW_HUE_GROUPS) + + host = bridge.get(CONF_HOST) + + if host is None: + host = _find_host_from_config(hass, filename) + + if host is None: + _LOGGER.error("No host found in configuration") + return False + + setup_bridge(host, hass, filename, allow_unreachable, + allow_in_emulated_hue, allow_hue_groups) + + return True + + +def bridge_discovered(hass, service, discovery_info): + """Dispatcher for Hue discovery events.""" + if "HASS Bridge" in discovery_info.get('name', ''): + return + + host = discovery_info.get('host') + serial = discovery_info.get('serial') + + filename = 'phue-{}.conf'.format(serial) + setup_bridge(host, hass, filename) + + +def setup_bridge(host, hass, filename=None, allow_unreachable=False, + allow_in_emulated_hue=True, allow_hue_groups=True): + """Set up a given Hue bridge.""" + # Only register a device once + if socket.gethostbyname(host) in hass.data[DOMAIN]: + return + + bridge = HueBridge(host, hass, filename, allow_unreachable, + allow_in_emulated_hue, allow_hue_groups) + bridge.setup() + + +def _find_host_from_config(hass, filename=PHUE_CONFIG_FILE): + """Attempt to detect host based on existing configuration.""" + path = hass.config.path(filename) + + if not os.path.isfile(path): + return None + + try: + with open(path) as inp: + return next(iter(json.load(inp).keys())) + except (ValueError, AttributeError, StopIteration): + # ValueError if can't parse as JSON + # AttributeError if JSON value is not a dict + # StopIteration if no keys + return None + + +class HueBridge(object): + """Manages a single Hue bridge.""" + + def __init__(self, host, hass, filename, allow_unreachable=False, + allow_in_emulated_hue=True, allow_hue_groups=True): + """Initialize the system.""" + self.host = host + self.hass = hass + self.filename = filename + self.allow_unreachable = allow_unreachable + self.allow_in_emulated_hue = allow_in_emulated_hue + self.allow_hue_groups = allow_hue_groups + + self.bridge = None + + self.configured = False + self.config_request_id = None + + hass.data[DOMAIN][socket.gethostbyname(host)] = self + + def setup(self): + """Set up a phue bridge based on host parameter.""" + import phue + + try: + self.bridge = phue.Bridge( + self.host, + config_file_path=self.hass.config.path(self.filename)) + except ConnectionRefusedError: # Wrong host was given + _LOGGER.error("Error connecting to the Hue bridge at %s", + self.host) + return + except phue.PhueRegistrationException: + _LOGGER.warning("Connected to Hue at %s but not registered.", + self.host) + self.request_configuration() + return + + # If we came here and configuring this host, mark as done + if self.config_request_id: + request_id = self.config_request_id + self.config_request_id = None + configurator = self.hass.components.configurator + configurator.request_done(request_id) + + self.configured = True + + discovery.load_platform( + self.hass, 'light', DOMAIN, + {'bridge_id': socket.gethostbyname(self.host)}) + + # create a service for calling run_scene directly on the bridge, + # used to simplify automation rules. + def hue_activate_scene(call): + """Service to call directly into bridge to set scenes.""" + group_name = call.data[ATTR_GROUP_NAME] + scene_name = call.data[ATTR_SCENE_NAME] + self.bridge.run_scene(group_name, scene_name) + + descriptions = load_yaml_config_file( + os.path.join(os.path.dirname(__file__), 'services.yaml')) + self.hass.services.register( + DOMAIN, SERVICE_HUE_SCENE, hue_activate_scene, + descriptions.get(SERVICE_HUE_SCENE), + schema=SCENE_SCHEMA) + + def request_configuration(self): + """Request configuration steps from the user.""" + configurator = self.hass.components.configurator + + # We got an error if this method is called while we are configuring + if self.config_request_id: + configurator.notify_errors( + self.config_request_id, + "Failed to register, please try again.") + return + + self.config_request_id = configurator.request_config( + "Philips Hue", + lambda data: self.setup(), + description=CONFIG_INSTRUCTIONS, + entity_picture="/static/images/logo_philips_hue.png", + submit_caption="I have pressed the button" + ) + + def get_api(self): + """Return the full api dictionary from phue.""" + return self.bridge.get_api() + + def set_light(self, light_id, command): + """Adjust properties of one or more lights. See phue for details.""" + return self.bridge.set_light(light_id, command) + + def set_group(self, light_id, command): + """Change light settings for a group. See phue for detail.""" + return self.bridge.set_group(light_id, command) diff --git a/homeassistant/components/isy994.py b/homeassistant/components/isy994.py index 7686eb7dc7d..af1846c7bf8 100644 --- a/homeassistant/components/isy994.py +++ b/homeassistant/components/isy994.py @@ -4,6 +4,7 @@ Support the ISY-994 controllers. For configuration details please visit the documentation for this component at https://home-assistant.io/components/isy994/ """ +import asyncio from collections import namedtuple import logging from urllib.parse import urlparse @@ -17,7 +18,7 @@ from homeassistant.helpers import discovery, config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType, Dict # noqa -REQUIREMENTS = ['PyISY==1.0.8'] +REQUIREMENTS = ['PyISY==1.1.0'] _LOGGER = logging.getLogger(__name__) @@ -91,6 +92,34 @@ def filter_nodes(nodes: list, units: list=None, states: list=None) -> list: return filtered_nodes +def _is_node_a_sensor(node, path: str, sensor_identifier: str) -> bool: + """Determine if the given node is a sensor.""" + if not isinstance(node, PYISY.Nodes.Node): + return False + + if sensor_identifier in path or sensor_identifier in node.name: + return True + + # This method is most reliable but only works on 5.x firmware + try: + if node.node_def_id == 'BinaryAlarm': + return True + except AttributeError: + pass + + # This method works on all firmwares, but only for Insteon devices + try: + device_type = node.type + except AttributeError: + # Node has no type; most likely not an Insteon device + pass + else: + split_type = device_type.split('.') + return split_type[0] == '16' # 16 represents Insteon binary sensors + + return False + + def _categorize_nodes(hidden_identifier: str, sensor_identifier: str) -> None: """Categorize the ISY994 nodes.""" global SENSOR_NODES @@ -106,7 +135,7 @@ def _categorize_nodes(hidden_identifier: str, sensor_identifier: str) -> None: hidden = hidden_identifier in path or hidden_identifier in node.name if hidden: node.name += hidden_identifier - if sensor_identifier in path or sensor_identifier in node.name: + if _is_node_a_sensor(node, path, sensor_identifier): SENSOR_NODES.append(node) elif isinstance(node, PYISY.Nodes.Node): NODES.append(node) @@ -227,15 +256,31 @@ class ISYDevice(Entity): def __init__(self, node) -> None: """Initialize the insteon device.""" self._node = node + self._change_handler = None + self._control_handler = None + @asyncio.coroutine + def async_added_to_hass(self) -> None: + """Subscribe to the node change events.""" self._change_handler = self._node.status.subscribe( 'changed', self.on_update) + if hasattr(self._node, 'controlEvents'): + self._control_handler = self._node.controlEvents.subscribe( + self.on_control) + # pylint: disable=unused-argument def on_update(self, event: object) -> None: """Handle the update event from the ISY994 Node.""" self.schedule_update_ha_state() + def on_control(self, event: object) -> None: + """Handle a control event from the ISY994 Node.""" + self.hass.bus.fire('isy994_control', { + 'entity_id': self.entity_id, + 'control': event + }) + @property def domain(self) -> str: """Get the domain of the device.""" @@ -270,6 +315,21 @@ class ISYDevice(Entity): # pylint: disable=protected-access return self._node.status._val + def is_unknown(self) -> bool: + """Get whether or not the value of this Entity's node is unknown. + + PyISY reports unknown values as -inf + """ + return self.value == -1 * float('inf') + + @property + def state(self): + """Return the state of the ISY device.""" + if self.is_unknown(): + return None + else: + return super().state + @property def device_state_attributes(self) -> Dict: """Get the state attributes for the device.""" diff --git a/homeassistant/components/keyboard_remote.py b/homeassistant/components/keyboard_remote.py index 5a81f6d2a9e..d737c555873 100644 --- a/homeassistant/components/keyboard_remote.py +++ b/homeassistant/components/keyboard_remote.py @@ -21,7 +21,7 @@ REQUIREMENTS = ['evdev==0.6.1'] _LOGGER = logging.getLogger(__name__) DEVICE_DESCRIPTOR = 'device_descriptor' -DEVICE_ID_GROUP = 'Device descriptor or name' +DEVICE_ID_GROUP = 'Device description' DEVICE_NAME = 'device_name' DOMAIN = 'keyboard_remote' @@ -36,12 +36,13 @@ KEYBOARD_REMOTE_DISCONNECTED = 'keyboard_remote_disconnected' TYPE = 'type' CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Exclusive(DEVICE_DESCRIPTOR, DEVICE_ID_GROUP): cv.string, - vol.Exclusive(DEVICE_NAME, DEVICE_ID_GROUP): cv.string, - vol.Optional(TYPE, default='key_up'): - vol.All(cv.string, vol.Any('key_up', 'key_down', 'key_hold')), - }), + DOMAIN: + vol.All(cv.ensure_list, [vol.Schema({ + vol.Exclusive(DEVICE_DESCRIPTOR, DEVICE_ID_GROUP): cv.string, + vol.Exclusive(DEVICE_NAME, DEVICE_ID_GROUP): cv.string, + vol.Optional(TYPE, default='key_up'): + vol.All(cv.string, vol.Any('key_up', 'key_down', 'key_hold')) + })]) }, extra=vol.ALLOW_EXTRA) @@ -49,11 +50,6 @@ def setup(hass, config): """Set up the keyboard_remote.""" config = config.get(DOMAIN) - if not config.get(DEVICE_DESCRIPTOR) and\ - not config.get(DEVICE_NAME): - _LOGGER.error("No device_descriptor or device_name found") - return - keyboard_remote = KeyboardRemote( hass, config @@ -63,7 +59,7 @@ def setup(hass, config): keyboard_remote.run() def _stop_keyboard_remote(_event): - keyboard_remote.stopped.set() + keyboard_remote.stop() hass.bus.listen_once( EVENT_HOMEASSISTANT_START, @@ -77,19 +73,21 @@ def setup(hass, config): return True -class KeyboardRemote(threading.Thread): +class KeyboardRemoteThread(threading.Thread): """This interfaces with the inputdevice using evdev.""" - def __init__(self, hass, config): - """Construct a KeyboardRemote interface object.""" - from evdev import InputDevice, list_devices + def __init__(self, hass, device_name, device_descriptor, key_value): + """Construct a thread listening for events on one device.""" + self.hass = hass + self.device_name = device_name + self.device_descriptor = device_descriptor + self.key_value = key_value - self.device_descriptor = config.get(DEVICE_DESCRIPTOR) - self.device_name = config.get(DEVICE_NAME) if self.device_descriptor: self.device_id = self.device_descriptor else: self.device_id = self.device_name + self.dev = self._get_keyboard_device() if self.dev is not None: _LOGGER.debug("Keyboard connected, %s", self.device_id) @@ -103,6 +101,7 @@ class KeyboardRemote(threading.Thread): id_folder = '/dev/input/by-id/' if os.path.isdir(id_folder): + from evdev import InputDevice, list_devices device_names = [InputDevice(file_name).name for file_name in list_devices()] _LOGGER.debug( @@ -116,7 +115,6 @@ class KeyboardRemote(threading.Thread): threading.Thread.__init__(self) self.stopped = threading.Event() self.hass = hass - self.key_value = KEY_VALUE.get(config.get(TYPE, 'key_up')) def _get_keyboard_device(self): """Get the keyboard device.""" @@ -145,7 +143,7 @@ class KeyboardRemote(threading.Thread): while not self.stopped.isSet(): # Sleeps to ease load on processor - time.sleep(.1) + time.sleep(.05) if self.dev is None: self.dev = self._get_keyboard_device() @@ -178,3 +176,32 @@ class KeyboardRemote(threading.Thread): KEYBOARD_REMOTE_COMMAND_RECEIVED, {KEY_CODE: event.code} ) + + +class KeyboardRemote(object): + """Sets up one thread per device.""" + + def __init__(self, hass, config): + """Construct a KeyboardRemote interface object.""" + self.threads = [] + for dev_block in config: + device_descriptor = dev_block.get(DEVICE_DESCRIPTOR) + device_name = dev_block.get(DEVICE_NAME) + key_value = KEY_VALUE.get(dev_block.get(TYPE, 'key_up')) + + if device_descriptor is not None\ + or device_name is not None: + thread = KeyboardRemoteThread(hass, device_name, + device_descriptor, + key_value) + self.threads.append(thread) + + def run(self): + """Run all event listener threads.""" + for thread in self.threads: + thread.start() + + def stop(self): + """Stop all event listener threads.""" + for thread in self.threads: + thread.stopped.set() diff --git a/homeassistant/components/light/ads.py b/homeassistant/components/light/ads.py new file mode 100644 index 00000000000..41709a4692b --- /dev/null +++ b/homeassistant/components/light/ads.py @@ -0,0 +1,117 @@ +""" +Support for ADS light sources. + +For more details about this platform, please refer to the documentation. +https://home-assistant.io/components/light.ads/ + +""" +import asyncio +import logging +import voluptuous as vol +from homeassistant.components.light import Light, ATTR_BRIGHTNESS, \ + SUPPORT_BRIGHTNESS, PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME +from homeassistant.components.ads import DATA_ADS, CONF_ADS_VAR, \ + CONF_ADS_VAR_BRIGHTNESS +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) +DEPENDENCIES = ['ads'] +DEFAULT_NAME = 'ADS Light' +CONF_ADSVAR_BRIGHTNESS = 'adsvar_brightness' +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ADS_VAR): cv.string, + vol.Optional(CONF_ADS_VAR_BRIGHTNESS): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the light platform for ADS.""" + ads_hub = hass.data.get(DATA_ADS) + + ads_var_enable = config.get(CONF_ADS_VAR) + ads_var_brightness = config.get(CONF_ADS_VAR_BRIGHTNESS) + name = config.get(CONF_NAME) + + add_devices([AdsLight(ads_hub, ads_var_enable, ads_var_brightness, + name)], True) + + +class AdsLight(Light): + """Representation of ADS light.""" + + def __init__(self, ads_hub, ads_var_enable, ads_var_brightness, name): + """Initialize AdsLight entity.""" + self._ads_hub = ads_hub + self._on_state = False + self._brightness = None + self._name = name + self.ads_var_enable = ads_var_enable + self.ads_var_brightness = ads_var_brightness + + @asyncio.coroutine + def async_added_to_hass(self): + """Register device notification.""" + def update_on_state(name, value): + """Handle device notifications for state.""" + _LOGGER.debug('Variable %s changed its value to %d', name, value) + self._on_state = value + self.schedule_update_ha_state() + + def update_brightness(name, value): + """Handle device notification for brightness.""" + _LOGGER.debug('Variable %s changed its value to %d', name, value) + self._brightness = value + self.schedule_update_ha_state() + + self.hass.async_add_job( + self._ads_hub.add_device_notification, + self.ads_var_enable, self._ads_hub.PLCTYPE_BOOL, update_on_state + ) + self.hass.async_add_job( + self._ads_hub.add_device_notification, + self.ads_var_brightness, self._ads_hub.PLCTYPE_INT, + update_brightness + ) + + @property + def name(self): + """Return the name of the device if any.""" + return self._name + + @property + def brightness(self): + """Return the brightness of the light (0..255).""" + return self._brightness + + @property + def is_on(self): + """Return if light is on.""" + return self._on_state + + @property + def should_poll(self): + """Return False because entity pushes its state to HA.""" + return False + + @property + def supported_features(self): + """Flag supported features.""" + if self.ads_var_brightness is not None: + return SUPPORT_BRIGHTNESS + + def turn_on(self, **kwargs): + """Turn the light on or set a specific dimmer value.""" + brightness = kwargs.get(ATTR_BRIGHTNESS) + self._ads_hub.write_by_name(self.ads_var_enable, True, + self._ads_hub.PLCTYPE_BOOL) + + if self.ads_var_brightness is not None and brightness is not None: + self._ads_hub.write_by_name(self.ads_var_brightness, brightness, + self._ads_hub.PLCTYPE_UINT) + + def turn_off(self, **kwargs): + """Turn the light off.""" + self._ads_hub.write_by_name(self.ads_var_enable, False, + self._ads_hub.PLCTYPE_BOOL) diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index fe7dd765d01..a454143bcd2 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -1,19 +1,21 @@ """ -Support for Hue lights. +This component provides light support for the Philips Hue system. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.hue/ """ -import json -import logging -import os -import random -import socket from datetime import timedelta +import logging +import random +import re +import socket import voluptuous as vol +import homeassistant.components.hue as hue + import homeassistant.util as util +from homeassistant.util import yaml import homeassistant.util.color as color_util from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, ATTR_RGB_COLOR, @@ -21,30 +23,21 @@ from homeassistant.components.light import ( FLASH_LONG, FLASH_SHORT, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_FLASH, SUPPORT_RGB_COLOR, SUPPORT_TRANSITION, SUPPORT_XY_COLOR, Light, PLATFORM_SCHEMA) -from homeassistant.config import load_yaml_config_file -from homeassistant.const import (CONF_FILENAME, CONF_HOST, DEVICE_DEFAULT_NAME) +from homeassistant.const import CONF_FILENAME, CONF_HOST, DEVICE_DEFAULT_NAME from homeassistant.components.emulated_hue import ATTR_EMULATED_HUE_HIDDEN import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['phue==1.0'] +DEPENDENCIES = ['hue'] -# Track previously setup bridges -_CONFIGURED_BRIDGES = {} -# Map ip to request id for configuring -_CONFIGURING = {} _LOGGER = logging.getLogger(__name__) -CONF_ALLOW_UNREACHABLE = 'allow_unreachable' - -DEFAULT_ALLOW_UNREACHABLE = False -DOMAIN = "light" -SERVICE_HUE_SCENE = "hue_activate_scene" +DATA_KEY = 'hue_lights' +DATA_LIGHTS = 'lights' +DATA_LIGHTGROUPS = 'lightgroups' MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) -PHUE_CONFIG_FILE = 'phue.conf' - SUPPORT_HUE_ON_OFF = (SUPPORT_FLASH | SUPPORT_TRANSITION) SUPPORT_HUE_DIMMABLE = (SUPPORT_HUE_ON_OFF | SUPPORT_BRIGHTNESS) SUPPORT_HUE_COLOR_TEMP = (SUPPORT_HUE_DIMMABLE | SUPPORT_COLOR_TEMP) @@ -60,10 +53,14 @@ SUPPORT_HUE = { 'Color temperature light': SUPPORT_HUE_COLOR_TEMP } -CONF_ALLOW_IN_EMULATED_HUE = "allow_in_emulated_hue" -DEFAULT_ALLOW_IN_EMULATED_HUE = True +ATTR_IS_HUE_GROUP = 'is_hue_group' -CONF_ALLOW_HUE_GROUPS = "allow_hue_groups" +# Legacy configuration, will be removed in 0.60 +CONF_ALLOW_UNREACHABLE = 'allow_unreachable' +DEFAULT_ALLOW_UNREACHABLE = False +CONF_ALLOW_IN_EMULATED_HUE = 'allow_in_emulated_hue' +DEFAULT_ALLOW_IN_EMULATED_HUE = True +CONF_ALLOW_HUE_GROUPS = 'allow_hue_groups' DEFAULT_ALLOW_HUE_GROUPS = True PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -75,236 +72,168 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ default=DEFAULT_ALLOW_HUE_GROUPS): cv.boolean, }) -ATTR_GROUP_NAME = "group_name" -ATTR_SCENE_NAME = "scene_name" -SCENE_SCHEMA = vol.Schema({ - vol.Required(ATTR_GROUP_NAME): cv.string, - vol.Required(ATTR_SCENE_NAME): cv.string, -}) +MIGRATION_ID = 'light_hue_config_migration' +MIGRATION_TITLE = 'Philips Hue Configuration Migration' +MIGRATION_INSTRUCTIONS = """ +Configuration for the Philips Hue component has changed; action required. -ATTR_IS_HUE_GROUP = "is_hue_group" +You have configured at least one bridge: -CONFIG_INSTRUCTIONS = """ -Press the button on the bridge to register Philips Hue with Home Assistant. + hue: +{config} -![Location of button on bridge](/static/images/config_philips_hue.jpg) +This configuration is deprecated, please check the +[Hue component](https://home-assistant.io/components/hue/) page for more +information. """ -def _find_host_from_config(hass, filename=PHUE_CONFIG_FILE): - """Attempt to detect host based on existing configuration.""" - path = hass.config.path(filename) - - if not os.path.isfile(path): - return None - - try: - with open(path) as inp: - return next(json.loads(''.join(inp)).keys().__iter__()) - except (ValueError, AttributeError, StopIteration): - # ValueError if can't parse as JSON - # AttributeError if JSON value is not a dict - # StopIteration if no keys - return None - - def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Hue lights.""" - # Default needed in case of discovery - filename = config.get(CONF_FILENAME, PHUE_CONFIG_FILE) - allow_unreachable = config.get(CONF_ALLOW_UNREACHABLE, - DEFAULT_ALLOW_UNREACHABLE) - allow_in_emulated_hue = config.get(CONF_ALLOW_IN_EMULATED_HUE, - DEFAULT_ALLOW_IN_EMULATED_HUE) - allow_hue_groups = config.get(CONF_ALLOW_HUE_GROUPS) - - if discovery_info is not None: - if "HASS Bridge" in discovery_info.get('name', ''): - _LOGGER.info("Emulated hue found, will not add") - return False - - host = discovery_info.get('host') - else: - host = config.get(CONF_HOST, None) - - if host is None: - host = _find_host_from_config(hass, filename) - - if host is None: - _LOGGER.error("No host found in configuration") - return False - - # Only act if we are not already configuring this host - if host in _CONFIGURING or \ - socket.gethostbyname(host) in _CONFIGURED_BRIDGES: + if discovery_info is None or 'bridge_id' not in discovery_info: return - setup_bridge(host, hass, add_devices, filename, allow_unreachable, - allow_in_emulated_hue, allow_hue_groups) + setup_data(hass) + + if config is not None and len(config) > 0: + # Legacy configuration, will be removed in 0.60 + config_str = yaml.dump([config]) + # Indent so it renders in a fixed-width font + config_str = re.sub('(?m)^', ' ', config_str) + hass.components.persistent_notification.async_create( + MIGRATION_INSTRUCTIONS.format(config=config_str), + title=MIGRATION_TITLE, + notification_id=MIGRATION_ID) + + bridge_id = discovery_info['bridge_id'] + bridge = hass.data[hue.DOMAIN][bridge_id] + unthrottled_update_lights(hass, bridge, add_devices) -def setup_bridge(host, hass, add_devices, filename, allow_unreachable, - allow_in_emulated_hue, allow_hue_groups): - """Set up a phue bridge based on host parameter.""" +def setup_data(hass): + """Initialize internal data. Useful from tests.""" + if DATA_KEY not in hass.data: + hass.data[DATA_KEY] = {DATA_LIGHTS: {}, DATA_LIGHTGROUPS: {}} + + +@util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) +def update_lights(hass, bridge, add_devices): + """Update the Hue light objects with latest info from the bridge.""" + return unthrottled_update_lights(hass, bridge, add_devices) + + +def unthrottled_update_lights(hass, bridge, add_devices): + """Internal version of update_lights.""" import phue + if not bridge.configured: + return + try: - bridge = phue.Bridge( - host, - config_file_path=hass.config.path(filename)) - except ConnectionRefusedError: # Wrong host was given - _LOGGER.error("Error connecting to the Hue bridge at %s", host) - + api = bridge.get_api() + except phue.PhueRequestTimeout: + _LOGGER.warning('Timeout trying to reach the bridge') + return + except ConnectionRefusedError: + _LOGGER.error('The bridge refused the connection') + return + except socket.error: + # socket.error when we cannot reach Hue + _LOGGER.exception('Cannot reach the bridge') return - except phue.PhueRegistrationException: - _LOGGER.warning("Connected to Hue at %s but not registered.", host) + bridge_type = get_bridge_type(api) - request_configuration(host, hass, add_devices, filename, - allow_unreachable, allow_in_emulated_hue, - allow_hue_groups) + new_lights = process_lights( + hass, api, bridge, bridge_type, + lambda **kw: update_lights(hass, bridge, add_devices, **kw)) + if bridge.allow_hue_groups: + new_lightgroups = process_groups( + hass, api, bridge, bridge_type, + lambda **kw: update_lights(hass, bridge, add_devices, **kw)) + new_lights.extend(new_lightgroups) - return + if new_lights: + add_devices(new_lights) - # If we came here and configuring this host, mark as done - if host in _CONFIGURING: - request_id = _CONFIGURING.pop(host) - configurator = hass.components.configurator - configurator.request_done(request_id) - lights = {} - lightgroups = {} - skip_groups = not allow_hue_groups +def get_bridge_type(api): + """Return the bridge type.""" + api_name = api.get('config').get('name') + if api_name in ('RaspBee-GW', 'deCONZ-GW'): + return 'deconz' + else: + return 'hue' - @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) - def update_lights(): - """Update the Hue light objects with latest info from the bridge.""" - nonlocal skip_groups - try: - api = bridge.get_api() - except phue.PhueRequestTimeout: - _LOGGER.warning("Timeout trying to reach the bridge") - return - except ConnectionRefusedError: - _LOGGER.error("The bridge refused the connection") - return - except socket.error: - # socket.error when we cannot reach Hue - _LOGGER.exception("Cannot reach the bridge") - return +def process_lights(hass, api, bridge, bridge_type, update_lights_cb): + """Set up HueLight objects for all lights.""" + api_lights = api.get('lights') - api_lights = api.get('lights') + if not isinstance(api_lights, dict): + _LOGGER.error('Got unexpected result from Hue API') + return [] - if not isinstance(api_lights, dict): - _LOGGER.error("Got unexpected result from Hue API") - return + new_lights = [] - if skip_groups: - api_groups = {} + lights = hass.data[DATA_KEY][DATA_LIGHTS] + for light_id, info in api_lights.items(): + if light_id not in lights: + lights[light_id] = HueLight( + int(light_id), info, bridge, + update_lights_cb, + bridge_type, bridge.allow_unreachable, + bridge.allow_in_emulated_hue) + new_lights.append(lights[light_id]) else: - api_groups = api.get('groups') + lights[light_id].info = info + lights[light_id].schedule_update_ha_state() - if not isinstance(api_groups, dict): - _LOGGER.error("Got unexpected result from Hue API") - return + return new_lights - new_lights = [] - api_name = api.get('config').get('name') - if api_name in ('RaspBee-GW', 'deCONZ-GW'): - bridge_type = 'deconz' +def process_groups(hass, api, bridge, bridge_type, update_lights_cb): + """Set up HueLight objects for all groups.""" + api_groups = api.get('groups') + + if not isinstance(api_groups, dict): + _LOGGER.error('Got unexpected result from Hue API') + return [] + + new_lights = [] + + groups = hass.data[DATA_KEY][DATA_LIGHTGROUPS] + for lightgroup_id, info in api_groups.items(): + if 'state' not in info: + _LOGGER.warning('Group info does not contain state. ' + 'Please update your hub.') + return [] + + if lightgroup_id not in groups: + groups[lightgroup_id] = HueLight( + int(lightgroup_id), info, bridge, + update_lights_cb, + bridge_type, bridge.allow_unreachable, + bridge.allow_in_emulated_hue, True) + new_lights.append(groups[lightgroup_id]) else: - bridge_type = 'hue' + groups[lightgroup_id].info = info + groups[lightgroup_id].schedule_update_ha_state() - for light_id, info in api_lights.items(): - if light_id not in lights: - lights[light_id] = HueLight(int(light_id), info, - bridge, update_lights, - bridge_type, allow_unreachable, - allow_in_emulated_hue) - new_lights.append(lights[light_id]) - else: - lights[light_id].info = info - lights[light_id].schedule_update_ha_state() - - for lightgroup_id, info in api_groups.items(): - if 'state' not in info: - _LOGGER.warning("Group info does not contain state. " - "Please update your hub.") - skip_groups = True - break - - if lightgroup_id not in lightgroups: - lightgroups[lightgroup_id] = HueLight( - int(lightgroup_id), info, bridge, update_lights, - bridge_type, allow_unreachable, allow_in_emulated_hue, - True) - new_lights.append(lightgroups[lightgroup_id]) - else: - lightgroups[lightgroup_id].info = info - lightgroups[lightgroup_id].schedule_update_ha_state() - - if new_lights: - add_devices(new_lights) - - _CONFIGURED_BRIDGES[socket.gethostbyname(host)] = True - - # create a service for calling run_scene directly on the bridge, - # used to simplify automation rules. - def hue_activate_scene(call): - """Service to call directly into bridge to set scenes.""" - group_name = call.data[ATTR_GROUP_NAME] - scene_name = call.data[ATTR_SCENE_NAME] - bridge.run_scene(group_name, scene_name) - - descriptions = load_yaml_config_file( - os.path.join(os.path.dirname(__file__), 'services.yaml')) - hass.services.register(DOMAIN, SERVICE_HUE_SCENE, hue_activate_scene, - descriptions.get(SERVICE_HUE_SCENE), - schema=SCENE_SCHEMA) - - update_lights() - - -def request_configuration(host, hass, add_devices, filename, - allow_unreachable, allow_in_emulated_hue, - allow_hue_groups): - """Request configuration steps from the user.""" - configurator = hass.components.configurator - - # We got an error if this method is called while we are configuring - if host in _CONFIGURING: - configurator.notify_errors( - _CONFIGURING[host], "Failed to register, please try again.") - - return - - # pylint: disable=unused-argument - def hue_configuration_callback(data): - """Set up actions to do when our configuration callback is called.""" - setup_bridge(host, hass, add_devices, filename, allow_unreachable, - allow_in_emulated_hue, allow_hue_groups) - - _CONFIGURING[host] = configurator.request_config( - "Philips Hue", hue_configuration_callback, - description=CONFIG_INSTRUCTIONS, - entity_picture="/static/images/logo_philips_hue.png", - submit_caption="I have pressed the button" - ) + return new_lights class HueLight(Light): """Representation of a Hue light.""" - def __init__(self, light_id, info, bridge, update_lights, + def __init__(self, light_id, info, bridge, update_lights_cb, bridge_type, allow_unreachable, allow_in_emulated_hue, is_group=False): """Initialize the light.""" self.light_id = light_id self.info = info self.bridge = bridge - self.update_lights = update_lights + self.update_lights = update_lights_cb self.bridge_type = bridge_type self.allow_unreachable = allow_unreachable self.is_group = is_group @@ -381,14 +310,15 @@ class HueLight(Light): command['transitiontime'] = int(kwargs[ATTR_TRANSITION] * 10) if ATTR_XY_COLOR in kwargs: - if self.info.get('manufacturername') == "OSRAM": - hue, sat = color_util.color_xy_to_hs(*kwargs[ATTR_XY_COLOR]) - command['hue'] = hue + if self.info.get('manufacturername') == 'OSRAM': + color_hue, sat = color_util.color_xy_to_hs( + *kwargs[ATTR_XY_COLOR]) + command['hue'] = color_hue command['sat'] = sat else: command['xy'] = kwargs[ATTR_XY_COLOR] elif ATTR_RGB_COLOR in kwargs: - if self.info.get('manufacturername') == "OSRAM": + if self.info.get('manufacturername') == 'OSRAM': hsv = color_util.color_RGB_to_hsv( *(int(val) for val in kwargs[ATTR_RGB_COLOR])) command['hue'] = hsv[0] diff --git a/homeassistant/components/light/lifx.py b/homeassistant/components/light/lifx.py index ad2cf204463..06a00954d3b 100644 --- a/homeassistant/components/light/lifx.py +++ b/homeassistant/components/light/lifx.py @@ -33,7 +33,7 @@ import homeassistant.util.color as color_util _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['aiolifx==0.6.0', 'aiolifx_effects==0.1.2'] +REQUIREMENTS = ['aiolifx==0.6.1', 'aiolifx_effects==0.1.2'] UDP_BROADCAST_PORT = 56700 @@ -157,20 +157,10 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): return True -def lifxwhite(device): - """Return whether this is a white-only bulb.""" - features = aiolifx().products.features_map.get(device.product, None) - if features: - return not features["color"] - return False - - -def lifxmultizone(device): - """Return whether this is a multizone bulb/strip.""" - features = aiolifx().products.features_map.get(device.product, None) - if features: - return features["multizone"] - return False +def lifx_features(device): + """Return a feature map for this device, or a default map if unknown.""" + return aiolifx().products.features_map.get(device.product) or \ + aiolifx().products.features_map.get(1) def find_hsbk(**kwargs): @@ -342,12 +332,12 @@ class LIFXManager(object): device.retry_count = MESSAGE_RETRIES device.unregister_timeout = UNAVAILABLE_GRACE - if lifxwhite(device): - entity = LIFXWhite(device, self.effects_conductor) - elif lifxmultizone(device): + if lifx_features(device)["multizone"]: entity = LIFXStrip(device, self.effects_conductor) - else: + elif lifx_features(device)["color"]: entity = LIFXColor(device, self.effects_conductor) + else: + entity = LIFXWhite(device, self.effects_conductor) _LOGGER.debug("%s register READY", entity.who) self.entities[device.mac_addr] = entity @@ -427,6 +417,29 @@ class LIFXLight(Light): """Return a string identifying the device.""" return "%s (%s)" % (self.device.ip_addr, self.name) + @property + def min_mireds(self): + """Return the coldest color_temp that this light supports.""" + kelvin = lifx_features(self.device)['max_kelvin'] + return math.floor(color_util.color_temperature_kelvin_to_mired(kelvin)) + + @property + def max_mireds(self): + """Return the warmest color_temp that this light supports.""" + kelvin = lifx_features(self.device)['min_kelvin'] + return math.ceil(color_util.color_temperature_kelvin_to_mired(kelvin)) + + @property + def supported_features(self): + """Flag supported features.""" + support = SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION | SUPPORT_EFFECT + + device_features = lifx_features(self.device) + if device_features['min_kelvin'] != device_features['max_kelvin']: + support |= SUPPORT_COLOR_TEMP + + return support + @property def brightness(self): """Return the brightness of this light between 0..255.""" @@ -571,22 +584,6 @@ class LIFXLight(Light): class LIFXWhite(LIFXLight): """Representation of a white-only LIFX light.""" - @property - def min_mireds(self): - """Return the coldest color_temp that this light supports.""" - return math.floor(color_util.color_temperature_kelvin_to_mired(6500)) - - @property - def max_mireds(self): - """Return the warmest color_temp that this light supports.""" - return math.ceil(color_util.color_temperature_kelvin_to_mired(2700)) - - @property - def supported_features(self): - """Flag supported features.""" - return (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_TRANSITION | - SUPPORT_EFFECT) - @property def effect_list(self): """Return the list of supported effects for this light.""" @@ -599,21 +596,12 @@ class LIFXWhite(LIFXLight): class LIFXColor(LIFXLight): """Representation of a color LIFX light.""" - @property - def min_mireds(self): - """Return the coldest color_temp that this light supports.""" - return math.floor(color_util.color_temperature_kelvin_to_mired(9000)) - - @property - def max_mireds(self): - """Return the warmest color_temp that this light supports.""" - return math.ceil(color_util.color_temperature_kelvin_to_mired(2500)) - @property def supported_features(self): """Flag supported features.""" - return (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_TRANSITION | - SUPPORT_EFFECT | SUPPORT_RGB_COLOR | SUPPORT_XY_COLOR) + support = super().supported_features + support |= SUPPORT_RGB_COLOR | SUPPORT_XY_COLOR + return support @property def effect_list(self): diff --git a/homeassistant/components/light/mochad.py b/homeassistant/components/light/mochad.py index fffaa293188..efc62b05434 100644 --- a/homeassistant/components/light/mochad.py +++ b/homeassistant/components/light/mochad.py @@ -12,13 +12,15 @@ import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light, PLATFORM_SCHEMA) from homeassistant.components import mochad -from homeassistant.const import (CONF_NAME, CONF_PLATFORM, CONF_DEVICES, - CONF_ADDRESS) +from homeassistant.const import ( + CONF_NAME, CONF_PLATFORM, CONF_DEVICES, CONF_ADDRESS) from homeassistant.helpers import config_validation as cv DEPENDENCIES = ['mochad'] _LOGGER = logging.getLogger(__name__) +CONF_BRIGHTNESS_LEVELS = 'brightness_levels' + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_PLATFORM): mochad.DOMAIN, @@ -26,6 +28,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME): cv.string, vol.Required(CONF_ADDRESS): cv.x10_address, vol.Optional(mochad.CONF_COMM_TYPE): cv.string, + vol.Optional(CONF_BRIGHTNESS_LEVELS, default=32): + vol.All(vol.Coerce(int), vol.In([32, 64, 256])), }] }) @@ -54,6 +58,7 @@ class MochadLight(Light): comm_type=self._comm_type) self._brightness = 0 self._state = self._get_device_status() + self._brightness_levels = dev.get(CONF_BRIGHTNESS_LEVELS) - 1 @property def brightness(self): @@ -62,7 +67,8 @@ class MochadLight(Light): def _get_device_status(self): """Get the status of the light from mochad.""" - status = self.device.get_status().rstrip() + with mochad.REQ_LOCK: + status = self.device.get_status().rstrip() return status == 'on' @property @@ -85,15 +91,47 @@ class MochadLight(Light): """X10 devices are normally 1-way so we have to assume the state.""" return True + def _calculate_brightness_value(self, value): + return int(value * (float(self._brightness_levels) / 255.0)) + + def _adjust_brightness(self, brightness): + if self._brightness > brightness: + bdelta = self._brightness - brightness + mochad_brightness = self._calculate_brightness_value(bdelta) + self.device.send_cmd("dim {}".format(mochad_brightness)) + self._controller.read_data() + elif self._brightness < brightness: + bdelta = brightness - self._brightness + mochad_brightness = self._calculate_brightness_value(bdelta) + self.device.send_cmd("bright {}".format(mochad_brightness)) + self._controller.read_data() + def turn_on(self, **kwargs): """Send the command to turn the light on.""" - self._brightness = kwargs.get(ATTR_BRIGHTNESS, 255) - self.device.send_cmd("xdim {}".format(self._brightness)) - self._controller.read_data() + brightness = kwargs.get(ATTR_BRIGHTNESS, 255) + with mochad.REQ_LOCK: + if self._brightness_levels > 32: + out_brightness = self._calculate_brightness_value(brightness) + self.device.send_cmd('xdim {}'.format(out_brightness)) + self._controller.read_data() + else: + self.device.send_cmd("on") + self._controller.read_data() + # There is no persistence for X10 modules so a fresh on command + # will be full brightness + if self._brightness == 0: + self._brightness = 255 + self._adjust_brightness(brightness) + self._brightness = brightness self._state = True def turn_off(self, **kwargs): """Send the command to turn the light on.""" - self.device.send_cmd('off') - self._controller.read_data() + with mochad.REQ_LOCK: + self.device.send_cmd('off') + self._controller.read_data() + # There is no persistence for X10 modules so we need to prepare + # to track a fresh on command will full brightness + if self._brightness_levels == 31: + self._brightness = 0 self._state = False diff --git a/homeassistant/components/light/tplink.py b/homeassistant/components/light/tplink.py index f6a544950c0..692a5fb86ec 100644 --- a/homeassistant/components/light/tplink.py +++ b/homeassistant/components/light/tplink.py @@ -72,6 +72,7 @@ class TPLinkSmartBulb(Light): if name is not None: self._name = name self._state = None + self._available = True self._color_temp = None self._brightness = None self._rgb = None @@ -83,6 +84,11 @@ class TPLinkSmartBulb(Light): """Return the name of the Smart Bulb, if any.""" return self._name + @property + def available(self) -> bool: + """Return if bulb is available.""" + return self._available + @property def device_state_attributes(self): """Return the state attributes of the device.""" @@ -132,6 +138,7 @@ class TPLinkSmartBulb(Light): """Update the TP-Link Bulb's state.""" from pyHS100 import SmartDeviceException try: + self._available = True if self._supported_features == 0: self.get_features() self._state = ( @@ -163,8 +170,10 @@ class TPLinkSmartBulb(Light): except KeyError: # device returned no daily/monthly history pass + except (SmartDeviceException, OSError) as ex: - _LOGGER.warning('Could not read state for %s: %s', self._name, ex) + _LOGGER.warning("Could not read state for %s: %s", self._name, ex) + self._available = False @property def supported_features(self): diff --git a/homeassistant/components/light/tradfri.py b/homeassistant/components/light/tradfri.py index 3bba6da8dd3..bb2fa44c15c 100644 --- a/homeassistant/components/light/tradfri.py +++ b/homeassistant/components/light/tradfri.py @@ -99,7 +99,7 @@ class TradfriGroup(Light): @asyncio.coroutine def async_turn_off(self, **kwargs): """Instruct the group lights to turn off.""" - self.hass.async_add_job(self._api(self._group.set_state(0))) + yield from self._api(self._group.set_state(0)) @asyncio.coroutine def async_turn_on(self, **kwargs): @@ -112,10 +112,10 @@ class TradfriGroup(Light): if kwargs[ATTR_BRIGHTNESS] == 255: kwargs[ATTR_BRIGHTNESS] = 254 - self.hass.async_add_job(self._api( - self._group.set_dimmer(kwargs[ATTR_BRIGHTNESS], **keys))) + yield from self._api( + self._group.set_dimmer(kwargs[ATTR_BRIGHTNESS], **keys)) else: - self.hass.async_add_job(self._api(self._group.set_state(1))) + yield from self._api(self._group.set_state(1)) @callback def _async_start_observe(self, exc=None): @@ -140,11 +140,11 @@ class TradfriGroup(Light): self._group = group self._name = group.name + @callback def _observe_update(self, tradfri_device): """Receive new state data for this light.""" self._refresh(tradfri_device) - - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() class TradfriLight(Light): @@ -160,6 +160,7 @@ class TradfriLight(Light): self._rgb_color = None self._features = SUPPORTED_FEATURES self._temp_supported = False + self._available = True self._refresh(light) @@ -196,6 +197,11 @@ class TradfriLight(Light): """Start thread when added to hass.""" self._async_start_observe() + @property + def available(self): + """Return True if entity is available.""" + return self._available + @property def should_poll(self): """No polling needed for tradfri light.""" @@ -238,8 +244,7 @@ class TradfriLight(Light): @asyncio.coroutine def async_turn_off(self, **kwargs): """Instruct the light to turn off.""" - self.hass.async_add_job(self._api( - self._light_control.set_state(False))) + yield from self._api(self._light_control.set_state(False)) @asyncio.coroutine def async_turn_on(self, **kwargs): @@ -250,17 +255,17 @@ class TradfriLight(Light): for ATTR_RGB_COLOR, this also supports Philips Hue bulbs. """ if ATTR_RGB_COLOR in kwargs and self._light_data.hex_color is not None: - self.hass.async_add_job(self._api( + yield from self._api( self._light.light_control.set_rgb_color( - *kwargs[ATTR_RGB_COLOR]))) + *kwargs[ATTR_RGB_COLOR])) elif ATTR_COLOR_TEMP in kwargs and \ self._light_data.hex_color is not None and \ self._temp_supported: kelvin = color_util.color_temperature_mired_to_kelvin( kwargs[ATTR_COLOR_TEMP]) - self.hass.async_add_job(self._api( - self._light_control.set_kelvin_color(kelvin))) + yield from self._api( + self._light_control.set_kelvin_color(kelvin)) keys = {} if ATTR_TRANSITION in kwargs: @@ -270,12 +275,12 @@ class TradfriLight(Light): if kwargs[ATTR_BRIGHTNESS] == 255: kwargs[ATTR_BRIGHTNESS] = 254 - self.hass.async_add_job(self._api( + yield from self._api( self._light_control.set_dimmer(kwargs[ATTR_BRIGHTNESS], - **keys))) + **keys)) else: - self.hass.async_add_job(self._api( - self._light_control.set_state(True))) + yield from self._api( + self._light_control.set_state(True)) @callback def _async_start_observe(self, exc=None): @@ -300,6 +305,7 @@ class TradfriLight(Light): self._light = light # Caching of LightControl and light object + self._available = light.reachable self._light_control = light.light_control self._light_data = light.light_control.lights[0] self._name = light.name @@ -318,10 +324,11 @@ class TradfriLight(Light): self._temp_supported = self._light.device_info.manufacturer \ in ALLOWED_TEMPERATURES + @callback def _observe_update(self, tradfri_device): """Receive new state data for this light.""" self._refresh(tradfri_device) self._rgb_color = color_util.rgb_hex_to_rgb_list( self._light_data.hex_color_inferred ) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/light/vera.py b/homeassistant/components/light/vera.py index b3be93d82e2..102ca814882 100644 --- a/homeassistant/components/light/vera.py +++ b/homeassistant/components/light/vera.py @@ -21,7 +21,8 @@ DEPENDENCIES = ['vera'] def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Vera lights.""" add_devices( - VeraLight(device, VERA_CONTROLLER) for device in VERA_DEVICES['light']) + VeraLight(device, hass.data[VERA_CONTROLLER]) for + device in hass.data[VERA_DEVICES]['light']) class VeraLight(VeraDevice, Light): diff --git a/homeassistant/components/lock/isy994.py b/homeassistant/components/lock/isy994.py index edbb8a34f24..63272b90b1f 100644 --- a/homeassistant/components/lock/isy994.py +++ b/homeassistant/components/lock/isy994.py @@ -66,7 +66,10 @@ class ISYLockDevice(isy.ISYDevice, LockDevice): @property def state(self) -> str: """Get the state of the lock.""" - return VALUE_TO_STATE.get(self.value, STATE_UNKNOWN) + if self.is_unknown(): + return None + else: + return VALUE_TO_STATE.get(self.value, STATE_UNKNOWN) def lock(self, **kwargs) -> None: """Send the lock command to the ISY994 device.""" diff --git a/homeassistant/components/lock/vera.py b/homeassistant/components/lock/vera.py index 04962566821..b3aae5e159f 100644 --- a/homeassistant/components/lock/vera.py +++ b/homeassistant/components/lock/vera.py @@ -19,8 +19,8 @@ DEPENDENCIES = ['vera'] def setup_platform(hass, config, add_devices, discovery_info=None): """Find and return Vera locks.""" add_devices( - VeraLock(device, VERA_CONTROLLER) for - device in VERA_DEVICES['lock']) + VeraLock(device, hass.data[VERA_CONTROLLER]) for + device in hass.data[VERA_DEVICES]['lock']) class VeraLock(VeraDevice, LockDevice): diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index 63a271acdd5..1dc0861d737 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -135,9 +135,8 @@ class LogbookView(HomeAssistantView): hass = request.app['hass'] events = yield from hass.async_add_job( - _get_events, hass, start_day, end_day) - events = _exclude_events(events, self.config) - return self.json(humanify(events)) + _get_events, hass, self.config, start_day, end_day) + return self.json(events) class Entry(object): @@ -274,7 +273,7 @@ def humanify(events): entity_id) -def _get_events(hass, start_day, end_day): +def _get_events(hass, config, start_day, end_day): """Get events for a period of time.""" from homeassistant.components.recorder.models import Events from homeassistant.components.recorder.util import ( @@ -285,7 +284,8 @@ def _get_events(hass, start_day, end_day): Events.time_fired).filter( (Events.time_fired > start_day) & (Events.time_fired < end_day)) - return execute(query) + events = execute(query) + return humanify(_exclude_events(events, config)) def _exclude_events(events, config): diff --git a/homeassistant/components/media_extractor.py b/homeassistant/components/media_extractor.py index 9d5e88282ae..669390b3b90 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -16,7 +16,7 @@ from homeassistant.components.media_player import ( from homeassistant.config import load_yaml_config_file from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['youtube_dl==2017.11.26'] +REQUIREMENTS = ['youtube_dl==2017.12.10'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/denonavr.py b/homeassistant/components/media_player/denonavr.py index 7fffc09696c..0a03af0e1bf 100644 --- a/homeassistant/components/media_player/denonavr.py +++ b/homeassistant/components/media_player/denonavr.py @@ -20,7 +20,7 @@ from homeassistant.const import ( CONF_NAME, STATE_ON, CONF_ZONE, CONF_TIMEOUT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['denonavr==0.5.4'] +REQUIREMENTS = ['denonavr==0.5.5'] _LOGGER = logging.getLogger(__name__) @@ -102,12 +102,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if config.get(CONF_HOST) is None and discovery_info is None: d_receivers = denonavr.discover() # More than one receiver could be discovered by that method - if d_receivers is not None: - for d_receiver in d_receivers: - host = d_receiver["host"] - name = d_receiver["friendlyName"] - new_hosts.append( - NewHost(host=host, name=name)) + for d_receiver in d_receivers: + host = d_receiver["host"] + name = d_receiver["friendlyName"] + new_hosts.append( + NewHost(host=host, name=name)) for entry in new_hosts: # Check if host not in cache, append it and save for later diff --git a/homeassistant/components/media_player/liveboxplaytv.py b/homeassistant/components/media_player/liveboxplaytv.py index 15698ec5022..8093f0d3dbe 100644 --- a/homeassistant/components/media_player/liveboxplaytv.py +++ b/homeassistant/components/media_player/liveboxplaytv.py @@ -20,8 +20,9 @@ from homeassistant.const import ( CONF_HOST, CONF_PORT, STATE_ON, STATE_OFF, STATE_PLAYING, STATE_PAUSED, CONF_NAME) import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util -REQUIREMENTS = ['liveboxplaytv==2.0.0'] +REQUIREMENTS = ['liveboxplaytv==2.0.2', 'pyteleloisirs==3.3'] _LOGGER = logging.getLogger(__name__) @@ -76,19 +77,32 @@ class LiveboxPlayTvDevice(MediaPlayerDevice): self._channel_list = {} self._current_channel = None self._current_program = None + self._media_duration = None + self._media_remaining_time = None self._media_image_url = None + self._media_last_updated = None @asyncio.coroutine def async_update(self): """Retrieve the latest data.""" + import pyteleloisirs try: self._state = self.refresh_state() # Update current channel channel = self._client.channel if channel is not None: - self._current_program = yield from \ - self._client.async_get_current_program_name() self._current_channel = channel + program = yield from \ + self._client.async_get_current_program() + if program and self._current_program != program.get('name'): + self._current_program = program.get('name') + # Media progress info + self._media_duration = \ + pyteleloisirs.get_program_duration(program) + rtime = pyteleloisirs.get_remaining_time(program) + if rtime != self._media_remaining_time: + self._media_remaining_time = rtime + self._media_last_updated = dt_util.utcnow() # Set media image to current program if a thumbnail is # available. Otherwise we'll use the channel's image. img_size = 800 @@ -100,7 +114,6 @@ class LiveboxPlayTvDevice(MediaPlayerDevice): chan_img_url = \ self._client.get_current_channel_image(img_size) self._media_image_url = chan_img_url - self.refresh_channel_list() except requests.ConnectionError: self._state = None @@ -149,8 +162,25 @@ class LiveboxPlayTvDevice(MediaPlayerDevice): if self._current_program: return '{}: {}'.format(self._current_channel, self._current_program) - else: - return self._current_channel + return self._current_channel + + @property + def media_duration(self): + """Duration of current playing media in seconds.""" + return self._media_duration + + @property + def media_position(self): + """Position of current playing media in seconds.""" + return self._media_remaining_time + + @property + def media_position_updated_at(self): + """When was the position of the current playing media valid. + + Returns value from homeassistant.util.dt.utcnow(). + """ + return self._media_last_updated @property def supported_features(self): diff --git a/homeassistant/components/media_player/samsungtv.py b/homeassistant/components/media_player/samsungtv.py index 0153eb687ff..d42bd9ea012 100644 --- a/homeassistant/components/media_player/samsungtv.py +++ b/homeassistant/components/media_player/samsungtv.py @@ -6,6 +6,7 @@ https://home-assistant.io/components/media_player.samsungtv/ """ import logging import socket +from datetime import timedelta import voluptuous as vol @@ -17,6 +18,7 @@ from homeassistant.const import ( CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON, STATE_UNKNOWN, CONF_PORT, CONF_MAC) import homeassistant.helpers.config_validation as cv +from homeassistant.util import dt as dt_util REQUIREMENTS = ['samsungctl==0.6.0', 'wakeonlan==0.2.2'] @@ -100,6 +102,9 @@ class SamsungTVDevice(MediaPlayerDevice): self._playing = True self._state = STATE_UNKNOWN self._remote = None + # Mark the end of a shutdown command (need to wait 15 seconds before + # sending the next command to avoid turning the TV back ON). + self._end_of_power_off = None # Generate a configuration for the Samsung library self._config = { 'name': 'HomeAssistant', @@ -118,7 +123,7 @@ class SamsungTVDevice(MediaPlayerDevice): def update(self): """Retrieve the latest data.""" # Send an empty key to see if we are still connected - return self.send_key('KEY') + self.send_key('KEY') def get_remote(self): """Create or return a remote control instance.""" @@ -130,6 +135,10 @@ class SamsungTVDevice(MediaPlayerDevice): def send_key(self, key): """Send a key to the tv and handles exceptions.""" + if self._power_off_in_progress() \ + and not (key == 'KEY_POWER' or key == 'KEY_POWEROFF'): + _LOGGER.info("TV is powering off, not sending command: %s", key) + return try: self.get_remote().control(key) self._state = STATE_ON @@ -139,13 +148,16 @@ class SamsungTVDevice(MediaPlayerDevice): # BrokenPipe can occur when the commands is sent to fast self._state = STATE_ON self._remote = None - return False + return except (self._exceptions_class.ConnectionClosed, OSError): self._state = STATE_OFF self._remote = None - return False + if self._power_off_in_progress(): + self._state = STATE_OFF - return True + def _power_off_in_progress(self): + return self._end_of_power_off is not None and \ + self._end_of_power_off > dt_util.utcnow() @property def name(self): @@ -171,12 +183,17 @@ class SamsungTVDevice(MediaPlayerDevice): def turn_off(self): """Turn off media player.""" + self._end_of_power_off = dt_util.utcnow() + timedelta(seconds=15) + if self._config['method'] == 'websocket': self.send_key('KEY_POWER') else: self.send_key('KEY_POWEROFF') # Force closing of remote session to provide instant UI feedback - self.get_remote().close() + try: + self.get_remote().close() + except OSError: + _LOGGER.debug("Could not establish connection.") def volume_up(self): """Volume up the media player.""" diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index f2d7b8e07dd..b2f98d378cf 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -215,6 +215,18 @@ sonos_clear_sleep_timer: description: Name(s) of entities that will have the timer cleared. example: 'media_player.living_room_sonos' +sonos_set_option: + description: Set Sonos sound options. + fields: + entity_id: + description: Name(s) of entities that will have options set. + example: 'media_player.living_room_sonos' + night_sound: + description: Enable Night Sound mode + example: 'true' + speech_enhance: + description: Enable Speech Enhancement mode + example: 'true' soundtouch_play_everywhere: description: Play on all Bose Soundtouch devices. diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index 47786e793ca..3bd3a722b46 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -19,7 +19,7 @@ from homeassistant.components.media_player import ( SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_CLEAR_PLAYLIST, SUPPORT_SELECT_SOURCE, MediaPlayerDevice, PLATFORM_SCHEMA, SUPPORT_STOP, - SUPPORT_PLAY) + SUPPORT_PLAY, SUPPORT_SHUFFLE_SET) from homeassistant.const import ( STATE_IDLE, STATE_PAUSED, STATE_PLAYING, STATE_OFF, ATTR_ENTITY_ID, CONF_HOSTS, ATTR_TIME) @@ -27,7 +27,7 @@ from homeassistant.config import load_yaml_config_file import homeassistant.helpers.config_validation as cv from homeassistant.util.dt import utcnow -REQUIREMENTS = ['SoCo==0.12'] +REQUIREMENTS = ['SoCo==0.13'] _LOGGER = logging.getLogger(__name__) @@ -43,7 +43,7 @@ _REQUESTS_LOGGER.setLevel(logging.ERROR) SUPPORT_SONOS = SUPPORT_STOP | SUPPORT_PAUSE | SUPPORT_VOLUME_SET |\ SUPPORT_VOLUME_MUTE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK |\ SUPPORT_PLAY_MEDIA | SUPPORT_SEEK | SUPPORT_CLEAR_PLAYLIST |\ - SUPPORT_SELECT_SOURCE | SUPPORT_PLAY + SUPPORT_SELECT_SOURCE | SUPPORT_PLAY | SUPPORT_SHUFFLE_SET SERVICE_JOIN = 'sonos_join' SERVICE_UNJOIN = 'sonos_unjoin' @@ -52,6 +52,7 @@ SERVICE_RESTORE = 'sonos_restore' SERVICE_SET_TIMER = 'sonos_set_sleep_timer' SERVICE_CLEAR_TIMER = 'sonos_clear_sleep_timer' SERVICE_UPDATE_ALARM = 'sonos_update_alarm' +SERVICE_SET_OPTION = 'sonos_set_option' DATA_SONOS = 'sonos' @@ -69,6 +70,8 @@ ATTR_ENABLED = 'enabled' ATTR_INCLUDE_LINKED_ZONES = 'include_linked_zones' ATTR_MASTER = 'master' ATTR_WITH_GROUP = 'with_group' +ATTR_NIGHT_SOUND = 'night_sound' +ATTR_SPEECH_ENHANCE = 'speech_enhance' ATTR_IS_COORDINATOR = 'is_coordinator' @@ -105,6 +108,11 @@ SONOS_UPDATE_ALARM_SCHEMA = SONOS_SCHEMA.extend({ vol.Optional(ATTR_INCLUDE_LINKED_ZONES): cv.boolean, }) +SONOS_SET_OPTION_SCHEMA = SONOS_SCHEMA.extend({ + vol.Optional(ATTR_NIGHT_SOUND): cv.boolean, + vol.Optional(ATTR_SPEECH_ENHANCE): cv.boolean, +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Sonos platform.""" @@ -140,7 +148,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): hosts = hosts.split(',') if isinstance(hosts, str) else hosts players = [] for host in hosts: - players.append(soco.SoCo(socket.gethostbyname(host))) + try: + players.append(soco.SoCo(socket.gethostbyname(host))) + except OSError: + _LOGGER.warning("Failed to initialize '%s'", host) if not players: players = soco.discover( @@ -189,6 +200,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): device.clear_sleep_timer() elif service.service == SERVICE_UPDATE_ALARM: device.update_alarm(**service.data) + elif service.service == SERVICE_SET_OPTION: + device.update_option(**service.data) device.schedule_update_ha_state(True) @@ -221,6 +234,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): descriptions.get(SERVICE_UPDATE_ALARM), schema=SONOS_UPDATE_ALARM_SCHEMA) + hass.services.register( + DOMAIN, SERVICE_SET_OPTION, service_handle, + descriptions.get(SERVICE_SET_OPTION), + schema=SONOS_SET_OPTION_SCHEMA) + def _parse_timespan(timespan): """Parse a time-span into number of seconds.""" @@ -331,8 +349,11 @@ class SonosDevice(MediaPlayerDevice): self._support_previous_track = False self._support_next_track = False self._support_play = False + self._support_shuffle_set = True self._support_stop = False self._support_pause = False + self._night_sound = None + self._speech_enhance = None self._current_track_uri = None self._current_track_is_radio_stream = False self._queue = None @@ -450,8 +471,11 @@ class SonosDevice(MediaPlayerDevice): self._support_previous_track = False self._support_next_track = False self._support_play = False + self._support_shuffle_set = False self._support_stop = False self._support_pause = False + self._night_sound = None + self._speech_enhance = None self._is_playing_tv = False self._is_playing_line_in = False self._source_name = None @@ -524,6 +548,9 @@ class SonosDevice(MediaPlayerDevice): media_position_updated_at = None source_name = None + night_sound = self._player.night_mode + speech_enhance = self._player.dialog_mode + is_radio_stream = \ current_media_uri.startswith('x-sonosapi-stream:') or \ current_media_uri.startswith('x-rincon-mp3radio:') @@ -536,6 +563,7 @@ class SonosDevice(MediaPlayerDevice): support_play = False support_stop = True support_pause = False + support_shuffle_set = False if is_playing_tv: media_artist = SUPPORT_SOURCE_TV @@ -558,6 +586,7 @@ class SonosDevice(MediaPlayerDevice): support_play = True support_stop = True support_pause = False + support_shuffle_set = False source_name = 'Radio' # Check if currently playing radio station is in favorites @@ -622,6 +651,7 @@ class SonosDevice(MediaPlayerDevice): support_play = True support_stop = True support_pause = True + support_shuffle_set = True position_info = self._player.avTransport.GetPositionInfo( [('InstanceID', 0), @@ -694,8 +724,11 @@ class SonosDevice(MediaPlayerDevice): self._support_previous_track = support_previous_track self._support_next_track = support_next_track self._support_play = support_play + self._support_shuffle_set = support_shuffle_set self._support_stop = support_stop self._support_pause = support_pause + self._night_sound = night_sound + self._speech_enhance = speech_enhance self._is_playing_tv = is_playing_tv self._is_playing_line_in = is_playing_line_in self._source_name = source_name @@ -762,6 +795,11 @@ class SonosDevice(MediaPlayerDevice): """Return true if volume is muted.""" return self._player_volume_muted + @property + def shuffle(self): + """Shuffling state.""" + return True if self._player.play_mode == 'SHUFFLE' else False + @property def media_content_id(self): """Content ID of current playing media.""" @@ -834,6 +872,16 @@ class SonosDevice(MediaPlayerDevice): return self._media_title + @property + def night_sound(self): + """Get status of Night Sound.""" + return self._night_sound + + @property + def speech_enhance(self): + """Get status of Speech Enhancement.""" + return self._speech_enhance + @property def supported_features(self): """Flag media player features that are supported.""" @@ -850,7 +898,8 @@ class SonosDevice(MediaPlayerDevice): if not self._support_play: supported = supported ^ SUPPORT_PLAY - + if not self._support_shuffle_set: + supported = supported ^ SUPPORT_SHUFFLE_SET if not self._support_stop: supported = supported ^ SUPPORT_STOP @@ -874,6 +923,11 @@ class SonosDevice(MediaPlayerDevice): """Set volume level, range 0..1.""" self._player.volume = str(int(volume * 100)) + @soco_error + def set_shuffle(self, shuffle): + """Enable/Disable shuffle mode.""" + self._player.play_mode = 'SHUFFLE' if shuffle else 'NORMAL' + @soco_error def mute_volume(self, mute): """Mute (true) or unmute (false) media player.""" @@ -932,7 +986,6 @@ class SonosDevice(MediaPlayerDevice): self._player.stop() self._player.clear_queue() - self._player.play_mode = 'NORMAL' self._player.add_to_queue(didl) @property @@ -1160,7 +1213,24 @@ class SonosDevice(MediaPlayerDevice): a.include_linked_zones = data[ATTR_INCLUDE_LINKED_ZONES] a.save() + @soco_error + def update_option(self, **data): + """Modify playback options.""" + if ATTR_NIGHT_SOUND in data and self.night_sound is not None: + self.soco.night_mode = data[ATTR_NIGHT_SOUND] + + if ATTR_SPEECH_ENHANCE in data and self.speech_enhance is not None: + self.soco.dialog_mode = data[ATTR_SPEECH_ENHANCE] + @property def device_state_attributes(self): """Return device specific state attributes.""" - return {ATTR_IS_COORDINATOR: self.is_coordinator} + attributes = {ATTR_IS_COORDINATOR: self.is_coordinator} + + if self.night_sound is not None: + attributes[ATTR_NIGHT_SOUND] = self.night_sound + + if self.speech_enhance is not None: + attributes[ATTR_SPEECH_ENHANCE] = self.speech_enhance + + return attributes diff --git a/homeassistant/components/media_player/ue_smart_radio.py b/homeassistant/components/media_player/ue_smart_radio.py new file mode 100644 index 00000000000..2684a819417 --- /dev/null +++ b/homeassistant/components/media_player/ue_smart_radio.py @@ -0,0 +1,207 @@ +""" +Support for Logitech UE Smart Radios. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.ue_smart_radio/ +""" + +import logging +import voluptuous as vol +import requests + +from homeassistant.components.media_player import ( + MediaPlayerDevice, MEDIA_TYPE_MUSIC, PLATFORM_SCHEMA, + SUPPORT_PLAY, SUPPORT_PAUSE, SUPPORT_STOP, SUPPORT_PREVIOUS_TRACK, + SUPPORT_NEXT_TRACK, SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_VOLUME_SET, + SUPPORT_VOLUME_MUTE) +from homeassistant.const import ( + CONF_USERNAME, CONF_PASSWORD, STATE_OFF, STATE_IDLE, STATE_PLAYING, + STATE_PAUSED) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +ICON = "mdi:radio" +URL = "http://decibel.logitechmusic.com/jsonrpc.js" + +SUPPORT_UE_SMART_RADIO = SUPPORT_PLAY | SUPPORT_PAUSE | SUPPORT_STOP | \ + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_TURN_ON | \ + SUPPORT_TURN_OFF | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE + +PLAYBACK_DICT = {"play": STATE_PLAYING, + "pause": STATE_PAUSED, + "stop": STATE_IDLE} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, +}) + + +def send_request(payload, session): + """Send request to radio.""" + try: + request = requests.post(URL, + cookies={"sdi_squeezenetwork_session": + session}, + json=payload, timeout=5) + except requests.exceptions.Timeout: + _LOGGER.error("Timed out when sending request") + except requests.exceptions.ConnectionError: + _LOGGER.error("An error occurred while connecting") + else: + return request.json() + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Logitech UE Smart Radio platform.""" + email = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + + session_request = requests.post("https://www.uesmartradio.com/user/login", + data={"email": email, "password": + password}) + session = session_request.cookies["sdi_squeezenetwork_session"] + + player_request = send_request({"params": ["", ["serverstatus"]]}, session) + player_id = player_request["result"]["players_loop"][0]["playerid"] + player_name = player_request["result"]["players_loop"][0]["name"] + + add_devices([UERadioDevice(session, player_id, player_name)]) + + +class UERadioDevice(MediaPlayerDevice): + """Representation of a Logitech UE Smart Radio device.""" + + def __init__(self, session, player_id, player_name): + """Initialize the Logitech UE Smart Radio device.""" + self._session = session + self._player_id = player_id + self._name = player_name + self._state = None + self._volume = 0 + self._last_volume = 0 + self._media_title = None + self._media_artist = None + self._media_artwork_url = None + + def send_command(self, command): + """Send command to radio.""" + send_request({"method": "slim.request", "params": + [self._player_id, command]}, self._session) + + def update(self): + """Get the latest details from the device.""" + request = send_request({ + "method": "slim.request", "params": + [self._player_id, ["status", "-", 1, + "tags:cgABbehldiqtyrSuoKLN"]]}, self._session) + + if request["error"] is not None: + self._state = None + return + + if request["result"]["power"] == 0: + self._state = STATE_OFF + else: + self._state = PLAYBACK_DICT[request["result"]["mode"]] + + media_info = request["result"]["playlist_loop"][0] + + self._volume = request["result"]["mixer volume"] / 100 + self._media_artwork_url = media_info["artwork_url"] + self._media_title = media_info["title"] + if "artist" in media_info: + self._media_artist = media_info["artist"] + else: + self._media_artist = media_info.get("remote_title") + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return ICON + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return True if self._volume <= 0 else False + + @property + def volume_level(self): + """Volume level of the media player (0..1).""" + return self._volume + + @property + def supported_features(self): + """Flag of features that are supported.""" + return SUPPORT_UE_SMART_RADIO + + @property + def media_content_type(self): + """Return the media content type.""" + return MEDIA_TYPE_MUSIC + + @property + def media_image_url(self): + """Image URL of current playing media.""" + return self._media_artwork_url + + @property + def media_artist(self): + """Artist of current playing media, music track only.""" + return self._media_artist + + @property + def media_title(self): + """Title of current playing media.""" + return self._media_title + + def turn_on(self): + """Turn on specified media player or all.""" + self.send_command(["power", 1]) + + def turn_off(self): + """Turn off specified media player or all.""" + self.send_command(["power", 0]) + + def media_play(self): + """Send the media player the command for play/pause.""" + self.send_command(["play"]) + + def media_pause(self): + """Send the media player the command for pause.""" + self.send_command(["pause"]) + + def media_stop(self): + """Send the media player the stop command.""" + self.send_command(["stop"]) + + def media_previous_track(self): + """Send the media player the command for prev track.""" + self.send_command(["button", "rew"]) + + def media_next_track(self): + """Send the media player the command for next track.""" + self.send_command(["button", "fwd"]) + + def mute_volume(self, mute): + """Send mute command.""" + if mute: + self._last_volume = self._volume + self.send_command(["mixer", "volume", 0]) + else: + self.send_command(["mixer", "volume", self._last_volume * 100]) + + def set_volume_level(self, volume): + """Set volume level, range 0..1.""" + self.send_command(["mixer", "volume", volume * 100]) diff --git a/homeassistant/components/media_player/webostv.py b/homeassistant/components/media_player/webostv.py index 3215ad82a7c..0abdb90e67a 100644 --- a/homeassistant/components/media_player/webostv.py +++ b/homeassistant/components/media_player/webostv.py @@ -322,12 +322,15 @@ class LgWebOSDevice(MediaPlayerDevice): def select_source(self, source): """Select input source.""" - if self._source_list.get(source).get('title'): - self._current_source_id = self._source_list[source]['id'] + source = self._source_list.get(source) + if source is None: + _LOGGER.warning("Source %s not found for %s", source, self.name) + return + self._current_source_id = self._source_list[source]['id'] + if source.get('title'): self._current_source = self._source_list[source]['title'] self._client.launch_app(self._source_list[source]['id']) - elif self._source_list.get(source).get('label'): - self._current_source_id = self._source_list[source]['id'] + elif source.get('label'): self._current_source = self._source_list[source]['label'] self._client.set_input(self._source_list[source]['id']) diff --git a/homeassistant/components/media_player/yamaha_musiccast.py b/homeassistant/components/media_player/yamaha_musiccast.py index bfcffff6bb4..b42a5ae474c 100644 --- a/homeassistant/components/media_player/yamaha_musiccast.py +++ b/homeassistant/components/media_player/yamaha_musiccast.py @@ -36,7 +36,7 @@ SUPPORTED_FEATURES = ( KNOWN_HOSTS_KEY = 'data_yamaha_musiccast' INTERVAL_SECONDS = 'interval_seconds' -REQUIREMENTS = ['pymusiccast==0.1.5'] +REQUIREMENTS = ['pymusiccast==0.1.6'] DEFAULT_PORT = 5005 DEFAULT_INTERVAL = 480 diff --git a/homeassistant/components/media_player/ziggo_mediabox_xl.py b/homeassistant/components/media_player/ziggo_mediabox_xl.py new file mode 100644 index 00000000000..1886cd751ea --- /dev/null +++ b/homeassistant/components/media_player/ziggo_mediabox_xl.py @@ -0,0 +1,174 @@ +""" +Support for interface with a Ziggo Mediabox XL. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.ziggo_mediabox_xl/ +""" +import logging +import socket + +import voluptuous as vol + +from homeassistant.components.media_player import ( + PLATFORM_SCHEMA, MediaPlayerDevice, + SUPPORT_TURN_ON, SUPPORT_TURN_OFF, + SUPPORT_NEXT_TRACK, SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, + SUPPORT_PLAY, SUPPORT_PAUSE) +from homeassistant.const import ( + CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON, STATE_PAUSED, STATE_PLAYING) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['ziggo-mediabox-xl==1.0.0'] + +_LOGGER = logging.getLogger(__name__) + +DATA_KNOWN_DEVICES = 'ziggo_mediabox_xl_known_devices' + +SUPPORT_ZIGGO = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \ + SUPPORT_NEXT_TRACK | SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | \ + SUPPORT_SELECT_SOURCE | SUPPORT_PLAY + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Ziggo Mediabox XL platform.""" + from ziggo_mediabox_xl import ZiggoMediaboxXL + + hass.data[DATA_KNOWN_DEVICES] = known_devices = set() + + # Is this a manual configuration? + if config.get(CONF_HOST) is not None: + host = config.get(CONF_HOST) + name = config.get(CONF_NAME) + elif discovery_info is not None: + host = discovery_info.get('host') + name = discovery_info.get('name') + else: + _LOGGER.error("Cannot determine device") + return + + # Only add a device once, so discovered devices do not override manual + # config. + hosts = [] + ip_addr = socket.gethostbyname(host) + if ip_addr not in known_devices: + try: + mediabox = ZiggoMediaboxXL(ip_addr) + if mediabox.test_connection(): + hosts.append(ZiggoMediaboxXLDevice(mediabox, host, name)) + known_devices.add(ip_addr) + else: + _LOGGER.error("Can't connect to %s", host) + except socket.error as error: + _LOGGER.error("Can't connect to %s: %s", host, error) + else: + _LOGGER.info("Ignoring duplicate Ziggo Mediabox XL %s", host) + add_devices(hosts, True) + + +class ZiggoMediaboxXLDevice(MediaPlayerDevice): + """Representation of a Ziggo Mediabox XL Device.""" + + def __init__(self, mediabox, host, name): + """Initialize the device.""" + # Generate a configuration for the Samsung library + self._mediabox = mediabox + self._host = host + self._name = name + self._state = None + + def update(self): + """Retrieve the state of the device.""" + try: + if self._mediabox.turned_on(): + if self._state != STATE_PAUSED: + self._state = STATE_PLAYING + else: + self._state = STATE_OFF + except socket.error: + _LOGGER.error("Couldn't fetch state from %s", self._host) + + def send_keys(self, keys): + """Send keys to the device and handle exceptions.""" + try: + self._mediabox.send_keys(keys) + except socket.error: + _LOGGER.error("Couldn't send keys to %s", self._host) + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def source_list(self): + """List of available sources (channels).""" + return [self._mediabox.channels()[c] + for c in sorted(self._mediabox.channels().keys())] + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return SUPPORT_ZIGGO + + def turn_on(self): + """Turn the media player on.""" + self.send_keys(['POWER']) + self._state = STATE_ON + + def turn_off(self): + """Turn off media player.""" + self.send_keys(['POWER']) + self._state = STATE_OFF + + def media_play(self): + """Send play command.""" + self.send_keys(['PLAY']) + self._state = STATE_PLAYING + + def media_pause(self): + """Send pause command.""" + self.send_keys(['PAUSE']) + self._state = STATE_PAUSED + + def media_play_pause(self): + """Simulate play pause media player.""" + self.send_keys(['PAUSE']) + if self._state == STATE_PAUSED: + self._state = STATE_PLAYING + else: + self._state = STATE_PAUSED + + def media_next_track(self): + """Channel up.""" + self.send_keys(['CHAN_UP']) + self._state = STATE_PLAYING + + def media_previous_track(self): + """Channel down.""" + self.send_keys(['CHAN_DOWN']) + self._state = STATE_PLAYING + + def select_source(self, source): + """Select the channel.""" + if str(source).isdigit(): + digits = str(source) + else: + digits = next(( + key for key, value in self._mediabox.channels().items() + if value == source), None) + if digits is None: + return + + self.send_keys(['NUM_{}'.format(digit) + for digit in str(digits)]) + self._state = STATE_PLAYING diff --git a/homeassistant/components/mochad.py b/homeassistant/components/mochad.py index 165c43f488f..3cc4eda7675 100644 --- a/homeassistant/components/mochad.py +++ b/homeassistant/components/mochad.py @@ -5,6 +5,7 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/mochad/ """ import logging +import threading import voluptuous as vol @@ -23,6 +24,8 @@ CONF_COMM_TYPE = 'comm_type' DOMAIN = 'mochad' +REQ_LOCK = threading.Lock() + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Optional(CONF_HOST, default='localhost'): cv.string, diff --git a/homeassistant/components/neato.py b/homeassistant/components/neato.py index e10878833e4..bd680b5361e 100644 --- a/homeassistant/components/neato.py +++ b/homeassistant/components/neato.py @@ -17,8 +17,8 @@ from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['https://github.com/jabesq/pybotvac/archive/v0.0.3.zip' - '#pybotvac==0.0.3'] +REQUIREMENTS = ['https://github.com/jabesq/pybotvac/archive/v0.0.4.zip' + '#pybotvac==0.0.4'] DOMAIN = 'neato' NEATO_ROBOTS = 'neato_robots' diff --git a/homeassistant/components/prometheus.py b/homeassistant/components/prometheus.py index 0396cafd4ff..0ecfa50ee63 100644 --- a/homeassistant/components/prometheus.py +++ b/homeassistant/components/prometheus.py @@ -14,12 +14,13 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.components import recorder from homeassistant.const import ( CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_INCLUDE, TEMP_CELSIUS, - EVENT_STATE_CHANGED, TEMP_FAHRENHEIT, CONTENT_TYPE_TEXT_PLAIN) + EVENT_STATE_CHANGED, TEMP_FAHRENHEIT, CONTENT_TYPE_TEXT_PLAIN, + ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT) from homeassistant import core as hacore from homeassistant.helpers import state as state_helper from homeassistant.util.temperature import fahrenheit_to_celsius -REQUIREMENTS = ['prometheus_client==0.0.19'] +REQUIREMENTS = ['prometheus_client==0.0.21'] _LOGGER = logging.getLogger(__name__) @@ -159,6 +160,26 @@ class Metrics(object): value = state_helper.state_as_number(state) metric.labels(**self._labels(state)).set(value) + def _handle_climate(self, state): + temp = state.attributes.get(ATTR_TEMPERATURE) + if temp: + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + if unit == TEMP_FAHRENHEIT: + temp = fahrenheit_to_celsius(temp) + metric = self._metric( + 'temperature_c', self.prometheus_client.Gauge, + 'Temperature in degrees Celsius') + metric.labels(**self._labels(state)).set(temp) + + metric = self._metric( + 'climate_state', self.prometheus_client.Gauge, + 'State of the thermostat (0/1)') + try: + value = state_helper.state_as_number(state) + metric.labels(**self._labels(state)).set(value) + except ValueError: + pass + def _handle_sensor(self, state): _sensor_types = { TEMP_CELSIUS: ( @@ -189,9 +210,17 @@ class Metrics(object): 'electricity_usage_w', self.prometheus_client.Gauge, 'Currently reported electricity draw in Watts', ), + 'min': ( + 'sensor_min', self.prometheus_client.Gauge, + 'Time in minutes reported by a sensor' + ), + 'Events': ( + 'sensor_event_count', self.prometheus_client.Gauge, + 'Number of events for a sensor' + ), } - unit = state.attributes.get('unit_of_measurement') + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) metric = _sensor_types.get(unit) if metric is not None: @@ -212,12 +241,25 @@ class Metrics(object): self.prometheus_client.Gauge, 'State of the switch (0/1)', ) - value = state_helper.state_as_number(state) - metric.labels(**self._labels(state)).set(value) + + try: + value = state_helper.state_as_number(state) + metric.labels(**self._labels(state)).set(value) + except ValueError: + pass def _handle_zwave(self, state): self._battery(state) + def _handle_automation(self, state): + metric = self._metric( + 'automation_triggered_count', + self.prometheus_client.Counter, + 'Count of times an automation has been triggered', + ) + + metric.labels(**self._labels(state)).inc() + class PrometheusView(HomeAssistantView): """Handle Prometheus requests.""" diff --git a/homeassistant/components/scene/vera.py b/homeassistant/components/scene/vera.py new file mode 100644 index 00000000000..3dbb68d214f --- /dev/null +++ b/homeassistant/components/scene/vera.py @@ -0,0 +1,60 @@ +""" +Support for Vera scenes. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/scene.vera/ +""" +import logging + +from homeassistant.util import slugify +from homeassistant.components.scene import Scene +from homeassistant.components.vera import ( + VERA_CONTROLLER, VERA_SCENES, VERA_ID_FORMAT) + +DEPENDENCIES = ['vera'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Vera scenes.""" + add_devices( + [VeraScene(scene, hass.data[VERA_CONTROLLER]) + for scene in hass.data[VERA_SCENES]], True) + + +class VeraScene(Scene): + """Representation of a Vera scene entity.""" + + def __init__(self, vera_scene, controller): + """Initialize the scene.""" + self.vera_scene = vera_scene + self.controller = controller + + self._name = self.vera_scene.name + # Append device id to prevent name clashes in HA. + self.vera_id = VERA_ID_FORMAT.format( + slugify(vera_scene.name), vera_scene.scene_id) + + def update(self): + """Update the scene status.""" + self.vera_scene.refresh() + + def activate(self, **kwargs): + """Activate the scene.""" + self.vera_scene.activate() + + @property + def name(self): + """Return the name of the scene.""" + return self._name + + @property + def device_state_attributes(self): + """Return the state attributes of the scene.""" + return {'vera_scene_id': self.vera_scene.vera_scene_id} + + @property + def should_poll(self): + """Return that polling is not necessary.""" + return False diff --git a/homeassistant/components/sensor/ads.py b/homeassistant/components/sensor/ads.py new file mode 100644 index 00000000000..725cbb555f1 --- /dev/null +++ b/homeassistant/components/sensor/ads.py @@ -0,0 +1,103 @@ +""" +Support for ADS sensors. + +For more details about this platform, please refer to the documentation. +https://home-assistant.io/components/sensor.ads/ + +""" +import asyncio +import logging +import voluptuous as vol +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME, CONF_UNIT_OF_MEASUREMENT +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv +from homeassistant.components import ads +from homeassistant.components.ads import CONF_ADS_VAR, CONF_ADS_TYPE, \ + CONF_ADS_FACTOR + + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'ADS sensor' +DEPENDENCIES = ['ads'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ADS_VAR): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UNIT_OF_MEASUREMENT, default=''): cv.string, + vol.Optional(CONF_ADS_TYPE, default=ads.ADSTYPE_INT): vol.In( + [ads.ADSTYPE_INT, ads.ADSTYPE_UINT, ads.ADSTYPE_BYTE] + ), + vol.Optional(CONF_ADS_FACTOR): cv.positive_int, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up an ADS sensor device.""" + ads_hub = hass.data.get(ads.DATA_ADS) + + ads_var = config.get(CONF_ADS_VAR) + ads_type = config.get(CONF_ADS_TYPE) + name = config.get(CONF_NAME) + unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) + factor = config.get(CONF_ADS_FACTOR) + + entity = AdsSensor(ads_hub, ads_var, ads_type, name, + unit_of_measurement, factor) + + add_devices([entity]) + + +class AdsSensor(Entity): + """Representation of an ADS sensor entity.""" + + def __init__(self, ads_hub, ads_var, ads_type, name, unit_of_measurement, + factor): + """Initialize AdsSensor entity.""" + self._ads_hub = ads_hub + self._name = name + self._value = None + self._unit_of_measurement = unit_of_measurement + self.ads_var = ads_var + self.ads_type = ads_type + self.factor = factor + + @asyncio.coroutine + def async_added_to_hass(self): + """Register device notification.""" + def update(name, value): + """Handle device notifications.""" + _LOGGER.debug('Variable %s changed its value to %d', name, value) + + # if factor is set use it otherwise not + if self.factor is None: + self._value = value + else: + self._value = value / self.factor + self.schedule_update_ha_state() + + self.hass.async_add_job( + self._ads_hub.add_device_notification, + self.ads_var, self._ads_hub.ADS_TYPEMAP[self.ads_type], update + ) + + @property + def name(self): + """Return the name of the entity.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + return self._value + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit_of_measurement + + @property + def should_poll(self): + """Return False because entity pushes its state to HA.""" + return False diff --git a/homeassistant/components/sensor/alarmdecoder.py b/homeassistant/components/sensor/alarmdecoder.py index 6b026298db0..ce709eee94c 100644 --- a/homeassistant/components/sensor/alarmdecoder.py +++ b/homeassistant/components/sensor/alarmdecoder.py @@ -7,25 +7,21 @@ https://home-assistant.io/components/sensor.alarmdecoder/ import asyncio import logging -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.components.alarmdecoder import (SIGNAL_PANEL_MESSAGE) -from homeassistant.const import (STATE_UNKNOWN) _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['alarmdecoder'] -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Set up for AlarmDecoder sensor devices.""" - _LOGGER.debug("AlarmDecoderSensor: async_setup_platform") + _LOGGER.debug("AlarmDecoderSensor: setup_platform") device = AlarmDecoderSensor(hass) - async_add_devices([device]) + add_devices([device]) class AlarmDecoderSensor(Entity): @@ -34,23 +30,20 @@ class AlarmDecoderSensor(Entity): def __init__(self, hass): """Initialize the alarm panel.""" self._display = "" - self._state = STATE_UNKNOWN + self._state = None self._icon = 'mdi:alarm-check' self._name = 'Alarm Panel Display' - _LOGGER.debug("Setting up panel") - @asyncio.coroutine def async_added_to_hass(self): """Register callbacks.""" - async_dispatcher_connect( - self.hass, SIGNAL_PANEL_MESSAGE, self._message_callback) + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_PANEL_MESSAGE, self._message_callback) - @callback def _message_callback(self, message): if self._display != message.text: self._display = message.text - self.async_schedule_update_ha_state() + self.schedule_update_ha_state() @property def icon(self): diff --git a/homeassistant/components/sensor/alpha_vantage.py b/homeassistant/components/sensor/alpha_vantage.py new file mode 100644 index 00000000000..88ead3301b6 --- /dev/null +++ b/homeassistant/components/sensor/alpha_vantage.py @@ -0,0 +1,110 @@ +""" +Stock market information from Alpha Vantage. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.alpha_vantage/ +""" +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +REQUIREMENTS = ['alpha_vantage==1.3.6'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_CLOSE = 'close' +ATTR_HIGH = 'high' +ATTR_LOW = 'low' +ATTR_VOLUME = 'volume' + +CONF_ATTRIBUTION = "Stock market information provided by Alpha Vantage." +CONF_SYMBOLS = 'symbols' + +DEFAULT_SYMBOL = 'GOOGL' + +ICON = 'mdi:currency-usd' + +SCAN_INTERVAL = timedelta(minutes=5) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_SYMBOLS, default=[DEFAULT_SYMBOL]): + vol.All(cv.ensure_list, [cv.string]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Alpha Vantage sensor.""" + from alpha_vantage.timeseries import TimeSeries + + api_key = config.get(CONF_API_KEY) + symbols = config.get(CONF_SYMBOLS) + + timeseries = TimeSeries(key=api_key) + + dev = [] + for symbol in symbols: + try: + timeseries.get_intraday(symbol) + except ValueError: + _LOGGER.error( + "API Key is not valid or symbol '%s' not known", symbol) + return + dev.append(AlphaVantageSensor(timeseries, symbol)) + + add_devices(dev, True) + + +class AlphaVantageSensor(Entity): + """Representation of a Alpha Vantage sensor.""" + + def __init__(self, timeseries, symbol): + """Initialize the sensor.""" + self._name = symbol + self._timeseries = timeseries + self._symbol = symbol + self.values = None + self._unit_of_measurement = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._symbol + + @property + def state(self): + """Return the state of the sensor.""" + return self.values['1. open'] + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self.values is not None: + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + ATTR_CLOSE: self.values['4. close'], + ATTR_HIGH: self.values['2. high'], + ATTR_LOW: self.values['3. low'], + ATTR_VOLUME: self.values['5. volume'], + } + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return ICON + + def update(self): + """Get the latest data and updates the states.""" + all_values, _ = self._timeseries.get_intraday(self._symbol) + self.values = next(iter(all_values.values())) diff --git a/homeassistant/components/sensor/canary.py b/homeassistant/components/sensor/canary.py new file mode 100644 index 00000000000..b0d2c27ae5d --- /dev/null +++ b/homeassistant/components/sensor/canary.py @@ -0,0 +1,85 @@ +""" +Support for Canary sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.canary/ +""" +from homeassistant.components.canary import DATA_CANARY +from homeassistant.const import TEMP_FAHRENHEIT, TEMP_CELSIUS +from homeassistant.helpers.entity import Entity + +DEPENDENCIES = ['canary'] + +SENSOR_VALUE_PRECISION = 1 + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Canary sensors.""" + data = hass.data[DATA_CANARY] + devices = [] + + from canary.api import SensorType + for location in data.locations: + for device in location.devices: + if device.is_online: + for sensor_type in SensorType: + devices.append(CanarySensor(data, sensor_type, location, + device)) + + add_devices(devices, True) + + +class CanarySensor(Entity): + """Representation of a Canary sensor.""" + + def __init__(self, data, sensor_type, location, device): + """Initialize the sensor.""" + self._data = data + self._sensor_type = sensor_type + self._device_id = device.device_id + self._is_celsius = location.is_celsius + self._sensor_value = None + + sensor_type_name = sensor_type.value.replace("_", " ").title() + self._name = '{} {} {}'.format(location.name, + device.name, + sensor_type_name) + + @property + def name(self): + """Return the name of the Canary sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._sensor_value + + @property + def unique_id(self): + """Return the unique ID of this sensor.""" + return "sensor_canary_{}_{}".format(self._device_id, + self._sensor_type.value) + + @property + def unit_of_measurement(self): + """Return the unit of measurement this sensor expresses itself in.""" + from canary.api import SensorType + if self._sensor_type == SensorType.TEMPERATURE: + return TEMP_CELSIUS if self._is_celsius else TEMP_FAHRENHEIT + elif self._sensor_type == SensorType.HUMIDITY: + return "%" + elif self._sensor_type == SensorType.AIR_QUALITY: + return "" + return None + + def update(self): + """Get the latest state of the sensor.""" + self._data.update() + + readings = self._data.get_readings(self._device_id) + value = next(( + reading.value for reading in readings + if reading.sensor_type == self._sensor_type), None) + if value is not None: + self._sensor_value = round(float(value), SENSOR_VALUE_PRECISION) diff --git a/homeassistant/components/sensor/efergy.py b/homeassistant/components/sensor/efergy.py index 0f24905c5f5..c14a33dce01 100644 --- a/homeassistant/components/sensor/efergy.py +++ b/homeassistant/components/sensor/efergy.py @@ -31,6 +31,7 @@ CONF_COST = 'cost' CONF_CURRENT_VALUES = 'current_values' DEFAULT_PERIOD = 'year' +DEFAULT_UTC_OFFSET = '0' SENSOR_TYPES = { CONF_INSTANT: ['Energy Usage', 'W'], @@ -50,7 +51,7 @@ SENSORS_SCHEMA = vol.Schema({ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_APPTOKEN): cv.string, - vol.Optional(CONF_UTC_OFFSET): cv.string, + vol.Optional(CONF_UTC_OFFSET, default=DEFAULT_UTC_OFFSET): cv.string, vol.Required(CONF_MONITORED_VARIABLES): [SENSORS_SCHEMA] }) diff --git a/homeassistant/components/sensor/eliqonline.py b/homeassistant/components/sensor/eliqonline.py index dc879fe0d3e..3e736ed719f 100644 --- a/homeassistant/components/sensor/eliqonline.py +++ b/homeassistant/components/sensor/eliqonline.py @@ -30,7 +30,7 @@ UNIT_OF_MEASUREMENT = 'W' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_ACCESS_TOKEN): cv.string, - vol.Optional(CONF_CHANNEL_ID): cv.positive_int, + vol.Required(CONF_CHANNEL_ID): cv.positive_int, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) diff --git a/homeassistant/components/sensor/gearbest.py b/homeassistant/components/sensor/gearbest.py new file mode 100755 index 00000000000..2bc7e5b3b3a --- /dev/null +++ b/homeassistant/components/sensor/gearbest.py @@ -0,0 +1,127 @@ +""" +Parse prices of a item from gearbest. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.gearbest/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +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 +from homeassistant.helpers.event import track_time_interval +from homeassistant.const import (CONF_NAME, CONF_ID, CONF_URL, CONF_CURRENCY) + +REQUIREMENTS = ['gearbest_parser==1.0.5'] +_LOGGER = logging.getLogger(__name__) + +CONF_ITEMS = 'items' + +ICON = 'mdi:coin' +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=2*60*60) # 2h +MIN_TIME_BETWEEN_CURRENCY_UPDATES = timedelta(seconds=12*60*60) # 12h + + +_ITEM_SCHEMA = vol.All( + vol.Schema({ + vol.Exclusive(CONF_URL, 'XOR'): cv.string, + vol.Exclusive(CONF_ID, 'XOR'): cv.string, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_CURRENCY): cv.string + }), cv.has_at_least_one_key(CONF_URL, CONF_ID) +) + +_ITEMS_SCHEMA = vol.Schema([_ITEM_SCHEMA]) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ITEMS): _ITEMS_SCHEMA, + vol.Required(CONF_CURRENCY): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Gearbest sensor.""" + from gearbest_parser import CurrencyConverter + currency = config.get(CONF_CURRENCY) + + sensors = [] + items = config.get(CONF_ITEMS) + + converter = CurrencyConverter() + converter.update() + + for item in items: + try: + sensors.append(GearbestSensor(converter, item, currency)) + except ValueError as exc: + _LOGGER.error(exc) + + def currency_update(event_time): + """Update currency list.""" + converter.update() + + track_time_interval(hass, + currency_update, + MIN_TIME_BETWEEN_CURRENCY_UPDATES) + + add_devices(sensors, True) + + +class GearbestSensor(Entity): + """Implementation of the sensor.""" + + def __init__(self, converter, item, currency): + """Initialize the sensor.""" + from gearbest_parser import GearbestParser + + self._name = item.get(CONF_NAME) + self._parser = GearbestParser() + self._parser.set_currency_converter(converter) + self._item = self._parser.load(item.get(CONF_ID), + item.get(CONF_URL), + item.get(CONF_CURRENCY, currency)) + if self._item is None: + raise ValueError("id and url could not be resolved") + + @property + def name(self): + """Return the name of the item.""" + return self._name if self._name is not None else self._item.name + + @property + def icon(self): + """Return the icon for the frontend.""" + return ICON + + @property + def state(self): + """Return the price of the selected product.""" + return self._item.price + + @property + def unit_of_measurement(self): + """Return the currency.""" + return self._item.currency + + @property + def entity_picture(self): + """Return the image.""" + return self._item.image + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attrs = {'name': self._item.name, + 'description': self._item.description, + 'currency': self._item.currency, + 'url': self._item.url} + return attrs + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest price from gearbest and updates the state.""" + self._item.update() diff --git a/homeassistant/components/sensor/isy994.py b/homeassistant/components/sensor/isy994.py index f64fa6191e2..e961c63a1b5 100644 --- a/homeassistant/components/sensor/isy994.py +++ b/homeassistant/components/sensor/isy994.py @@ -282,6 +282,9 @@ class ISYSensorDevice(isy.ISYDevice): @property def state(self) -> str: """Get the state of the ISY994 sensor device.""" + if self.is_unknown(): + return None + if len(self._node.uom) == 1: if self._node.uom[0] in UOM_TO_STATES: states = UOM_TO_STATES.get(self._node.uom[0]) diff --git a/homeassistant/components/sensor/luftdaten.py b/homeassistant/components/sensor/luftdaten.py index e317e89030f..8c5fcc15ec2 100644 --- a/homeassistant/components/sensor/luftdaten.py +++ b/homeassistant/components/sensor/luftdaten.py @@ -5,85 +5,94 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.luftdaten/ """ import asyncio -import json -import logging from datetime import timedelta +import logging -import requests import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_NAME, CONF_RESOURCE, CONF_VERIFY_SSL, CONF_MONITORED_CONDITIONS, - TEMP_CELSIUS) -from homeassistant.helpers.entity import Entity + ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS, CONF_NAME, TEMP_CELSIUS) +from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +REQUIREMENTS = ['luftdaten==0.1.1'] _LOGGER = logging.getLogger(__name__) +ATTR_SENSOR_ID = 'sensor_id' + +CONF_ATTRIBUTION = "Data provided by luftdaten.info" + + VOLUME_MICROGRAMS_PER_CUBIC_METER = 'µg/m3' SENSOR_TEMPERATURE = 'temperature' SENSOR_HUMIDITY = 'humidity' SENSOR_PM10 = 'P1' SENSOR_PM2_5 = 'P2' +SENSOR_PRESSURE = 'pressure' SENSOR_TYPES = { SENSOR_TEMPERATURE: ['Temperature', TEMP_CELSIUS], SENSOR_HUMIDITY: ['Humidity', '%'], + SENSOR_PRESSURE: ['Pressure', 'Pa'], SENSOR_PM10: ['PM10', VOLUME_MICROGRAMS_PER_CUBIC_METER], SENSOR_PM2_5: ['PM2.5', VOLUME_MICROGRAMS_PER_CUBIC_METER] } -DEFAULT_NAME = 'Luftdaten Sensor' -DEFAULT_RESOURCE = 'https://api.luftdaten.info/v1/sensor/' -DEFAULT_VERIFY_SSL = True +DEFAULT_NAME = 'Luftdaten' CONF_SENSORID = 'sensorid' -SCAN_INTERVAL = timedelta(minutes=3) +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_SENSORID): cv.positive_int, vol.Required(CONF_MONITORED_CONDITIONS): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_RESOURCE, default=DEFAULT_RESOURCE): cv.string, - vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean }) @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Luftdaten sensor.""" + from luftdaten import Luftdaten + name = config.get(CONF_NAME) - sensorid = config.get(CONF_SENSORID) - verify_ssl = config.get(CONF_VERIFY_SSL) + sensor_id = config.get(CONF_SENSORID) - resource = '{}{}/'.format(config.get(CONF_RESOURCE), sensorid) + session = async_get_clientsession(hass) + luftdaten = LuftdatenData(Luftdaten(sensor_id, hass.loop, session)) - rest_client = LuftdatenData(resource, verify_ssl) - rest_client.update() + yield from luftdaten.async_update() - if rest_client.data is None: - _LOGGER.error("Unable to fetch Luftdaten data") - return False + if luftdaten.data is None: + _LOGGER.error("Sensor is not available: %s", sensor_id) + return devices = [] for variable in config[CONF_MONITORED_CONDITIONS]: - devices.append(LuftdatenSensor(rest_client, name, variable)) + if luftdaten.data.values[variable] is None: + _LOGGER.warning("It might be that sensor %s is not providing " + "measurements for %s", sensor_id, variable) + devices.append(LuftdatenSensor(luftdaten, name, variable, sensor_id)) - async_add_devices(devices, True) + async_add_devices(devices) class LuftdatenSensor(Entity): - """Implementation of a LuftdatenSensor sensor.""" + """Implementation of a Luftdaten sensor.""" - def __init__(self, rest_client, name, sensor_type): - """Initialize the LuftdatenSensor sensor.""" - self.rest_client = rest_client + def __init__(self, luftdaten, name, sensor_type, sensor_id): + """Initialize the Luftdaten sensor.""" + self.luftdaten = luftdaten self._name = name self._state = None + self._sensor_id = sensor_id self.sensor_type = sensor_type self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] @@ -95,48 +104,50 @@ class LuftdatenSensor(Entity): @property def state(self): """Return the state of the device.""" - return self._state + return self.luftdaten.data.values[self.sensor_type] @property def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement - def update(self): - """Get the latest data from REST API and update the state.""" - self.rest_client.update() - value = self.rest_client.data + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self.luftdaten.data.meta is None: + return - if value is None: - self._state = None - else: - parsed_json = json.loads(value) + attr = { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + ATTR_SENSOR_ID: self._sensor_id, + 'lat': self.luftdaten.data.meta['latitude'], + 'long': self.luftdaten.data.meta['longitude'], + } + return attr - log_entries_count = len(parsed_json) - 1 - latest_log_entry = parsed_json[log_entries_count] - sensordata_values = latest_log_entry['sensordatavalues'] - for sensordata_value in sensordata_values: - if sensordata_value['value_type'] == self.sensor_type: - self._state = sensordata_value['value'] + @asyncio.coroutine + def async_update(self): + """Get the latest data from luftdaten.info and update the state.""" + try: + yield from self.luftdaten.async_update() + except TypeError: + pass class LuftdatenData(object): """Class for handling the data retrieval.""" - def __init__(self, resource, verify_ssl): + def __init__(self, data): """Initialize the data object.""" - self._request = requests.Request('GET', resource).prepare() - self._verify_ssl = verify_ssl - self.data = None + self.data = data + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + @asyncio.coroutine + def async_update(self): + """Get the latest data from luftdaten.info.""" + from luftdaten.exceptions import LuftdatenError - def update(self): - """Get the latest data from Luftdaten service.""" try: - with requests.Session() as sess: - response = sess.send( - self._request, timeout=10, verify=self._verify_ssl) - - self.data = response.text - except requests.exceptions.RequestException: - _LOGGER.error("Error fetching data: %s", self._request) - self.data = None + yield from self.data.async_get_data() + except LuftdatenError: + _LOGGER.error("Unable to retrieve data from luftdaten.info") diff --git a/homeassistant/components/sensor/miflora.py b/homeassistant/components/sensor/miflora.py index 063c4e8068e..349e55abb5d 100644 --- a/homeassistant/components/sensor/miflora.py +++ b/homeassistant/components/sensor/miflora.py @@ -12,7 +12,8 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv from homeassistant.const import ( - CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_MAC) + CONF_FORCE_UPDATE, CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_MAC +) REQUIREMENTS = ['miflora==0.1.16'] @@ -20,7 +21,6 @@ _LOGGER = logging.getLogger(__name__) CONF_ADAPTER = 'adapter' CONF_CACHE = 'cache_value' -CONF_FORCE_UPDATE = 'force_update' CONF_MEDIAN = 'median' CONF_RETRIES = 'retries' CONF_TIMEOUT = 'timeout' diff --git a/homeassistant/components/sensor/mqtt.py b/homeassistant/components/sensor/mqtt.py index 70b1294c13f..bf7de94b5d7 100644 --- a/homeassistant/components/sensor/mqtt.py +++ b/homeassistant/components/sensor/mqtt.py @@ -13,7 +13,8 @@ import voluptuous as vol from homeassistant.core import callback from homeassistant.components.mqtt import CONF_STATE_TOPIC, CONF_QOS from homeassistant.const import ( - CONF_NAME, CONF_VALUE_TEMPLATE, STATE_UNKNOWN, CONF_UNIT_OF_MEASUREMENT) + CONF_FORCE_UPDATE, CONF_NAME, CONF_VALUE_TEMPLATE, STATE_UNKNOWN, + CONF_UNIT_OF_MEASUREMENT) from homeassistant.helpers.entity import Entity import homeassistant.components.mqtt as mqtt import homeassistant.helpers.config_validation as cv @@ -22,7 +23,6 @@ from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) -CONF_FORCE_UPDATE = 'force_update' CONF_EXPIRE_AFTER = 'expire_after' DEFAULT_NAME = 'MQTT Sensor' diff --git a/homeassistant/components/sensor/octoprint.py b/homeassistant/components/sensor/octoprint.py index 85b388a1919..71b72b0a671 100644 --- a/homeassistant/components/sensor/octoprint.py +++ b/homeassistant/components/sensor/octoprint.py @@ -45,7 +45,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): name = config.get(CONF_NAME) monitored_conditions = config.get(CONF_MONITORED_CONDITIONS) tools = octoprint_api.get_tools() - _LOGGER.error(str(tools)) if "Temperatures" in monitored_conditions: if not tools: diff --git a/homeassistant/components/sensor/rest.py b/homeassistant/components/sensor/rest.py index 2ae1c3674ea..19f5a1c271e 100644 --- a/homeassistant/components/sensor/rest.py +++ b/homeassistant/components/sensor/rest.py @@ -5,6 +5,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.rest/ """ import logging +import json import voluptuous as vol import requests @@ -12,10 +13,11 @@ from requests.auth import HTTPBasicAuth, HTTPDigestAuth 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, CONF_VERIFY_SSL, CONF_USERNAME, - CONF_PASSWORD, CONF_AUTHENTICATION, HTTP_BASIC_AUTHENTICATION, - HTTP_DIGEST_AUTHENTICATION, CONF_HEADERS) + CONF_AUTHENTICATION, CONF_FORCE_UPDATE, CONF_HEADERS, CONF_NAME, + CONF_METHOD, CONF_PASSWORD, CONF_PAYLOAD, CONF_RESOURCE, + CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, + CONF_VALUE_TEMPLATE, CONF_VERIFY_SSL, + HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, STATE_UNKNOWN) from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv @@ -24,7 +26,9 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_METHOD = 'GET' DEFAULT_NAME = 'REST Sensor' DEFAULT_VERIFY_SSL = True +DEFAULT_FORCE_UPDATE = False +CONF_JSON_ATTRS = 'json_attributes' METHODS = ['POST', 'GET'] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -32,6 +36,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_AUTHENTICATION): vol.In([HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]), vol.Optional(CONF_HEADERS): {cv.string: cv.string}, + vol.Optional(CONF_JSON_ATTRS, default=[]): cv.ensure_list_csv, vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): vol.In(METHODS), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PASSWORD): cv.string, @@ -40,6 +45,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_USERNAME): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, + vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, }) @@ -55,6 +61,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): headers = config.get(CONF_HEADERS) unit = config.get(CONF_UNIT_OF_MEASUREMENT) value_template = config.get(CONF_VALUE_TEMPLATE) + json_attrs = config.get(CONF_JSON_ATTRS) + force_update = config.get(CONF_FORCE_UPDATE) + if value_template is not None: value_template.hass = hass @@ -68,13 +77,16 @@ def setup_platform(hass, config, add_devices, discovery_info=None): rest = RestData(method, resource, auth, headers, payload, verify_ssl) rest.update() - add_devices([RestSensor(hass, rest, name, unit, value_template)], True) + add_devices([RestSensor( + hass, rest, name, unit, value_template, json_attrs, force_update + )], True) class RestSensor(Entity): """Implementation of a REST sensor.""" - def __init__(self, hass, rest, name, unit_of_measurement, value_template): + def __init__(self, hass, rest, name, unit_of_measurement, + value_template, json_attrs, force_update): """Initialize the REST sensor.""" self._hass = hass self.rest = rest @@ -82,6 +94,9 @@ class RestSensor(Entity): self._state = STATE_UNKNOWN self._unit_of_measurement = unit_of_measurement self._value_template = value_template + self._json_attrs = json_attrs + self._attributes = None + self._force_update = force_update @property def name(self): @@ -103,11 +118,30 @@ class RestSensor(Entity): """Return the state of the device.""" return self._state + @property + def force_update(self): + """Force update.""" + return self._force_update + def update(self): """Get the latest data from REST API and update the state.""" self.rest.update() value = self.rest.data + if self._json_attrs: + self._attributes = {} + try: + json_dict = json.loads(value) + if isinstance(json_dict, dict): + attrs = {k: json_dict[k] for k in self._json_attrs + if k in json_dict} + self._attributes = attrs + else: + _LOGGER.warning("JSON result was not a dictionary") + except ValueError: + _LOGGER.warning("REST result could not be parsed as JSON") + _LOGGER.debug("Erroneous JSON: %s", value) + if value is None: value = STATE_UNKNOWN elif self._value_template is not None: @@ -116,6 +150,11 @@ class RestSensor(Entity): self._state = value + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._attributes + class RestData(object): """Class for handling the data retrieval.""" diff --git a/homeassistant/components/sensor/ripple.py b/homeassistant/components/sensor/ripple.py index 6f92a1a3390..d516706fdc0 100644 --- a/homeassistant/components/sensor/ripple.py +++ b/homeassistant/components/sensor/ripple.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import (CONF_NAME, ATTR_ATTRIBUTION) from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['python-ripple-api==0.0.2'] +REQUIREMENTS = ['python-ripple-api==0.0.3'] CONF_ADDRESS = 'address' CONF_ATTRIBUTION = "Data provided by ripple.com" @@ -71,4 +71,6 @@ class RippleSensor(Entity): def update(self): """Get the latest state of the sensor.""" from pyripple import get_balance - self._state = get_balance(self.address) + balance = get_balance(self.address) + if balance is not None: + self._state = balance diff --git a/homeassistant/components/sensor/shodan.py b/homeassistant/components/sensor/shodan.py index 3d86d940f4d..720158e1029 100644 --- a/homeassistant/components/sensor/shodan.py +++ b/homeassistant/components/sensor/shodan.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY, CONF_NAME from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['shodan==1.7.5'] +REQUIREMENTS = ['shodan==1.7.7'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/systemmonitor.py b/homeassistant/components/sensor/systemmonitor.py index 324d3029c99..8e6f7b404fd 100755 --- a/homeassistant/components/sensor/systemmonitor.py +++ b/homeassistant/components/sensor/systemmonitor.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -REQUIREMENTS = ['psutil==5.4.1'] +REQUIREMENTS = ['psutil==5.4.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/tesla.py b/homeassistant/components/sensor/tesla.py index 824fec41580..3f36a1128d6 100644 --- a/homeassistant/components/sensor/tesla.py +++ b/homeassistant/components/sensor/tesla.py @@ -39,7 +39,7 @@ class TeslaSensor(TeslaDevice, Entity): def __init__(self, tesla_device, controller, sensor_type=None): """Initialisation of the sensor.""" self.current_value = None - self._temperature_units = None + self._unit = None self.last_changed_time = None self.type = sensor_type super().__init__(tesla_device, controller) @@ -59,7 +59,7 @@ class TeslaSensor(TeslaDevice, Entity): @property def unit_of_measurement(self): """Return the unit_of_measurement of the device.""" - return self._temperature_units + return self._unit def update(self): """Update the state from the sensor.""" @@ -74,8 +74,9 @@ class TeslaSensor(TeslaDevice, Entity): tesla_temp_units = self.tesla_device.measurement if tesla_temp_units == 'F': - self._temperature_units = TEMP_FAHRENHEIT + self._unit = TEMP_FAHRENHEIT else: - self._temperature_units = TEMP_CELSIUS + self._unit = TEMP_CELSIUS else: self.current_value = self.tesla_device.battery_level() + self._unit = "%" diff --git a/homeassistant/components/sensor/vera.py b/homeassistant/components/sensor/vera.py index f901bd27dca..c81c208e33e 100644 --- a/homeassistant/components/sensor/vera.py +++ b/homeassistant/components/sensor/vera.py @@ -25,8 +25,8 @@ SCAN_INTERVAL = timedelta(seconds=5) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Vera controller devices.""" add_devices( - VeraSensor(device, VERA_CONTROLLER) - for device in VERA_DEVICES['sensor']) + VeraSensor(device, hass.data[VERA_CONTROLLER]) + for device in hass.data[VERA_DEVICES]['sensor']) class VeraSensor(VeraDevice, Entity): diff --git a/homeassistant/components/sensor/volvooncall.py b/homeassistant/components/sensor/volvooncall.py index 622261941d6..32b228ca1f9 100644 --- a/homeassistant/components/sensor/volvooncall.py +++ b/homeassistant/components/sensor/volvooncall.py @@ -6,8 +6,10 @@ https://home-assistant.io/components/sensor.volvooncall/ """ import logging +from math import floor -from homeassistant.components.volvooncall import VolvoEntity, RESOURCES +from homeassistant.components.volvooncall import ( + VolvoEntity, RESOURCES, CONF_SCANDINAVIAN_MILES) _LOGGER = logging.getLogger(__name__) @@ -26,14 +28,37 @@ class VolvoSensor(VolvoEntity): def state(self): """Return the state of the sensor.""" val = getattr(self.vehicle, self._attribute) + + if val is None: + return val + if self._attribute == 'odometer': - return round(val / 1000) # km - return val + val /= 1000 # m -> km + + if 'mil' in self.unit_of_measurement: + val /= 10 # km -> mil + + if self._attribute == 'average_fuel_consumption': + val /= 10 # L/1000km -> L/100km + if 'mil' in self.unit_of_measurement: + return round(val, 2) + else: + return round(val, 1) + elif self._attribute == 'distance_to_empty': + return int(floor(val)) + else: + return int(round(val)) @property def unit_of_measurement(self): """Return the unit of measurement.""" - return RESOURCES[self._attribute][3] + unit = RESOURCES[self._attribute][3] + if self._state.config[CONF_SCANDINAVIAN_MILES] and 'km' in unit: + if self._attribute == 'average_fuel_consumption': + return 'L/mil' + else: + return unit.replace('km', 'mil') + return unit @property def icon(self): diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index c4e460fdb66..90a1bbbc613 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -32,55 +32,6 @@ foursquare: description: Vertical accuracy of the user's location, in meters. example: 1 -homematic: - virtualkey: - description: Press a virtual key from CCU/Homegear or simulate keypress. - fields: - address: - description: Address of homematic device or BidCoS-RF for virtual remote. - example: BidCoS-RF - channel: - description: Channel for calling a keypress. - example: 1 - param: - description: Event to send i.e. PRESS_LONG, PRESS_SHORT. - example: PRESS_LONG - proxy: - description: (Optional) for set a hosts value. - example: Hosts name from config - set_var_value: - description: Set the name of a node. - fields: - entity_id: - description: Name(s) of homematic central to set value. - example: 'homematic.ccu2' - name: - description: Name of the variable to set. - example: 'testvariable' - value: - description: New value - example: 1 - set_dev_value: - description: Set a device property on RPC XML interface. - fields: - address: - description: Address of homematic device or BidCoS-RF for virtual remote - example: BidCoS-RF - channel: - description: Channel for calling a keypress - example: 1 - param: - description: Event to send i.e. PRESS_LONG, PRESS_SHORT - example: PRESS_LONG - proxy: - description: (Optional) for set a hosts value - example: Hosts name from config - value: - description: New value - example: 1 - reconnect: - description: Reconnect to all Homematic Hubs. - microsoft_face: create_group: description: Create a new person group. @@ -437,7 +388,7 @@ input_text: set_value: description: Set the value of an input text entity. fields: - entity_id: + entity_id: description: Entity id of the input text to set the new value. example: 'input_text.text1' value: @@ -448,7 +399,7 @@ input_number: set_value: description: Set the value of an input number entity. fields: - entity_id: + entity_id: description: Entity id of the input number to set the new value. example: 'input_number.threshold' value: @@ -457,13 +408,13 @@ input_number: increment: description: Increment the value of an input number entity by its stepping. fields: - entity_id: + entity_id: description: Entity id of the input number the should be incremented. example: 'input_number.threshold' decrement: description: Decrement the value of an input number entity by its stepping. fields: - entity_id: + entity_id: description: Entity id of the input number the should be decremented. example: 'input_number.threshold' diff --git a/homeassistant/components/snips.py b/homeassistant/components/snips.py index 1f64f78e9c8..a302f25bd00 100644 --- a/homeassistant/components/snips.py +++ b/homeassistant/components/snips.py @@ -15,7 +15,7 @@ DEPENDENCIES = ['mqtt'] CONF_INTENTS = 'intents' CONF_ACTION = 'action' -INTENT_TOPIC = 'hermes/nlu/intentParsed' +INTENT_TOPIC = 'hermes/intent/#' _LOGGER = logging.getLogger(__name__) @@ -32,7 +32,8 @@ INTENT_SCHEMA = vol.Schema({ vol.Required('slotName'): str, vol.Required('value'): { vol.Required('kind'): str, - vol.Required('value'): cv.match_all + vol.Optional('value'): cv.match_all, + vol.Optional('rawValue'): cv.match_all } }] }, extra=vol.ALLOW_EXTRA) @@ -59,8 +60,12 @@ def async_setup(hass, config): return intent_type = request['intent']['intentName'].split('__')[-1] - slots = {slot['slotName']: {'value': slot['value']['value']} - for slot in request.get('slots', [])} + slots = {} + for slot in request.get('slots', []): + if 'value' in slot['value']: + slots[slot['slotName']] = {'value': slot['value']['value']} + else: + slots[slot['slotName']] = {'value': slot['rawValue']} try: yield from intent.async_handle( diff --git a/homeassistant/components/switch/ads.py b/homeassistant/components/switch/ads.py new file mode 100644 index 00000000000..f4abf2391e2 --- /dev/null +++ b/homeassistant/components/switch/ads.py @@ -0,0 +1,85 @@ +""" +Support for ADS switch platform. + +For more details about this platform, please refer to the documentation. +https://home-assistant.io/components/switch.ads/ + +""" +import asyncio +import logging +import voluptuous as vol +from homeassistant.components.switch import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME +from homeassistant.components.ads import DATA_ADS, CONF_ADS_VAR +from homeassistant.helpers.entity import ToggleEntity +import homeassistant.helpers.config_validation as cv + + +_LOGGER = logging.getLogger(__name__) +DEPENDENCIES = ['ads'] +DEFAULT_NAME = 'ADS Switch' +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ADS_VAR): cv.string, + vol.Optional(CONF_NAME): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up switch platform for ADS.""" + ads_hub = hass.data.get(DATA_ADS) + + name = config.get(CONF_NAME) + ads_var = config.get(CONF_ADS_VAR) + + add_devices([AdsSwitch(ads_hub, name, ads_var)], True) + + +class AdsSwitch(ToggleEntity): + """Representation of an Ads switch device.""" + + def __init__(self, ads_hub, name, ads_var): + """Initialize the AdsSwitch entity.""" + self._ads_hub = ads_hub + self._on_state = False + self._name = name + self.ads_var = ads_var + + @asyncio.coroutine + def async_added_to_hass(self): + """Register device notification.""" + def update(name, value): + """Handle device notification.""" + _LOGGER.debug('Variable %s changed its value to %d', + name, value) + self._on_state = value + self.schedule_update_ha_state() + + self.hass.async_add_job( + self._ads_hub.add_device_notification, + self.ads_var, self._ads_hub.PLCTYPE_BOOL, update + ) + + @property + def is_on(self): + """Return if the switch is turned on.""" + return self._on_state + + @property + def name(self): + """Return the name of the entity.""" + return self._name + + @property + def should_poll(self): + """Return False because entity pushes its state to HA.""" + return False + + def turn_on(self, **kwargs): + """Turn the switch on.""" + self._ads_hub.write_by_name(self.ads_var, True, + self._ads_hub.PLCTYPE_BOOL) + + def turn_off(self, **kwargs): + """Turn the switch off.""" + self._ads_hub.write_by_name(self.ads_var, False, + self._ads_hub.PLCTYPE_BOOL) diff --git a/homeassistant/components/switch/isy994.py b/homeassistant/components/switch/isy994.py index b930bedc2c7..0f1ec62eaee 100644 --- a/homeassistant/components/switch/isy994.py +++ b/homeassistant/components/switch/isy994.py @@ -69,7 +69,10 @@ class ISYSwitchDevice(isy.ISYDevice, SwitchDevice): @property def state(self) -> str: """Get the state of the ISY994 device.""" - return VALUE_TO_STATE.get(bool(self.value), STATE_UNKNOWN) + if self.is_unknown(): + return None + else: + return VALUE_TO_STATE.get(bool(self.value), STATE_UNKNOWN) def turn_off(self, **kwargs) -> None: """Send the turn on command to the ISY994 switch.""" diff --git a/homeassistant/components/switch/mochad.py b/homeassistant/components/switch/mochad.py index a67b27a6a91..da8f96dc1f0 100644 --- a/homeassistant/components/switch/mochad.py +++ b/homeassistant/components/switch/mochad.py @@ -60,18 +60,21 @@ class MochadSwitch(SwitchDevice): def turn_on(self, **kwargs): """Turn the switch on.""" self._state = True - self.device.send_cmd('on') - self._controller.read_data() + with mochad.REQ_LOCK: + self.device.send_cmd('on') + self._controller.read_data() def turn_off(self, **kwargs): """Turn the switch off.""" self._state = False - self.device.send_cmd('off') - self._controller.read_data() + with mochad.REQ_LOCK: + self.device.send_cmd('off') + self._controller.read_data() def _get_device_status(self): """Get the status of the switch from mochad.""" - status = self.device.get_status().rstrip() + with mochad.REQ_LOCK: + status = self.device.get_status().rstrip() return status == 'on' @property diff --git a/homeassistant/components/switch/tplink.py b/homeassistant/components/switch/tplink.py index 8fa6493862c..0772cc9277c 100644 --- a/homeassistant/components/switch/tplink.py +++ b/homeassistant/components/switch/tplink.py @@ -5,7 +5,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.tplink/ """ import logging - import time import voluptuous as vol @@ -23,9 +22,12 @@ ATTR_TOTAL_CONSUMPTION = 'total_consumption' ATTR_DAILY_CONSUMPTION = 'daily_consumption' ATTR_CURRENT = 'current' +CONF_LEDS = 'enable_leds' + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_LEDS, default=True): cv.boolean, }) @@ -35,18 +37,21 @@ def setup_platform(hass, config, add_devices, discovery_info=None): from pyHS100 import SmartPlug host = config.get(CONF_HOST) name = config.get(CONF_NAME) + leds_on = config.get(CONF_LEDS) - add_devices([SmartPlugSwitch(SmartPlug(host), name)], True) + add_devices([SmartPlugSwitch(SmartPlug(host), name, leds_on)], True) class SmartPlugSwitch(SwitchDevice): """Representation of a TPLink Smart Plug switch.""" - def __init__(self, smartplug, name): + def __init__(self, smartplug, name, leds_on): """Initialize the switch.""" self.smartplug = smartplug self._name = name + self._leds_on = leds_on self._state = None + self._available = True # Set up emeter cache self._emeter_params = {} @@ -55,6 +60,11 @@ class SmartPlugSwitch(SwitchDevice): """Return the name of the Smart Plug, if any.""" return self._name + @property + def available(self) -> bool: + """Return if switch is available.""" + return self._available + @property def is_on(self): """Return true if switch is on.""" @@ -77,12 +87,15 @@ class SmartPlugSwitch(SwitchDevice): """Update the TP-Link switch's state.""" from pyHS100 import SmartDeviceException try: + self._available = True self._state = self.smartplug.state == \ self.smartplug.SWITCH_STATE_ON if self._name is None: self._name = self.smartplug.alias + self.smartplug.led = self._leds_on + if self.smartplug.has_emeter: emeter_readings = self.smartplug.get_emeter_realtime() @@ -100,8 +113,9 @@ class SmartPlugSwitch(SwitchDevice): self._emeter_params[ATTR_DAILY_CONSUMPTION] \ = "%.2f kW" % emeter_statics[int(time.strftime("%e"))] except KeyError: - # device returned no daily history + # Device returned no daily history pass except (SmartDeviceException, OSError) as ex: - _LOGGER.warning('Could not read state for %s: %s', self.name, ex) + _LOGGER.warning("Could not read state for %s: %s", self.name, ex) + self._available = False diff --git a/homeassistant/components/switch/vera.py b/homeassistant/components/switch/vera.py index 1e92612b9a9..d7c284e4ccf 100644 --- a/homeassistant/components/switch/vera.py +++ b/homeassistant/components/switch/vera.py @@ -19,8 +19,8 @@ _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Vera switches.""" add_devices( - VeraSwitch(device, VERA_CONTROLLER) for - device in VERA_DEVICES['switch']) + VeraSwitch(device, hass.data[VERA_CONTROLLER]) for + device in hass.data[VERA_DEVICES]['switch']) class VeraSwitch(VeraDevice, SwitchDevice): diff --git a/homeassistant/components/tellstick.py b/homeassistant/components/tellstick.py index bcef0d3fb85..0eef2c4ece1 100644 --- a/homeassistant/components/tellstick.py +++ b/homeassistant/components/tellstick.py @@ -17,7 +17,7 @@ from homeassistant.const import ( from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['tellcore-py==1.1.2', 'tellcore-net==0.3'] +REQUIREMENTS = ['tellcore-py==1.1.2', 'tellcore-net==0.4'] _LOGGER = logging.getLogger(__name__) @@ -67,7 +67,7 @@ def _discover(hass, config, component_name, found_tellcore_devices): def setup(hass, config): """Set up the Tellstick component.""" - from tellcore.constants import TELLSTICK_DIM + from tellcore.constants import (TELLSTICK_DIM, TELLSTICK_UP) from tellcore.telldus import AsyncioCallbackDispatcher from tellcore.telldus import TelldusCore from tellcorenet import TellCoreClient @@ -102,16 +102,22 @@ def setup(hass, config): hass.data[DATA_TELLSTICK] = {device.id: device for device in tellcore_devices} - # Discover the switches - _discover(hass, config, 'switch', - [device.id for device in tellcore_devices - if not device.methods(TELLSTICK_DIM)]) - # Discover the lights _discover(hass, config, 'light', [device.id for device in tellcore_devices if device.methods(TELLSTICK_DIM)]) + # Discover the cover + _discover(hass, config, 'cover', + [device.id for device in tellcore_devices + if device.methods(TELLSTICK_UP)]) + + # Discover the switches + _discover(hass, config, 'switch', + [device.id for device in tellcore_devices + if (not device.methods(TELLSTICK_UP) and + not device.methods(TELLSTICK_DIM))]) + @callback def async_handle_callback(tellcore_id, tellcore_command, tellcore_data, cid): diff --git a/homeassistant/components/vera.py b/homeassistant/components/vera.py index 7418ca812a1..b15c4ddabfd 100644 --- a/homeassistant/components/vera.py +++ b/homeassistant/components/vera.py @@ -19,13 +19,13 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, CONF_LIGHTS, CONF_EXCLUDE) from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pyvera==0.2.38'] +REQUIREMENTS = ['pyvera==0.2.39'] _LOGGER = logging.getLogger(__name__) DOMAIN = 'vera' -VERA_CONTROLLER = None +VERA_CONTROLLER = 'vera_controller' CONF_CONTROLLER = 'vera_controller_url' @@ -34,7 +34,8 @@ VERA_ID_FORMAT = '{}_{}' ATTR_CURRENT_POWER_W = "current_power_w" ATTR_CURRENT_ENERGY_KWH = "current_energy_kwh" -VERA_DEVICES = defaultdict(list) +VERA_DEVICES = 'vera_devices' +VERA_SCENES = 'vera_scenes' VERA_ID_LIST_SCHEMA = vol.Schema([int]) @@ -47,20 +48,20 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) VERA_COMPONENTS = [ - 'binary_sensor', 'sensor', 'light', 'switch', 'lock', 'climate', 'cover' + 'binary_sensor', 'sensor', 'light', 'switch', + 'lock', 'climate', 'cover', 'scene' ] # pylint: disable=unused-argument, too-many-function-args def setup(hass, base_config): """Set up for Vera devices.""" - global VERA_CONTROLLER import pyvera as veraApi def stop_subscription(event): """Shutdown Vera subscriptions and subscription thread on exit.""" _LOGGER.info("Shutting down subscriptions") - VERA_CONTROLLER.stop() + hass.data[VERA_CONTROLLER].stop() config = base_config.get(DOMAIN) @@ -70,11 +71,14 @@ def setup(hass, base_config): exclude_ids = config.get(CONF_EXCLUDE) # Initialize the Vera controller. - VERA_CONTROLLER, _ = veraApi.init_controller(base_url) + controller, _ = veraApi.init_controller(base_url) + hass.data[VERA_CONTROLLER] = controller hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_subscription) try: - all_devices = VERA_CONTROLLER.get_devices() + all_devices = controller.get_devices() + + all_scenes = controller.get_scenes() except RequestException: # There was a network related error connecting to the Vera controller. _LOGGER.exception("Error communicating with Vera API") @@ -84,12 +88,19 @@ def setup(hass, base_config): devices = [device for device in all_devices if device.device_id not in exclude_ids] + vera_devices = defaultdict(list) for device in devices: device_type = map_vera_device(device, light_ids) if device_type is None: continue - VERA_DEVICES[device_type].append(device) + vera_devices[device_type].append(device) + hass.data[VERA_DEVICES] = vera_devices + + vera_scenes = [] + for scene in all_scenes: + vera_scenes.append(scene) + hass.data[VERA_SCENES] = vera_scenes for component in VERA_COMPONENTS: discovery.load_platform(hass, component, DOMAIN, {}, base_config) diff --git a/homeassistant/components/volvooncall.py b/homeassistant/components/volvooncall.py index 4cee6ea2139..dcd4ed518d0 100644 --- a/homeassistant/components/volvooncall.py +++ b/homeassistant/components/volvooncall.py @@ -26,11 +26,13 @@ REQUIREMENTS = ['volvooncall==0.4.0'] _LOGGER = logging.getLogger(__name__) -CONF_UPDATE_INTERVAL = 'update_interval' MIN_UPDATE_INTERVAL = timedelta(minutes=1) DEFAULT_UPDATE_INTERVAL = timedelta(minutes=1) + +CONF_UPDATE_INTERVAL = 'update_interval' CONF_REGION = 'region' CONF_SERVICE_URL = 'service_url' +CONF_SCANDINAVIAN_MILES = 'scandinavian_miles' SIGNAL_VEHICLE_SEEN = '{}.vehicle_seen'.format(DOMAIN) @@ -41,6 +43,8 @@ RESOURCES = {'position': ('device_tracker',), 'fuel_amount': ('sensor', 'Fuel amount', 'mdi:gas-station', 'L'), 'fuel_amount_level': ( 'sensor', 'Fuel level', 'mdi:water-percent', '%'), + 'average_fuel_consumption': ( + 'sensor', 'Fuel consumption', 'mdi:gas-station', 'L/100 km'), 'distance_to_empty': ('sensor', 'Range', 'mdi:ruler', 'km'), 'washer_fluid_level': ('binary_sensor', 'Washer fluid'), 'brake_fluid': ('binary_sensor', 'Brake Fluid'), @@ -61,6 +65,7 @@ CONFIG_SCHEMA = vol.Schema({ cv.ensure_list, [vol.In(RESOURCES)]), vol.Optional(CONF_REGION): cv.string, vol.Optional(CONF_SERVICE_URL): cv.string, + vol.Optional(CONF_SCANDINAVIAN_MILES, default=False): cv.boolean, }), }, extra=vol.ALLOW_EXTRA) @@ -123,7 +128,8 @@ class VolvoData: """Initialize the component state.""" self.entities = {} self.vehicles = {} - self.names = config[DOMAIN].get(CONF_NAME) + self.config = config[DOMAIN] + self.names = self.config.get(CONF_NAME) def vehicle_name(self, vehicle): """Provide a friendly name for a vehicle.""" diff --git a/homeassistant/components/xiaomi_aqara.py b/homeassistant/components/xiaomi_aqara.py index f875edef310..678ead981c1 100644 --- a/homeassistant/components/xiaomi_aqara.py +++ b/homeassistant/components/xiaomi_aqara.py @@ -219,7 +219,9 @@ class XiaomiDevice(Entity): def push_data(self, data): """Push from Hub.""" _LOGGER.debug("PUSH >> %s: %s", self, data) - if self.parse_data(data) or self.parse_voltage(data): + is_data = self.parse_data(data) + is_voltage = self.parse_voltage(data) + if is_data or is_voltage: self.schedule_update_ha_state() def parse_voltage(self, data): diff --git a/homeassistant/config.py b/homeassistant/config.py index c4c96804fca..fee7572a2c2 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -110,6 +110,9 @@ sensor: tts: - platform: google +# Cloud +cloud: + group: !include groups.yaml automation: !include automations.yaml script: !include scripts.yaml diff --git a/homeassistant/const.py b/homeassistant/const.py index 3f578a251cf..dd15e1fb75d 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,8 +1,8 @@ # coding: utf-8 """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 -MINOR_VERSION = 59 -PATCH_VERSION = '2' +MINOR_VERSION = 60 +PATCH_VERSION = '0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 4, 2) @@ -52,6 +52,7 @@ CONF_CURRENCY = 'currency' CONF_CUSTOMIZE = 'customize' CONF_CUSTOMIZE_DOMAIN = 'customize_domain' CONF_CUSTOMIZE_GLOB = 'customize_glob' +CONF_DELAY_TIME = 'delay_time' CONF_DEVICE = 'device' CONF_DEVICE_CLASS = 'device_class' CONF_DEVICES = 'devices' @@ -74,6 +75,7 @@ CONF_EXCLUDE = 'exclude' CONF_FILE_PATH = 'file_path' CONF_FILENAME = 'filename' CONF_FOR = 'for' +CONF_FORCE_UPDATE = 'force_update' CONF_FRIENDLY_NAME = 'friendly_name' CONF_HEADERS = 'headers' CONF_HOST = 'host' diff --git a/homeassistant/helpers/discovery.py b/homeassistant/helpers/discovery.py index 46eeef45f14..6a527021c77 100644 --- a/homeassistant/helpers/discovery.py +++ b/homeassistant/helpers/discovery.py @@ -149,7 +149,7 @@ def async_load_platform(hass, component, platform, discovered=None, Use `listen_platform` to register a callback for these events. Warning: Do not yield from this inside a setup method to avoid a dead lock. - Use `hass.loop.async_add_job(async_load_platform(..))` instead. + Use `hass.async_add_job(async_load_platform(..))` instead. This method is a coroutine. """ diff --git a/homeassistant/helpers/state.py b/homeassistant/helpers/state.py index 8b98bfadb68..254a48c3d0a 100644 --- a/homeassistant/helpers/state.py +++ b/homeassistant/helpers/state.py @@ -21,7 +21,8 @@ from homeassistant.components.climate import ( ATTR_HUMIDITY, ATTR_OPERATION_MODE, ATTR_SWING_MODE, SERVICE_SET_AUX_HEAT, SERVICE_SET_AWAY_MODE, SERVICE_SET_HOLD_MODE, SERVICE_SET_FAN_MODE, SERVICE_SET_HUMIDITY, SERVICE_SET_OPERATION_MODE, - SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE) + SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, STATE_HEAT, STATE_COOL, + STATE_IDLE) from homeassistant.components.climate.ecobee import ( ATTR_FAN_MIN_ON_TIME, SERVICE_SET_FAN_MIN_ON_TIME, ATTR_RESUME_ALL, SERVICE_RESUME_PROGRAM) @@ -210,10 +211,11 @@ def state_as_number(state): Raises ValueError if this is not possible. """ if state.state in (STATE_ON, STATE_LOCKED, STATE_ABOVE_HORIZON, - STATE_OPEN, STATE_HOME): + STATE_OPEN, STATE_HOME, STATE_HEAT, STATE_COOL): return 1 elif state.state in (STATE_OFF, STATE_UNLOCKED, STATE_UNKNOWN, - STATE_BELOW_HORIZON, STATE_CLOSED, STATE_NOT_HOME): + STATE_BELOW_HORIZON, STATE_CLOSED, STATE_NOT_HOME, + STATE_IDLE): return 0 return float(state.state) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2e7acb212e2..3080160dfce 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,8 +5,8 @@ pip>=8.0.3 jinja2>=2.9.6 voluptuous==0.10.5 typing>=3,<4 -aiohttp==2.3.5 -yarl==0.15.0 +aiohttp==2.3.6 +yarl==0.16.0 async_timeout==2.0.0 chardet==3.0.4 astral==1.4 diff --git a/homeassistant/util/__init__.py b/homeassistant/util/__init__.py index 646edcf1c35..cb3ebeb7ee6 100644 --- a/homeassistant/util/__init__.py +++ b/homeassistant/util/__init__.py @@ -299,7 +299,7 @@ class Throttle(object): return None # Check if method is never called or no_throttle is given - force = not throttle[1] or kwargs.pop('no_throttle', False) + force = kwargs.pop('no_throttle', False) or not throttle[1] try: if force or utcnow() - throttle[1] > self.min_time: diff --git a/requirements_all.txt b/requirements_all.txt index 56c78e1c70d..02a53b9c26e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -6,8 +6,8 @@ pip>=8.0.3 jinja2>=2.9.6 voluptuous==0.10.5 typing>=3,<4 -aiohttp==2.3.5 -yarl==0.15.0 +aiohttp==2.3.6 +yarl==0.16.0 async_timeout==2.0.0 chardet==3.0.4 astral==1.4 @@ -23,7 +23,7 @@ certifi>=2017.4.17 DoorBirdPy==0.1.0 # homeassistant.components.isy994 -PyISY==1.0.8 +PyISY==1.1.0 # homeassistant.components.notify.html5 PyJWT==1.5.3 @@ -44,7 +44,7 @@ PyXiaomiGateway==0.6.0 RtmAPI==0.7.0 # homeassistant.components.media_player.sonos -SoCo==0.12 +SoCo==0.13 # homeassistant.components.sensor.travisci TravisPy==0.3.5 @@ -72,7 +72,7 @@ aiohttp_cors==0.5.3 aioimaplib==0.7.13 # homeassistant.components.light.lifx -aiolifx==0.6.0 +aiolifx==0.6.1 # homeassistant.components.light.lifx aiolifx_effects==0.1.2 @@ -83,6 +83,9 @@ aiopvapi==1.5.4 # homeassistant.components.alarmdecoder alarmdecoder==0.12.3 +# homeassistant.components.sensor.alpha_vantage +alpha_vantage==1.3.6 + # homeassistant.components.amcrest amcrest==1.2.1 @@ -157,6 +160,9 @@ broadlink==0.5 # homeassistant.components.weather.buienradar buienradar==0.9 +# homeassistant.components.calendar.caldav +caldav==0.5.0 + # homeassistant.components.notify.ciscospark ciscosparkapi==0.4.2 @@ -197,7 +203,7 @@ defusedxml==0.5.0 deluge-client==1.0.5 # homeassistant.components.media_player.denonavr -denonavr==0.5.4 +denonavr==0.5.5 # homeassistant.components.media_player.directv directpy==0.2 @@ -288,6 +294,9 @@ gTTS-token==1.1.1 # homeassistant.components.device_tracker.bluetooth_le_tracker # gattlib==0.20150805 +# homeassistant.components.sensor.gearbest +gearbest_parser==1.0.5 + # homeassistant.components.sensor.gitter gitterpy==0.1.6 @@ -331,7 +340,7 @@ hipnotify==1.0.8 holidays==0.8.1 # homeassistant.components.frontend -home-assistant-frontend==20171204.0 +home-assistant-frontend==20171216.0 # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a @@ -352,7 +361,7 @@ https://github.com/happyleavesaoc/spotipy/archive/544614f4b1d508201d363e84e871f8 https://github.com/jabesq/netatmo-api-python/archive/v0.9.2.1.zip#lnetatmo==0.9.2.1 # homeassistant.components.neato -https://github.com/jabesq/pybotvac/archive/v0.0.3.zip#pybotvac==0.0.3 +https://github.com/jabesq/pybotvac/archive/v0.0.4.zip#pybotvac==0.0.4 # homeassistant.components.sensor.sabnzbd https://github.com/jamespcole/home-assistant-nzb-clients/archive/616cad59154092599278661af17e2a9f2cf5e2a9.zip#python-sabnzbd==0.1 @@ -429,12 +438,15 @@ limitlessled==1.0.8 linode-api==4.1.4b2 # homeassistant.components.media_player.liveboxplaytv -liveboxplaytv==2.0.0 +liveboxplaytv==2.0.2 # homeassistant.components.lametric # homeassistant.components.notify.lametric lmnotify==0.0.4 +# homeassistant.components.sensor.luftdaten +luftdaten==0.1.1 + # homeassistant.components.sensor.lyft lyft_rides==0.2 @@ -524,7 +536,7 @@ pdunehd==1.3 # homeassistant.components.media_player.pandora pexpect==4.0.1 -# homeassistant.components.light.hue +# homeassistant.components.hue phue==1.0 # homeassistant.components.rpi_pfio @@ -557,10 +569,10 @@ pocketcasts==0.1 proliphix==0.4.1 # homeassistant.components.prometheus -prometheus_client==0.0.19 +prometheus_client==0.0.21 # homeassistant.components.sensor.systemmonitor -psutil==5.4.1 +psutil==5.4.2 # homeassistant.components.wink pubnubsub-handler==1.0.2 @@ -575,6 +587,9 @@ pushetta==1.0.15 # homeassistant.components.light.rpi_gpio_pwm pwmled==1.2.1 +# homeassistant.components.canary +py-canary==0.2.3 + # homeassistant.components.sensor.cpuspeed py-cpuinfo==3.3.0 @@ -597,6 +612,9 @@ pyTibber==0.2.1 # homeassistant.components.switch.dlink pyW215==0.6.0 +# homeassistant.components.ads +pyads==2.2.6 + # homeassistant.components.sensor.airvisual pyairvisual==1.0.0 @@ -613,7 +631,7 @@ pyasn1-modules==0.1.5 pyasn1==0.3.7 # homeassistant.components.apple_tv -pyatv==0.3.8 +pyatv==0.3.9 # homeassistant.components.device_tracker.bbox # homeassistant.components.sensor.bbox @@ -677,11 +695,14 @@ pyhik==0.1.4 pyhiveapi==0.2.5 # homeassistant.components.homematic -pyhomematic==0.1.35 +pyhomematic==0.1.36 # homeassistant.components.sensor.hydroquebec pyhydroquebec==1.3.1 +# homeassistant.components.alarm_control_panel.ialarm +pyialarm==0.2 + # homeassistant.components.device_tracker.icloud pyicloud==0.9.1 @@ -735,7 +756,7 @@ pymodbus==1.3.1 pymonoprice==0.2 # homeassistant.components.media_player.yamaha_musiccast -pymusiccast==0.1.5 +pymusiccast==0.1.6 # homeassistant.components.cover.myq pymyq==0.0.8 @@ -795,6 +816,9 @@ pysma==0.1.3 # homeassistant.components.switch.snmp pysnmp==4.4.2 +# homeassistant.components.media_player.liveboxplaytv +pyteleloisirs==3.3 + # homeassistant.components.sensor.thinkingcleaner # homeassistant.components.switch.thinkingcleaner pythinkingcleaner==0.0.3 @@ -809,7 +833,7 @@ python-clementine-remote==1.0.1 python-digitalocean==1.12 # homeassistant.components.ecobee -python-ecobee-api==0.0.12 +python-ecobee-api==0.0.14 # homeassistant.components.climate.eq3btsmart # python-eq3bt==0.1.6 @@ -859,7 +883,7 @@ python-nmap==0.6.1 python-pushover==0.3 # homeassistant.components.sensor.ripple -python-ripple-api==0.0.2 +python-ripple-api==0.0.3 # homeassistant.components.media_player.roku python-roku==3.1.3 @@ -913,7 +937,7 @@ pyunifi==2.13 # pyuserinput==0.1.11 # homeassistant.components.vera -pyvera==0.2.38 +pyvera==0.2.39 # homeassistant.components.media_player.vizio pyvizio==0.0.2 @@ -998,7 +1022,7 @@ sense-hat==2.2.0 sharp_aquos_rc==0.3.2 # homeassistant.components.sensor.shodan -shodan==1.7.5 +shodan==1.7.7 # homeassistant.components.notify.simplepush simplepush==1.1.4 @@ -1057,7 +1081,7 @@ tank_utility==1.4.0 tapsaff==0.1.3 # homeassistant.components.tellstick -tellcore-net==0.3 +tellcore-net==0.4 # homeassistant.components.tellstick tellcore-py==1.1.2 @@ -1128,7 +1152,7 @@ wakeonlan==0.2.2 waqiasync==1.0.0 # homeassistant.components.cloud -warrant==0.5.0 +warrant==0.6.1 # homeassistant.components.media_player.gpmdp websocket-client==0.37.0 @@ -1166,10 +1190,13 @@ yeelight==0.3.3 yeelightsunflower==0.0.8 # homeassistant.components.media_extractor -youtube_dl==2017.11.26 +youtube_dl==2017.12.10 # homeassistant.components.light.zengge zengge==0.2 # homeassistant.components.zeroconf zeroconf==0.19.1 + +# homeassistant.components.media_player.ziggo_mediabox_xl +ziggo-mediabox-xl==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3cc2bfa9a9c..a96c3af1fd9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -24,7 +24,7 @@ freezegun>=0.3.8 PyJWT==1.5.3 # homeassistant.components.media_player.sonos -SoCo==0.12 +SoCo==0.13 # homeassistant.components.device_tracker.automatic aioautomatic==0.6.4 @@ -36,6 +36,9 @@ aiohttp_cors==0.5.3 # homeassistant.components.notify.apns apns2==0.3.0 +# homeassistant.components.calendar.caldav +caldav==0.5.0 + # homeassistant.components.sensor.coinmarketcap coinmarketcap==4.1.1 @@ -74,7 +77,7 @@ hbmqtt==0.9.1 holidays==0.8.1 # homeassistant.components.frontend -home-assistant-frontend==20171204.0 +home-assistant-frontend==20171216.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb @@ -113,7 +116,10 @@ pilight==0.1.1 pmsensor==0.4 # homeassistant.components.prometheus -prometheus_client==0.0.19 +prometheus_client==0.0.21 + +# homeassistant.components.canary +py-canary==0.2.3 # homeassistant.components.zwave pydispatcher==2.0.5 @@ -176,7 +182,7 @@ vultr==0.1.2 wakeonlan==0.2.2 # homeassistant.components.cloud -warrant==0.5.0 +warrant==0.6.1 # homeassistant.components.sensor.yahoo_finance yahoo-finance==1.4.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index fbd60ffdadc..0bfb5f9e607 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -38,6 +38,7 @@ TEST_REQUIREMENTS = ( 'aioautomatic', 'aiohttp_cors', 'apns2', + 'caldav', 'coinmarketcap', 'defusedxml', 'dsmr_parser', @@ -61,6 +62,7 @@ TEST_REQUIREMENTS = ( 'pilight', 'pmsensor', 'prometheus_client', + 'py-canary', 'pydispatcher', 'PyJWT', 'pylitejet', diff --git a/setup.py b/setup.py index d79f11732ad..fe60a15e32e 100755 --- a/setup.py +++ b/setup.py @@ -53,8 +53,8 @@ REQUIRES = [ 'jinja2>=2.9.6', 'voluptuous==0.10.5', 'typing>=3,<4', - 'aiohttp==2.3.5', # If updated, check if yarl also needs an update! - 'yarl==0.15.0', + 'aiohttp==2.3.6', # If updated, check if yarl also needs an update! + 'yarl==0.16.0', 'async_timeout==2.0.0', 'chardet==3.0.4', 'astral==1.4', diff --git a/tests/components/alarm_control_panel/test_manual.py b/tests/components/alarm_control_panel/test_manual.py index d65568b0844..c47ed941b65 100644 --- a/tests/components/alarm_control_panel/test_manual.py +++ b/tests/components/alarm_control_panel/test_manual.py @@ -140,6 +140,32 @@ class TestAlarmControlPanelManual(unittest.TestCase): self.assertEqual(STATE_ALARM_ARMED_AWAY, self.hass.states.get(entity_id).state) + def test_arm_home_with_template_code(self): + """Attempt to arm with a template-based code.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code_template': '{{ "abc" }}', + 'pending_time': 0, + 'disarm_after_trigger': False + }})) + + entity_id = 'alarm_control_panel.test' + + self.hass.start() + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_home(self.hass, 'abc') + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_ARMED_HOME, state.state) + def test_arm_away_with_pending(self): """Test arm home method.""" self.assertTrue(setup_component( @@ -257,6 +283,13 @@ class TestAlarmControlPanelManual(unittest.TestCase): state = self.hass.states.get(entity_id) assert state.state == STATE_ALARM_ARMED_NIGHT + # Do not go to the pending state when updating to the same state + alarm_control_panel.alarm_arm_night(self.hass, CODE, entity_id) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_NIGHT, + self.hass.states.get(entity_id).state) + def test_arm_night_with_invalid_code(self): """Attempt to night home without a valid code.""" self.assertTrue(setup_component( @@ -311,6 +344,93 @@ class TestAlarmControlPanelManual(unittest.TestCase): self.assertEqual(STATE_ALARM_TRIGGERED, self.hass.states.get(entity_id).state) + def test_trigger_with_delay(self): + """Test trigger method and switch from pending to triggered.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'delay_time': 1, + 'pending_time': 0, + 'disarm_after_trigger': False + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_away(self.hass, CODE) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_PENDING, state.state) + self.assertEqual(STATE_ALARM_TRIGGERED, + state.attributes['post_pending_state']) + + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_TRIGGERED, state.state) + + def test_trigger_zero_trigger_time(self): + """Test disabled trigger.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'pending_time': 0, + 'trigger_time': 0, + 'disarm_after_trigger': False + }})) + + 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) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + def test_trigger_zero_trigger_time_with_pending(self): + """Test disabled trigger.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'pending_time': 2, + 'trigger_time': 0, + 'disarm_after_trigger': False + }})) + + 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) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + def test_trigger_with_pending(self): """Test arm home method.""" self.assertTrue(setup_component( @@ -355,6 +475,203 @@ class TestAlarmControlPanelManual(unittest.TestCase): state = self.hass.states.get(entity_id) assert state.state == STATE_ALARM_DISARMED + def test_trigger_with_unused_specific_delay(self): + """Test trigger method and switch from pending to triggered.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'delay_time': 5, + 'pending_time': 0, + 'armed_home': { + 'delay_time': 10 + }, + 'disarm_after_trigger': False + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_away(self.hass, CODE) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_PENDING, state.state) + self.assertEqual(STATE_ALARM_TRIGGERED, + state.attributes['post_pending_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.block_till_done() + + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_TRIGGERED + + def test_trigger_with_specific_delay(self): + """Test trigger method and switch from pending to triggered.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'delay_time': 10, + 'pending_time': 0, + 'armed_away': { + 'delay_time': 1 + }, + 'disarm_after_trigger': False + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_away(self.hass, CODE) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_PENDING, state.state) + self.assertEqual(STATE_ALARM_TRIGGERED, + state.attributes['post_pending_state']) + + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_TRIGGERED + + def test_trigger_with_pending_and_delay(self): + """Test trigger method and switch from pending to triggered.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'delay_time': 1, + 'pending_time': 0, + 'triggered': { + 'pending_time': 1 + }, + 'disarm_after_trigger': False + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_away(self.hass, CODE) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_PENDING + assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED + + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_PENDING + assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED + + future += timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_TRIGGERED + + def test_trigger_with_pending_and_specific_delay(self): + """Test trigger method and switch from pending to triggered.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'delay_time': 10, + 'pending_time': 0, + 'armed_away': { + 'delay_time': 1 + }, + 'triggered': { + 'pending_time': 1 + }, + 'disarm_after_trigger': False + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_away(self.hass, CODE) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_PENDING + assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED + + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_PENDING + assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED + + future += timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_TRIGGERED + def test_armed_home_with_specific_pending(self): """Test arm home method.""" self.assertTrue(setup_component( @@ -518,6 +835,101 @@ class TestAlarmControlPanelManual(unittest.TestCase): self.assertEqual(STATE_ALARM_DISARMED, self.hass.states.get(entity_id).state) + def test_trigger_with_zero_specific_trigger_time(self): + """Test trigger method.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'trigger_time': 5, + 'disarmed': { + 'trigger_time': 0 + }, + '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.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + def test_trigger_with_unused_zero_specific_trigger_time(self): + """Test disarm after trigger.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'trigger_time': 5, + 'armed_home': { + 'trigger_time': 0 + }, + '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.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.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + def test_trigger_with_specific_trigger_time(self): + """Test disarm after trigger.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'disarmed': { + '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.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.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + def test_trigger_with_no_disarm_after_trigger(self): """Test disarm after trigger.""" self.assertTrue(setup_component( @@ -684,6 +1096,45 @@ class TestAlarmControlPanelManual(unittest.TestCase): self.assertEqual(STATE_ALARM_TRIGGERED, self.hass.states.get(entity_id).state) + def test_disarm_with_template_code(self): + """Attempt to disarm with a valid or invalid template-based code.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code_template': + '{{ "" if from_state == "disarmed" else "abc" }}', + 'pending_time': 0, + 'disarm_after_trigger': False + }})) + + entity_id = 'alarm_control_panel.test' + + self.hass.start() + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_home(self.hass, 'def') + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_ARMED_HOME, state.state) + + alarm_control_panel.alarm_disarm(self.hass, 'def') + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_ARMED_HOME, state.state) + + alarm_control_panel.alarm_disarm(self.hass, 'abc') + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_DISARMED, state.state) + def test_arm_custom_bypass_no_pending(self): """Test arm custom bypass method.""" self.assertTrue(setup_component( @@ -795,3 +1246,75 @@ class TestAlarmControlPanelManual(unittest.TestCase): self.assertEqual(STATE_ALARM_ARMED_CUSTOM_BYPASS, self.hass.states.get(entity_id).state) + + def test_arm_away_after_disabled_disarmed(self): + """Test pending state with and without zero trigger time.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'pending_time': 0, + 'delay_time': 1, + 'armed_away': { + 'pending_time': 1, + }, + 'disarmed': { + 'trigger_time': 0 + }, + 'disarm_after_trigger': False + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_away(self.hass, CODE) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_PENDING, state.state) + self.assertEqual(STATE_ALARM_DISARMED, + state.attributes['pre_pending_state']) + self.assertEqual(STATE_ALARM_ARMED_AWAY, + state.attributes['post_pending_state']) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_PENDING, state.state) + self.assertEqual(STATE_ALARM_DISARMED, + state.attributes['pre_pending_state']) + self.assertEqual(STATE_ALARM_ARMED_AWAY, + state.attributes['post_pending_state']) + + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_ARMED_AWAY, state.state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_PENDING, state.state) + self.assertEqual(STATE_ALARM_ARMED_AWAY, + state.attributes['pre_pending_state']) + self.assertEqual(STATE_ALARM_TRIGGERED, + state.attributes['post_pending_state']) + + future += timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_TRIGGERED, state.state) diff --git a/tests/components/alarm_control_panel/test_manual_mqtt.py b/tests/components/alarm_control_panel/test_manual_mqtt.py index e56b6865e6e..83254d9104f 100644 --- a/tests/components/alarm_control_panel/test_manual_mqtt.py +++ b/tests/components/alarm_control_panel/test_manual_mqtt.py @@ -162,6 +162,34 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): self.assertEqual(STATE_ALARM_ARMED_AWAY, self.hass.states.get(entity_id).state) + def test_arm_home_with_template_code(self): + """Attempt to arm with a template-based code.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'code_template': '{{ "abc" }}', + 'pending_time': 0, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state', + }})) + + entity_id = 'alarm_control_panel.test' + + self.hass.start() + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_home(self.hass, 'abc') + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_ARMED_HOME, state.state) + def test_arm_away_with_pending(self): """Test arm home method.""" self.assertTrue(setup_component( @@ -287,6 +315,13 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): self.assertEqual(STATE_ALARM_ARMED_NIGHT, self.hass.states.get(entity_id).state) + # Do not go to the pending state when updating to the same state + alarm_control_panel.alarm_arm_night(self.hass, CODE, entity_id) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_NIGHT, + self.hass.states.get(entity_id).state) + def test_arm_night_with_invalid_code(self): """Attempt to arm night without a valid code.""" self.assertTrue(setup_component( @@ -345,6 +380,99 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): self.assertEqual(STATE_ALARM_TRIGGERED, self.hass.states.get(entity_id).state) + def test_trigger_with_delay(self): + """Test trigger method and switch from pending to triggered.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'code': CODE, + 'delay_time': 1, + 'pending_time': 0, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state' + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_away(self.hass, CODE) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_PENDING, state.state) + self.assertEqual(STATE_ALARM_TRIGGERED, + state.attributes['post_pending_state']) + + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_TRIGGERED, state.state) + + def test_trigger_zero_trigger_time(self): + """Test disabled trigger.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'pending_time': 0, + 'trigger_time': 0, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state' + }})) + + 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) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + def test_trigger_zero_trigger_time_with_pending(self): + """Test disabled trigger.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'pending_time': 2, + 'trigger_time': 0, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state' + }})) + + 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) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + def test_trigger_with_pending(self): """Test arm home method.""" self.assertTrue(setup_component( @@ -425,6 +553,107 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): self.assertEqual(STATE_ALARM_DISARMED, self.hass.states.get(entity_id).state) + def test_trigger_with_zero_specific_trigger_time(self): + """Test trigger method.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'trigger_time': 5, + 'disarmed': { + 'trigger_time': 0 + }, + 'pending_time': 0, + 'disarm_after_trigger': True, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state' + }})) + + 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.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + def test_trigger_with_unused_zero_specific_trigger_time(self): + """Test disarm after trigger.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'trigger_time': 5, + 'armed_home': { + 'trigger_time': 0 + }, + 'pending_time': 0, + 'disarm_after_trigger': True, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state' + }})) + + 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.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_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + def test_trigger_with_specific_trigger_time(self): + """Test disarm after trigger.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'disarmed': { + 'trigger_time': 5 + }, + 'pending_time': 0, + 'disarm_after_trigger': True, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state' + }})) + + 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.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_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + def test_back_to_back_trigger_with_no_disarm_after_trigger(self): """Test no disarm after back to back trigger.""" self.assertTrue(setup_component( @@ -559,6 +788,211 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): self.assertEqual(STATE_ALARM_TRIGGERED, self.hass.states.get(entity_id).state) + def test_trigger_with_unused_specific_delay(self): + """Test trigger method and switch from pending to triggered.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'code': CODE, + 'delay_time': 5, + 'pending_time': 0, + 'armed_home': { + 'delay_time': 10 + }, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state' + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_away(self.hass, CODE) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_PENDING, state.state) + self.assertEqual(STATE_ALARM_TRIGGERED, + state.attributes['post_pending_state']) + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_TRIGGERED + + def test_trigger_with_specific_delay(self): + """Test trigger method and switch from pending to triggered.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'code': CODE, + 'delay_time': 10, + 'pending_time': 0, + 'armed_away': { + 'delay_time': 1 + }, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state' + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_away(self.hass, CODE) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_PENDING, state.state) + self.assertEqual(STATE_ALARM_TRIGGERED, + state.attributes['post_pending_state']) + + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_TRIGGERED + + def test_trigger_with_pending_and_delay(self): + """Test trigger method and switch from pending to triggered.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'code': CODE, + 'delay_time': 1, + 'pending_time': 0, + 'triggered': { + 'pending_time': 1 + }, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state' + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_away(self.hass, CODE) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_PENDING + assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED + + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_PENDING + assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED + + future += timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_TRIGGERED + + def test_trigger_with_pending_and_specific_delay(self): + """Test trigger method and switch from pending to triggered.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'code': CODE, + 'delay_time': 10, + 'pending_time': 0, + 'armed_away': { + 'delay_time': 1 + }, + 'triggered': { + 'pending_time': 1 + }, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state' + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_away(self.hass, CODE) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_PENDING + assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED + + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_PENDING + assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED + + future += timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_TRIGGERED + def test_armed_home_with_specific_pending(self): """Test arm home method.""" self.assertTrue(setup_component( @@ -674,21 +1108,6 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): entity_id = 'alarm_control_panel.test' - alarm_control_panel.alarm_arm_home(self.hass) - self.hass.block_till_done() - - self.assertEqual(STATE_ALARM_PENDING, - self.hass.states.get(entity_id).state) - - future = dt_util.utcnow() + timedelta(seconds=10) - with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - self.assertEqual(STATE_ALARM_ARMED_HOME, - self.hass.states.get(entity_id).state) - alarm_control_panel.alarm_trigger(self.hass) self.hass.block_till_done() @@ -710,9 +1129,124 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): fire_time_changed(self.hass, future) self.hass.block_till_done() - self.assertEqual(STATE_ALARM_ARMED_HOME, + self.assertEqual(STATE_ALARM_DISARMED, self.hass.states.get(entity_id).state) + def test_arm_away_after_disabled_disarmed(self): + """Test pending state with and without zero trigger time.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'code': CODE, + 'pending_time': 0, + 'delay_time': 1, + 'armed_away': { + 'pending_time': 1, + }, + 'disarmed': { + 'trigger_time': 0 + }, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state', + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_away(self.hass, CODE) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_PENDING, state.state) + self.assertEqual(STATE_ALARM_DISARMED, + state.attributes['pre_pending_state']) + self.assertEqual(STATE_ALARM_ARMED_AWAY, + state.attributes['post_pending_state']) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_PENDING, state.state) + self.assertEqual(STATE_ALARM_DISARMED, + state.attributes['pre_pending_state']) + self.assertEqual(STATE_ALARM_ARMED_AWAY, + state.attributes['post_pending_state']) + + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_ARMED_AWAY, state.state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_PENDING, state.state) + self.assertEqual(STATE_ALARM_ARMED_AWAY, + state.attributes['pre_pending_state']) + self.assertEqual(STATE_ALARM_TRIGGERED, + state.attributes['post_pending_state']) + + future += timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_TRIGGERED, state.state) + + def test_disarm_with_template_code(self): + """Attempt to disarm with a valid or invalid template-based code.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'code_template': + '{{ "" if from_state == "disarmed" else "abc" }}', + 'pending_time': 0, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state', + }})) + + entity_id = 'alarm_control_panel.test' + + self.hass.start() + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_home(self.hass, 'def') + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_ARMED_HOME, state.state) + + alarm_control_panel.alarm_disarm(self.hass, 'def') + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_ARMED_HOME, state.state) + + alarm_control_panel.alarm_disarm(self.hass, 'abc') + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(STATE_ALARM_DISARMED, state.state) + def test_arm_home_via_command_topic(self): """Test arming home via command topic.""" assert setup_component(self.hass, alarm_control_panel.DOMAIN, { diff --git a/tests/components/calendar/test_caldav.py b/tests/components/calendar/test_caldav.py new file mode 100644 index 00000000000..8a44f96fe87 --- /dev/null +++ b/tests/components/calendar/test_caldav.py @@ -0,0 +1,302 @@ +"""The tests for the webdav calendar component.""" +# pylint: disable=protected-access +import datetime +import logging +import unittest +from unittest.mock import (patch, Mock, MagicMock) + +import homeassistant.components.calendar as calendar_base +import homeassistant.components.calendar.caldav as caldav +from caldav.objects import Event +from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON +from homeassistant.util import dt +from tests.common import get_test_home_assistant + +TEST_PLATFORM = {calendar_base.DOMAIN: {CONF_PLATFORM: 'test'}} + +_LOGGER = logging.getLogger(__name__) + + +DEVICE_DATA = { + "name": "Private Calendar", + "device_id": "Private Calendar" +} + +EVENTS = [ + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//E-Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:1 +DTSTAMP:20171125T000000Z +DTSTART:20171127T170000Z +DTEND:20171127T180000Z +SUMMARY:This is a normal event +LOCATION:Hamburg +DESCRIPTION:Surprisingly rainy +END:VEVENT +END:VCALENDAR +""", + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Global Dynamics.//CalDAV Client//EN +BEGIN:VEVENT +UID:2 +DTSTAMP:20171125T000000Z +DTSTART:20171127T100000Z +DTEND:20171127T110000Z +SUMMARY:This is an offset event !!-02:00 +LOCATION:Hamburg +DESCRIPTION:Surprisingly shiny +END:VEVENT +END:VCALENDAR +""", + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Global Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:3 +DTSTAMP:20171125T000000Z +DTSTART:20171127 +DTEND:20171128 +SUMMARY:This is an all day event +LOCATION:Hamburg +DESCRIPTION:What a beautiful day +END:VEVENT +END:VCALENDAR +""" +] + + +def _local_datetime(hours, minutes): + """Build a datetime object for testing in the correct timezone.""" + return dt.as_local(datetime.datetime(2017, 11, 27, hours, minutes, 0)) + + +def _mocked_dav_client(*args, **kwargs): + """Mock requests.get invocations.""" + calendars = [ + _mock_calendar("First"), + _mock_calendar("Second") + ] + principal = Mock() + principal.calendars = MagicMock(return_value=calendars) + + client = Mock() + client.principal = MagicMock(return_value=principal) + return client + + +def _mock_calendar(name): + events = [] + for idx, event in enumerate(EVENTS): + events.append(Event(None, "%d.ics" % idx, event, None, str(idx))) + + calendar = Mock() + calendar.date_search = MagicMock(return_value=events) + calendar.name = name + return calendar + + +class TestComponentsWebDavCalendar(unittest.TestCase): + """Test the WebDav calendar.""" + + hass = None # HomeAssistant + + # pylint: disable=invalid-name + def setUp(self): + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.calendar = _mock_calendar("Private") + + # pylint: disable=invalid-name + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + @patch('caldav.DAVClient', side_effect=_mocked_dav_client) + def test_setup_component(self, req_mock): + """Test setup component with calendars.""" + def _add_device(devices): + assert len(devices) == 2 + assert devices[0].name == "First" + assert devices[0].dev_id == "First" + assert devices[1].name == "Second" + assert devices[1].dev_id == "Second" + + caldav.setup_platform(self.hass, + { + "url": "http://test.local", + "custom_calendars": [] + }, + _add_device) + + @patch('caldav.DAVClient', side_effect=_mocked_dav_client) + def test_setup_component_with_no_calendar_matching(self, req_mock): + """Test setup component with wrong calendar.""" + def _add_device(devices): + assert not devices + + caldav.setup_platform(self.hass, + { + "url": "http://test.local", + "calendars": ["none"], + "custom_calendars": [] + }, + _add_device) + + @patch('caldav.DAVClient', side_effect=_mocked_dav_client) + def test_setup_component_with_a_calendar_match(self, req_mock): + """Test setup component with right calendar.""" + def _add_device(devices): + assert len(devices) == 1 + assert devices[0].name == "Second" + + caldav.setup_platform(self.hass, + { + "url": "http://test.local", + "calendars": ["Second"], + "custom_calendars": [] + }, + _add_device) + + @patch('caldav.DAVClient', side_effect=_mocked_dav_client) + def test_setup_component_with_one_custom_calendar(self, req_mock): + """Test setup component with custom calendars.""" + def _add_device(devices): + assert len(devices) == 1 + assert devices[0].name == "HomeOffice" + assert devices[0].dev_id == "Second HomeOffice" + + caldav.setup_platform(self.hass, + { + "url": "http://test.local", + "custom_calendars": [ + { + "name": "HomeOffice", + "calendar": "Second", + "filter": "HomeOffice" + }] + }, + _add_device) + + @patch('homeassistant.util.dt.now', return_value=_local_datetime(17, 30)) + def test_ongoing_event(self, mock_now): + """Test that the ongoing event is returned.""" + cal = caldav.WebDavCalendarEventDevice(self.hass, + DEVICE_DATA, + self.calendar) + + self.assertEqual(cal.name, DEVICE_DATA["name"]) + self.assertEqual(cal.state, STATE_ON) + self.assertEqual(cal.device_state_attributes, { + "message": "This is a normal event", + "all_day": False, + "offset_reached": False, + "start_time": "2017-11-27 17:00:00", + "end_time": "2017-11-27 18:00:00", + "location": "Hamburg", + "description": "Surprisingly rainy" + }) + + @patch('homeassistant.util.dt.now', return_value=_local_datetime(8, 30)) + def test_ongoing_event_with_offset(self, mock_now): + """Test that the offset is taken into account.""" + cal = caldav.WebDavCalendarEventDevice(self.hass, + DEVICE_DATA, + self.calendar) + + self.assertEqual(cal.state, STATE_OFF) + self.assertEqual(cal.device_state_attributes, { + "message": "This is an offset event", + "all_day": False, + "offset_reached": True, + "start_time": "2017-11-27 10:00:00", + "end_time": "2017-11-27 11:00:00", + "location": "Hamburg", + "description": "Surprisingly shiny" + }) + + @patch('homeassistant.util.dt.now', return_value=_local_datetime(12, 00)) + def test_matching_filter(self, mock_now): + """Test that the matching event is returned.""" + cal = caldav.WebDavCalendarEventDevice(self.hass, + DEVICE_DATA, + self.calendar, + False, + "This is a normal event") + + self.assertEqual(cal.state, STATE_OFF) + self.assertFalse(cal.offset_reached()) + self.assertEqual(cal.device_state_attributes, { + "message": "This is a normal event", + "all_day": False, + "offset_reached": False, + "start_time": "2017-11-27 17:00:00", + "end_time": "2017-11-27 18:00:00", + "location": "Hamburg", + "description": "Surprisingly rainy" + }) + + @patch('homeassistant.util.dt.now', return_value=_local_datetime(12, 00)) + def test_matching_filter_real_regexp(self, mock_now): + """Test that the event matching the regexp is returned.""" + cal = caldav.WebDavCalendarEventDevice(self.hass, + DEVICE_DATA, + self.calendar, + False, + "^This.*event") + + self.assertEqual(cal.state, STATE_OFF) + self.assertFalse(cal.offset_reached()) + self.assertEqual(cal.device_state_attributes, { + "message": "This is a normal event", + "all_day": False, + "offset_reached": False, + "start_time": "2017-11-27 17:00:00", + "end_time": "2017-11-27 18:00:00", + "location": "Hamburg", + "description": "Surprisingly rainy" + }) + + @patch('homeassistant.util.dt.now', return_value=_local_datetime(20, 00)) + def test_filter_matching_past_event(self, mock_now): + """Test that the matching past event is not returned.""" + cal = caldav.WebDavCalendarEventDevice(self.hass, + DEVICE_DATA, + self.calendar, + False, + "This is a normal event") + + self.assertEqual(cal.data.event, None) + + @patch('homeassistant.util.dt.now', return_value=_local_datetime(12, 00)) + def test_no_result_with_filtering(self, mock_now): + """Test that nothing is returned since nothing matches.""" + cal = caldav.WebDavCalendarEventDevice(self.hass, + DEVICE_DATA, + self.calendar, + False, + "This is a non-existing event") + + self.assertEqual(cal.data.event, None) + + @patch('homeassistant.util.dt.now', return_value=_local_datetime(17, 30)) + def test_all_day_event_returned(self, mock_now): + """Test that the event lasting the whole day is returned.""" + cal = caldav.WebDavCalendarEventDevice(self.hass, + DEVICE_DATA, + self.calendar, + True) + + self.assertEqual(cal.name, DEVICE_DATA["name"]) + self.assertEqual(cal.state, STATE_ON) + self.assertEqual(cal.device_state_attributes, { + "message": "This is an all day event", + "all_day": True, + "offset_reached": False, + "start_time": "2017-11-27 00:00:00", + "end_time": "2017-11-28 00:00:00", + "location": "Hamburg", + "description": "What a beautiful day" + }) diff --git a/tests/components/climate/test_generic_thermostat.py b/tests/components/climate/test_generic_thermostat.py index 5982a6c16d8..63bbce2e7c6 100644 --- a/tests/components/climate/test_generic_thermostat.py +++ b/tests/components/climate/test_generic_thermostat.py @@ -205,6 +205,10 @@ class TestClimateGenericThermostat(unittest.TestCase): self.hass.block_till_done() state = self.hass.states.get(ENTITY) self.assertEqual(30.0, state.attributes.get('temperature')) + climate.set_temperature(self.hass, None) + self.hass.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.""" @@ -888,19 +892,22 @@ def test_custom_setup_params(hass): 'min_temp': MIN_TEMP, 'max_temp': MAX_TEMP, 'target_temp': TARGET_TEMP, + 'initial_operation_mode': STATE_OFF, }}) assert result state = hass.states.get(ENTITY) assert state.attributes.get('min_temp') == MIN_TEMP assert state.attributes.get('max_temp') == MAX_TEMP assert state.attributes.get('temperature') == TARGET_TEMP + assert state.attributes.get(climate.ATTR_OPERATION_MODE) == STATE_OFF @asyncio.coroutine def test_restore_state(hass): """Ensure states are restored on startup.""" mock_restore_cache(hass, ( - State('climate.test_thermostat', '0', {ATTR_TEMPERATURE: "20"}), + State('climate.test_thermostat', '0', {ATTR_TEMPERATURE: "20", + climate.ATTR_OPERATION_MODE: "off"}), )) hass.state = CoreState.starting @@ -915,3 +922,29 @@ def test_restore_state(hass): state = hass.states.get('climate.test_thermostat') assert(state.attributes[ATTR_TEMPERATURE] == 20) + assert(state.attributes[climate.ATTR_OPERATION_MODE] == "off") + + +@asyncio.coroutine +def test_no_restore_state(hass): + """Ensure states are not restored on startup if not needed.""" + mock_restore_cache(hass, ( + State('climate.test_thermostat', '0', {ATTR_TEMPERATURE: "20", + climate.ATTR_OPERATION_MODE: "off"}), + )) + + hass.state = CoreState.starting + + yield from async_setup_component( + hass, climate.DOMAIN, {'climate': { + 'platform': 'generic_thermostat', + 'name': 'test_thermostat', + 'heater': ENT_SWITCH, + 'target_sensor': ENT_SENSOR, + 'target_temp': 22, + 'initial_operation_mode': 'auto', + }}) + + state = hass.states.get('climate.test_thermostat') + assert(state.attributes[ATTR_TEMPERATURE] == 22) + assert(state.attributes[climate.ATTR_OPERATION_MODE] != "off") diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index c05fdabf465..c5bb6f7fda7 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -132,15 +132,6 @@ def test_write_user_info(): } -@asyncio.coroutine -def test_subscription_not_expired_without_sub_in_claim(): - """Test that we do not enforce subscriptions yet.""" - cl = cloud.Cloud(None, cloud.MODE_DEV) - cl.id_token = jwt.encode({}, 'test') - - assert not cl.subscription_expired - - @asyncio.coroutine def test_subscription_expired(): """Test subscription being expired.""" diff --git a/tests/components/device_tracker/test_asuswrt.py b/tests/components/device_tracker/test_asuswrt.py index b507bfea7c9..a6827d165cd 100644 --- a/tests/components/device_tracker/test_asuswrt.py +++ b/tests/components/device_tracker/test_asuswrt.py @@ -9,7 +9,8 @@ import voluptuous as vol from homeassistant.setup import setup_component from homeassistant.components import device_tracker from homeassistant.components.device_tracker import ( - CONF_CONSIDER_HOME, CONF_TRACK_NEW) + CONF_CONSIDER_HOME, CONF_TRACK_NEW, CONF_NEW_DEVICE_DEFAULTS, + CONF_AWAY_HIDE) from homeassistant.components.device_tracker.asuswrt import ( CONF_PROTOCOL, CONF_MODE, CONF_PUB_KEY, DOMAIN, CONF_PORT, PLATFORM_SCHEMA) @@ -78,7 +79,11 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): CONF_USERNAME: 'fake_user', CONF_PASSWORD: 'fake_pass', CONF_TRACK_NEW: True, - CONF_CONSIDER_HOME: timedelta(seconds=180) + CONF_CONSIDER_HOME: timedelta(seconds=180), + CONF_NEW_DEVICE_DEFAULTS: { + CONF_TRACK_NEW: True, + CONF_AWAY_HIDE: False + } } } @@ -104,7 +109,11 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): CONF_USERNAME: 'fake_user', CONF_PUB_KEY: FAKEFILE, CONF_TRACK_NEW: True, - CONF_CONSIDER_HOME: timedelta(seconds=180) + CONF_CONSIDER_HOME: timedelta(seconds=180), + CONF_NEW_DEVICE_DEFAULTS: { + CONF_TRACK_NEW: True, + CONF_AWAY_HIDE: False + } } } diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index 704b2590f12..34c7ecf465d 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -123,7 +123,7 @@ class TestComponentsDeviceTracker(unittest.TestCase): 'My device', None, None, False), device_tracker.Device(self.hass, True, True, 'your_device', 'AB:01', 'Your device', None, None, False)] - device_tracker.DeviceTracker(self.hass, False, True, devices) + device_tracker.DeviceTracker(self.hass, False, True, {}, devices) _LOGGER.debug(mock_warning.call_args_list) assert mock_warning.call_count == 1, \ "The only warning call should be duplicates (check DEBUG)" @@ -137,7 +137,7 @@ class TestComponentsDeviceTracker(unittest.TestCase): 'AB:01', 'My device', None, None, False), device_tracker.Device(self.hass, True, True, 'my_device', None, 'Your device', None, None, False)] - device_tracker.DeviceTracker(self.hass, False, True, devices) + device_tracker.DeviceTracker(self.hass, False, True, {}, devices) _LOGGER.debug(mock_warning.call_args_list) assert mock_warning.call_count == 1, \ @@ -299,7 +299,7 @@ class TestComponentsDeviceTracker(unittest.TestCase): vendor_string = 'Raspberry Pi Foundation' tracker = device_tracker.DeviceTracker( - self.hass, timedelta(seconds=60), 0, []) + self.hass, timedelta(seconds=60), 0, {}, []) with mock_aiohttp_client() as aioclient_mock: aioclient_mock.get('http://api.macvendors.com/b8:27:eb', @@ -622,7 +622,7 @@ class TestComponentsDeviceTracker(unittest.TestCase): def test_see_failures(self, mock_warning): """Test that the device tracker see failures.""" tracker = device_tracker.DeviceTracker( - self.hass, timedelta(seconds=60), 0, []) + self.hass, timedelta(seconds=60), 0, {}, []) # MAC is not a string (but added) tracker.see(mac=567, host_name="Number MAC") @@ -654,7 +654,7 @@ class TestComponentsDeviceTracker(unittest.TestCase): def test_picture_and_icon_on_see_discovery(self): """Test that picture and icon are set in initial see.""" tracker = device_tracker.DeviceTracker( - self.hass, timedelta(seconds=60), False, []) + self.hass, timedelta(seconds=60), False, {}, []) tracker.see(dev_id=11, picture='pic_url', icon='mdi:icon') self.hass.block_till_done() config = device_tracker.load_config(self.yaml_devices, self.hass, @@ -663,6 +663,18 @@ class TestComponentsDeviceTracker(unittest.TestCase): assert config[0].icon == 'mdi:icon' assert config[0].entity_picture == 'pic_url' + def test_default_hide_if_away_is_used(self): + """Test that default track_new is used.""" + tracker = device_tracker.DeviceTracker( + self.hass, timedelta(seconds=60), False, + {device_tracker.CONF_AWAY_HIDE: True}, []) + tracker.see(dev_id=12) + self.hass.block_till_done() + config = device_tracker.load_config(self.yaml_devices, self.hass, + timedelta(seconds=0)) + assert len(config) == 1 + self.assertTrue(config[0].hidden) + @asyncio.coroutine def test_async_added_to_hass(hass): diff --git a/tests/components/device_tracker/test_meraki.py b/tests/components/device_tracker/test_meraki.py new file mode 100644 index 00000000000..a739df804fd --- /dev/null +++ b/tests/components/device_tracker/test_meraki.py @@ -0,0 +1,139 @@ +"""The tests the for Meraki device tracker.""" +import asyncio +import json +from unittest.mock import patch +import pytest +from homeassistant.components.device_tracker.meraki import ( + CONF_VALIDATOR, CONF_SECRET) +from homeassistant.setup import async_setup_component +import homeassistant.components.device_tracker as device_tracker +from homeassistant.const import CONF_PLATFORM +from homeassistant.components.device_tracker.meraki import URL + + +@pytest.fixture +def meraki_client(loop, hass, test_client): + """Meraki mock client.""" + assert loop.run_until_complete(async_setup_component( + hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'meraki', + CONF_VALIDATOR: 'validator', + CONF_SECRET: 'secret' + + } + })) + + with patch('homeassistant.components.device_tracker.update_config'): + yield loop.run_until_complete(test_client(hass.http.app)) + + +@asyncio.coroutine +def test_invalid_or_missing_data(meraki_client): + """Test validator with invalid or missing data.""" + req = yield from meraki_client.get(URL) + text = yield from req.text() + assert req.status == 200 + assert text == 'validator' + + req = yield from meraki_client.post(URL, data=b"invalid") + text = yield from req.json() + assert req.status == 400 + assert text['message'] == 'Invalid JSON' + + req = yield from meraki_client.post(URL, data=b"{}") + text = yield from req.json() + assert req.status == 422 + assert text['message'] == 'No secret' + + data = { + "version": "1.0", + "secret": "secret" + } + req = yield from meraki_client.post(URL, data=json.dumps(data)) + text = yield from req.json() + assert req.status == 422 + assert text['message'] == 'Invalid version' + + data = { + "version": "2.0", + "secret": "invalid" + } + req = yield from meraki_client.post(URL, data=json.dumps(data)) + text = yield from req.json() + assert req.status == 422 + assert text['message'] == 'Invalid secret' + + data = { + "version": "2.0", + "secret": "secret", + "type": "InvalidType" + } + req = yield from meraki_client.post(URL, data=json.dumps(data)) + text = yield from req.json() + assert req.status == 422 + assert text['message'] == 'Invalid device type' + + data = { + "version": "2.0", + "secret": "secret", + "type": "BluetoothDevicesSeen", + "data": { + "observations": [] + } + } + req = yield from meraki_client.post(URL, data=json.dumps(data)) + assert req.status == 200 + + +@asyncio.coroutine +def test_data_will_be_saved(hass, meraki_client): + """Test with valid data.""" + data = { + "version": "2.0", + "secret": "secret", + "type": "DevicesSeen", + "data": { + "observations": [ + { + "location": { + "lat": "51.5355157", + "lng": "21.0699035", + "unc": "46.3610585", + }, + "seenTime": "2016-09-12T16:23:13Z", + "ssid": 'ssid', + "os": 'HA', + "ipv6": '2607:f0d0:1002:51::4/64', + "clientMac": "00:26:ab:b8:a9:a4", + "seenEpoch": "147369739", + "rssi": "20", + "manufacturer": "Seiko Epson" + }, + { + "location": { + "lat": "51.5355357", + "lng": "21.0699635", + "unc": "46.3610585", + }, + "seenTime": "2016-09-12T16:21:13Z", + "ssid": 'ssid', + "os": 'HA', + "ipv4": '192.168.0.1', + "clientMac": "00:26:ab:b8:a9:a5", + "seenEpoch": "147369750", + "rssi": "20", + "manufacturer": "Seiko Epson" + } + ] + } + } + req = yield from meraki_client.post(URL, data=json.dumps(data)) + assert req.status == 200 + state_name = hass.states.get('{}.{}'.format('device_tracker', + '0026abb8a9a4')).state + assert 'home' == state_name + + state_name = hass.states.get('{}.{}'.format('device_tracker', + '0026abb8a9a5')).state + assert 'home' == state_name diff --git a/tests/components/device_tracker/test_unifi_direct.py b/tests/components/device_tracker/test_unifi_direct.py index 0e22758d07e..b378118141a 100644 --- a/tests/components/device_tracker/test_unifi_direct.py +++ b/tests/components/device_tracker/test_unifi_direct.py @@ -11,7 +11,8 @@ import voluptuous as vol from homeassistant.setup import setup_component from homeassistant.components import device_tracker from homeassistant.components.device_tracker import ( - CONF_CONSIDER_HOME, CONF_TRACK_NEW) + CONF_CONSIDER_HOME, CONF_TRACK_NEW, CONF_AWAY_HIDE, + CONF_NEW_DEVICE_DEFAULTS) from homeassistant.components.device_tracker.unifi_direct import ( DOMAIN, CONF_PORT, PLATFORM_SCHEMA, _response_to_json, get_scanner) from homeassistant.const import (CONF_PLATFORM, CONF_PASSWORD, CONF_USERNAME, @@ -54,7 +55,11 @@ class TestComponentsDeviceTrackerUnifiDirect(unittest.TestCase): CONF_USERNAME: 'fake_user', CONF_PASSWORD: 'fake_pass', CONF_TRACK_NEW: True, - CONF_CONSIDER_HOME: timedelta(seconds=180) + CONF_CONSIDER_HOME: timedelta(seconds=180), + CONF_NEW_DEVICE_DEFAULTS: { + CONF_TRACK_NEW: True, + CONF_AWAY_HIDE: False + } } } diff --git a/tests/components/light/test_hue.py b/tests/components/light/test_hue.py new file mode 100644 index 00000000000..5e5bd4f6c7f --- /dev/null +++ b/tests/components/light/test_hue.py @@ -0,0 +1,479 @@ +"""Philips Hue lights platform tests.""" + +import logging +import unittest +import unittest.mock as mock +from unittest.mock import call, MagicMock, patch + +from homeassistant.components import hue +import homeassistant.components.light.hue as hue_light + +from tests.common import get_test_home_assistant, MockDependency + +_LOGGER = logging.getLogger(__name__) + + +class TestSetup(unittest.TestCase): + """Test the Hue light platform.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.skip_teardown_stop = False + + def tearDown(self): + """Stop everything that was started.""" + if not self.skip_teardown_stop: + self.hass.stop() + + def setup_mocks_for_update_lights(self): + """Set up all mocks for update_lights tests.""" + self.mock_bridge = MagicMock() + self.mock_bridge.allow_hue_groups = False + self.mock_api = MagicMock() + self.mock_bridge.get_api.return_value = self.mock_api + self.mock_bridge_type = MagicMock() + self.mock_lights = [] + self.mock_groups = [] + self.mock_add_devices = MagicMock() + hue_light.setup_data(self.hass) + + def setup_mocks_for_process_lights(self): + """Set up all mocks for process_lights tests.""" + self.mock_bridge = MagicMock() + self.mock_api = MagicMock() + self.mock_api.get.return_value = {} + self.mock_bridge.get_api.return_value = self.mock_api + self.mock_bridge_type = MagicMock() + hue_light.setup_data(self.hass) + + def setup_mocks_for_process_groups(self): + """Set up all mocks for process_groups tests.""" + self.mock_bridge = MagicMock() + self.mock_bridge.get_group.return_value = { + 'name': 'Group 0', 'state': {'any_on': True}} + self.mock_api = MagicMock() + self.mock_api.get.return_value = {} + self.mock_bridge.get_api.return_value = self.mock_api + self.mock_bridge_type = MagicMock() + hue_light.setup_data(self.hass) + + def test_setup_platform_no_discovery_info(self): + """Test setup_platform without discovery info.""" + self.hass.data[hue.DOMAIN] = {} + mock_add_devices = MagicMock() + + hue_light.setup_platform(self.hass, {}, mock_add_devices) + + mock_add_devices.assert_not_called() + + def test_setup_platform_no_bridge_id(self): + """Test setup_platform without a bridge.""" + self.hass.data[hue.DOMAIN] = {} + mock_add_devices = MagicMock() + + hue_light.setup_platform(self.hass, {}, mock_add_devices, {}) + + mock_add_devices.assert_not_called() + + def test_setup_platform_one_bridge(self): + """Test setup_platform with one bridge.""" + mock_bridge = MagicMock() + self.hass.data[hue.DOMAIN] = {'10.0.0.1': mock_bridge} + mock_add_devices = MagicMock() + + with patch('homeassistant.components.light.hue.' + + 'unthrottled_update_lights') as mock_update_lights: + hue_light.setup_platform( + self.hass, {}, mock_add_devices, + {'bridge_id': '10.0.0.1'}) + mock_update_lights.assert_called_once_with( + self.hass, mock_bridge, mock_add_devices) + + def test_setup_platform_multiple_bridges(self): + """Test setup_platform wuth multiple bridges.""" + mock_bridge = MagicMock() + mock_bridge2 = MagicMock() + self.hass.data[hue.DOMAIN] = { + '10.0.0.1': mock_bridge, + '192.168.0.10': mock_bridge2, + } + mock_add_devices = MagicMock() + + with patch('homeassistant.components.light.hue.' + + 'unthrottled_update_lights') as mock_update_lights: + hue_light.setup_platform( + self.hass, {}, mock_add_devices, + {'bridge_id': '10.0.0.1'}) + hue_light.setup_platform( + self.hass, {}, mock_add_devices, + {'bridge_id': '192.168.0.10'}) + + mock_update_lights.assert_has_calls([ + call(self.hass, mock_bridge, mock_add_devices), + call(self.hass, mock_bridge2, mock_add_devices), + ]) + + @MockDependency('phue') + def test_update_lights_with_no_lights(self, mock_phue): + """Test the update_lights function when no lights are found.""" + self.setup_mocks_for_update_lights() + + with patch('homeassistant.components.light.hue.get_bridge_type', + return_value=self.mock_bridge_type): + with patch('homeassistant.components.light.hue.process_lights', + return_value=[]) as mock_process_lights: + with patch('homeassistant.components.light.hue.process_groups', + return_value=self.mock_groups) \ + as mock_process_groups: + hue_light.unthrottled_update_lights( + self.hass, self.mock_bridge, self.mock_add_devices) + + mock_process_lights.assert_called_once_with( + self.hass, self.mock_api, self.mock_bridge, + self.mock_bridge_type, mock.ANY) + mock_process_groups.assert_not_called() + self.mock_add_devices.assert_not_called() + + @MockDependency('phue') + def test_update_lights_with_some_lights(self, mock_phue): + """Test the update_lights function with some lights.""" + self.setup_mocks_for_update_lights() + self.mock_lights = ['some', 'light'] + + with patch('homeassistant.components.light.hue.get_bridge_type', + return_value=self.mock_bridge_type): + with patch('homeassistant.components.light.hue.process_lights', + return_value=self.mock_lights) as mock_process_lights: + with patch('homeassistant.components.light.hue.process_groups', + return_value=self.mock_groups) \ + as mock_process_groups: + hue_light.unthrottled_update_lights( + self.hass, self.mock_bridge, self.mock_add_devices) + + mock_process_lights.assert_called_once_with( + self.hass, self.mock_api, self.mock_bridge, + self.mock_bridge_type, mock.ANY) + mock_process_groups.assert_not_called() + self.mock_add_devices.assert_called_once_with( + self.mock_lights) + + @MockDependency('phue') + def test_update_lights_no_groups(self, mock_phue): + """Test the update_lights function when no groups are found.""" + self.setup_mocks_for_update_lights() + self.mock_bridge.allow_hue_groups = True + self.mock_lights = ['some', 'light'] + + with patch('homeassistant.components.light.hue.get_bridge_type', + return_value=self.mock_bridge_type): + with patch('homeassistant.components.light.hue.process_lights', + return_value=self.mock_lights) as mock_process_lights: + with patch('homeassistant.components.light.hue.process_groups', + return_value=self.mock_groups) \ + as mock_process_groups: + hue_light.unthrottled_update_lights( + self.hass, self.mock_bridge, self.mock_add_devices) + + mock_process_lights.assert_called_once_with( + self.hass, self.mock_api, self.mock_bridge, + self.mock_bridge_type, mock.ANY) + mock_process_groups.assert_called_once_with( + self.hass, self.mock_api, self.mock_bridge, + self.mock_bridge_type, mock.ANY) + self.mock_add_devices.assert_called_once_with( + self.mock_lights) + + @MockDependency('phue') + def test_update_lights_with_lights_and_groups(self, mock_phue): + """Test the update_lights function with both lights and groups.""" + self.setup_mocks_for_update_lights() + self.mock_bridge.allow_hue_groups = True + self.mock_lights = ['some', 'light'] + self.mock_groups = ['and', 'groups'] + + with patch('homeassistant.components.light.hue.get_bridge_type', + return_value=self.mock_bridge_type): + with patch('homeassistant.components.light.hue.process_lights', + return_value=self.mock_lights) as mock_process_lights: + with patch('homeassistant.components.light.hue.process_groups', + return_value=self.mock_groups) \ + as mock_process_groups: + hue_light.unthrottled_update_lights( + self.hass, self.mock_bridge, self.mock_add_devices) + + mock_process_lights.assert_called_once_with( + self.hass, self.mock_api, self.mock_bridge, + self.mock_bridge_type, mock.ANY) + mock_process_groups.assert_called_once_with( + self.hass, self.mock_api, self.mock_bridge, + self.mock_bridge_type, mock.ANY) + self.mock_add_devices.assert_called_once_with( + self.mock_lights) + + def test_process_lights_api_error(self): + """Test the process_lights function when the bridge errors out.""" + self.setup_mocks_for_process_lights() + self.mock_api.get.return_value = None + + ret = hue_light.process_lights( + self.hass, self.mock_api, self.mock_bridge, self.mock_bridge_type, + None) + + self.assertEquals([], ret) + self.assertEquals( + {}, + self.hass.data[hue_light.DATA_KEY][hue_light.DATA_LIGHTS]) + + def test_process_lights_no_lights(self): + """Test the process_lights function when bridge returns no lights.""" + self.setup_mocks_for_process_lights() + + ret = hue_light.process_lights( + self.hass, self.mock_api, self.mock_bridge, self.mock_bridge_type, + None) + + self.assertEquals([], ret) + self.assertEquals( + {}, + self.hass.data[hue_light.DATA_KEY][hue_light.DATA_LIGHTS]) + + @patch('homeassistant.components.light.hue.HueLight') + def test_process_lights_some_lights(self, mock_hue_light): + """Test the process_lights function with multiple groups.""" + self.setup_mocks_for_process_lights() + self.mock_api.get.return_value = { + 1: {'state': 'on'}, 2: {'state': 'off'}} + + ret = hue_light.process_lights( + self.hass, self.mock_api, self.mock_bridge, self.mock_bridge_type, + None) + + self.assertEquals(len(ret), 2) + mock_hue_light.assert_has_calls([ + call( + 1, {'state': 'on'}, self.mock_bridge, mock.ANY, + self.mock_bridge_type, self.mock_bridge.allow_unreachable, + self.mock_bridge.allow_in_emulated_hue), + call( + 2, {'state': 'off'}, self.mock_bridge, mock.ANY, + self.mock_bridge_type, self.mock_bridge.allow_unreachable, + self.mock_bridge.allow_in_emulated_hue), + ]) + self.assertEquals( + len(self.hass.data[hue_light.DATA_KEY][hue_light.DATA_LIGHTS]), + 2) + + @patch('homeassistant.components.light.hue.HueLight') + def test_process_lights_new_light(self, mock_hue_light): + """ + Test the process_lights function with new groups. + + Test what happens when we already have a light and a new one shows up. + """ + self.setup_mocks_for_process_lights() + self.mock_api.get.return_value = { + 1: {'state': 'on'}, 2: {'state': 'off'}} + self.hass.data[ + hue_light.DATA_KEY][hue_light.DATA_LIGHTS][1] = MagicMock() + + ret = hue_light.process_lights( + self.hass, self.mock_api, self.mock_bridge, self.mock_bridge_type, + None) + + self.assertEquals(len(ret), 1) + mock_hue_light.assert_has_calls([ + call( + 2, {'state': 'off'}, self.mock_bridge, mock.ANY, + self.mock_bridge_type, self.mock_bridge.allow_unreachable, + self.mock_bridge.allow_in_emulated_hue), + ]) + self.hass.data[hue_light.DATA_KEY][hue_light.DATA_LIGHTS][ + 1].schedule_update_ha_state.assert_called_once_with() + self.assertEquals( + len(self.hass.data[hue_light.DATA_KEY][hue_light.DATA_LIGHTS]), + 2) + + def test_process_groups_api_error(self): + """Test the process_groups function when the bridge errors out.""" + self.setup_mocks_for_process_groups() + self.mock_api.get.return_value = None + + ret = hue_light.process_groups( + self.hass, self.mock_api, self.mock_bridge, self.mock_bridge_type, + None) + + self.assertEquals([], ret) + self.assertEquals( + {}, + self.hass.data[hue_light.DATA_KEY][hue_light.DATA_LIGHTGROUPS]) + + def test_process_groups_no_state(self): + """Test the process_groups function when bridge returns no status.""" + self.setup_mocks_for_process_groups() + self.mock_bridge.get_group.return_value = {'name': 'Group 0'} + + ret = hue_light.process_groups( + self.hass, self.mock_api, self.mock_bridge, self.mock_bridge_type, + None) + + self.assertEquals([], ret) + self.assertEquals( + {}, + self.hass.data[hue_light.DATA_KEY][hue_light.DATA_LIGHTGROUPS]) + + @patch('homeassistant.components.light.hue.HueLight') + def test_process_groups_some_groups(self, mock_hue_light): + """Test the process_groups function with multiple groups.""" + self.setup_mocks_for_process_groups() + self.mock_api.get.return_value = { + 1: {'state': 'on'}, 2: {'state': 'off'}} + + ret = hue_light.process_groups( + self.hass, self.mock_api, self.mock_bridge, self.mock_bridge_type, + None) + + self.assertEquals(len(ret), 2) + mock_hue_light.assert_has_calls([ + call( + 1, {'state': 'on'}, self.mock_bridge, mock.ANY, + self.mock_bridge_type, self.mock_bridge.allow_unreachable, + self.mock_bridge.allow_in_emulated_hue, True), + call( + 2, {'state': 'off'}, self.mock_bridge, mock.ANY, + self.mock_bridge_type, self.mock_bridge.allow_unreachable, + self.mock_bridge.allow_in_emulated_hue, True), + ]) + self.assertEquals( + len(self.hass.data[ + hue_light.DATA_KEY][hue_light.DATA_LIGHTGROUPS]), + 2) + + @patch('homeassistant.components.light.hue.HueLight') + def test_process_groups_new_group(self, mock_hue_light): + """ + Test the process_groups function with new groups. + + Test what happens when we already have a light and a new one shows up. + """ + self.setup_mocks_for_process_groups() + self.mock_api.get.return_value = { + 1: {'state': 'on'}, 2: {'state': 'off'}} + self.hass.data[ + hue_light.DATA_KEY][hue_light.DATA_LIGHTGROUPS][1] = MagicMock() + + ret = hue_light.process_groups( + self.hass, self.mock_api, self.mock_bridge, self.mock_bridge_type, + None) + + self.assertEquals(len(ret), 1) + mock_hue_light.assert_has_calls([ + call( + 2, {'state': 'off'}, self.mock_bridge, mock.ANY, + self.mock_bridge_type, self.mock_bridge.allow_unreachable, + self.mock_bridge.allow_in_emulated_hue, True), + ]) + self.hass.data[hue_light.DATA_KEY][hue_light.DATA_LIGHTGROUPS][ + 1].schedule_update_ha_state.assert_called_once_with() + self.assertEquals( + len(self.hass.data[ + hue_light.DATA_KEY][hue_light.DATA_LIGHTGROUPS]), + 2) + + +class TestHueLight(unittest.TestCase): + """Test the HueLight class.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.skip_teardown_stop = False + + self.light_id = 42 + self.mock_info = MagicMock() + self.mock_bridge = MagicMock() + self.mock_update_lights = MagicMock() + self.mock_bridge_type = MagicMock() + self.mock_allow_unreachable = MagicMock() + self.mock_is_group = MagicMock() + self.mock_allow_in_emulated_hue = MagicMock() + self.mock_is_group = False + + def tearDown(self): + """Stop everything that was started.""" + if not self.skip_teardown_stop: + self.hass.stop() + + def buildLight( + self, light_id=None, info=None, update_lights=None, is_group=None): + """Helper to build a HueLight object with minimal fuss.""" + return hue_light.HueLight( + light_id if light_id is not None else self.light_id, + info if info is not None else self.mock_info, + self.mock_bridge, + (update_lights + if update_lights is not None + else self.mock_update_lights), + self.mock_bridge_type, + self.mock_allow_unreachable, self.mock_allow_in_emulated_hue, + is_group if is_group is not None else self.mock_is_group) + + def test_unique_id_for_light(self): + """Test the unique_id method with lights.""" + class_name = "" + + light = self.buildLight(info={'uniqueid': 'foobar'}) + self.assertEquals( + class_name+'.foobar', + light.unique_id) + + light = self.buildLight(info={}) + self.assertEquals( + class_name+'.Unnamed Device.Light.42', + light.unique_id) + + light = self.buildLight(info={'name': 'my-name'}) + self.assertEquals( + class_name+'.my-name.Light.42', + light.unique_id) + + light = self.buildLight(info={'type': 'my-type'}) + self.assertEquals( + class_name+'.Unnamed Device.my-type.42', + light.unique_id) + + light = self.buildLight(info={'name': 'a name', 'type': 'my-type'}) + self.assertEquals( + class_name+'.a name.my-type.42', + light.unique_id) + + def test_unique_id_for_group(self): + """Test the unique_id method with groups.""" + class_name = "" + + light = self.buildLight(info={'uniqueid': 'foobar'}, is_group=True) + self.assertEquals( + class_name+'.foobar', + light.unique_id) + + light = self.buildLight(info={}, is_group=True) + self.assertEquals( + class_name+'.Unnamed Device.Group.42', + light.unique_id) + + light = self.buildLight(info={'name': 'my-name'}, is_group=True) + self.assertEquals( + class_name+'.my-name.Group.42', + light.unique_id) + + light = self.buildLight(info={'type': 'my-type'}, is_group=True) + self.assertEquals( + class_name+'.Unnamed Device.my-type.42', + light.unique_id) + + light = self.buildLight( + info={'name': 'a name', 'type': 'my-type'}, + is_group=True) + self.assertEquals( + class_name+'.a name.my-type.42', + light.unique_id) diff --git a/tests/components/light/test_mochad.py b/tests/components/light/test_mochad.py index e69ebdb4aef..5c82ab06085 100644 --- a/tests/components/light/test_mochad.py +++ b/tests/components/light/test_mochad.py @@ -60,7 +60,8 @@ class TestMochadLight(unittest.TestCase): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() controller_mock = mock.MagicMock() - dev_dict = {'address': 'a1', 'name': 'fake_light'} + dev_dict = {'address': 'a1', 'name': 'fake_light', + 'brightness_levels': 32} self.light = mochad.MochadLight(self.hass, controller_mock, dev_dict) @@ -72,6 +73,39 @@ class TestMochadLight(unittest.TestCase): """Test the name.""" self.assertEqual('fake_light', self.light.name) + def test_turn_on_with_no_brightness(self): + """Test turn_on.""" + self.light.turn_on() + self.light.device.send_cmd.assert_called_once_with('on') + + def test_turn_on_with_brightness(self): + """Test turn_on.""" + self.light.turn_on(brightness=45) + self.light.device.send_cmd.assert_has_calls( + [mock.call('on'), mock.call('dim 25')]) + + def test_turn_off(self): + """Test turn_off.""" + self.light.turn_off() + self.light.device.send_cmd.assert_called_once_with('off') + + +class TestMochadLight256Levels(unittest.TestCase): + """Test for mochad light platform.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + controller_mock = mock.MagicMock() + dev_dict = {'address': 'a1', 'name': 'fake_light', + 'brightness_levels': 256} + self.light = mochad.MochadLight(self.hass, controller_mock, + dev_dict) + + def teardown_method(self, method): + """Stop everything that was started.""" + self.hass.stop() + def test_turn_on_with_no_brightness(self): """Test turn_on.""" self.light.turn_on() @@ -86,3 +120,35 @@ class TestMochadLight(unittest.TestCase): """Test turn_off.""" self.light.turn_off() self.light.device.send_cmd.assert_called_once_with('off') + + +class TestMochadLight64Levels(unittest.TestCase): + """Test for mochad light platform.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + controller_mock = mock.MagicMock() + dev_dict = {'address': 'a1', 'name': 'fake_light', + 'brightness_levels': 64} + self.light = mochad.MochadLight(self.hass, controller_mock, + dev_dict) + + def teardown_method(self, method): + """Stop everything that was started.""" + self.hass.stop() + + def test_turn_on_with_no_brightness(self): + """Test turn_on.""" + self.light.turn_on() + self.light.device.send_cmd.assert_called_once_with('xdim 63') + + def test_turn_on_with_brightness(self): + """Test turn_on.""" + self.light.turn_on(brightness=45) + self.light.device.send_cmd.assert_called_once_with('xdim 11') + + def test_turn_off(self): + """Test turn_off.""" + self.light.turn_off() + self.light.device.send_cmd.assert_called_once_with('off') diff --git a/tests/components/media_player/test_sonos.py b/tests/components/media_player/test_sonos.py index 8c62c6c84e9..815204e718a 100644 --- a/tests/components/media_player/test_sonos.py +++ b/tests/components/media_player/test_sonos.py @@ -281,6 +281,20 @@ class TestSonosMediaPlayer(unittest.TestCase): self.assertEqual(unjoinMock.call_count, 1) self.assertEqual(unjoinMock.call_args, mock.call()) + @mock.patch('soco.SoCo', new=SoCoMock) + @mock.patch('socket.create_connection', side_effect=socket.error()) + def test_set_shuffle(self, shuffle_set_mock, *args): + """Ensuring soco methods called for sonos_snapshot service.""" + sonos.setup_platform(self.hass, {}, fake_add_device, { + 'host': '192.0.2.1' + }) + device = self.hass.data[sonos.DATA_SONOS][-1] + device.hass = self.hass + + device.set_shuffle(True) + self.assertEqual(shuffle_set_mock.call_count, 1) + self.assertEqual(device._player.play_mode, 'SHUFFLE') + @mock.patch('soco.SoCo', new=SoCoMock) @mock.patch('socket.create_connection', side_effect=socket.error()) @mock.patch.object(SoCoMock, 'set_sleep_timer') @@ -375,3 +389,21 @@ class TestSonosMediaPlayer(unittest.TestCase): device.restore() self.assertEqual(restoreMock.call_count, 1) self.assertEqual(restoreMock.call_args, mock.call(False)) + + @mock.patch('soco.SoCo', new=SoCoMock) + @mock.patch('socket.create_connection', side_effect=socket.error()) + def test_sonos_set_option(self, option_mock, *args): + """Ensuring soco methods called for sonos_set_option service.""" + sonos.setup_platform(self.hass, {}, fake_add_device, { + 'host': '192.0.2.1' + }) + device = self.hass.data[sonos.DATA_SONOS][-1] + device.hass = self.hass + + option_mock.return_value = True + device._snapshot_coordinator = mock.MagicMock() + device._snapshot_coordinator.soco_device = SoCoMock('192.0.2.17') + + device.update_option(night_sound=True, speech_enhance=True) + + self.assertEqual(option_mock.call_count, 1) diff --git a/tests/components/sensor/test_canary.py b/tests/components/sensor/test_canary.py new file mode 100644 index 00000000000..99df05f36a4 --- /dev/null +++ b/tests/components/sensor/test_canary.py @@ -0,0 +1,125 @@ +"""The tests for the Canary sensor platform.""" +import copy +import unittest +from unittest.mock import patch, Mock + +from canary.api import SensorType +from homeassistant.components import canary as base_canary +from homeassistant.components.canary import DATA_CANARY +from homeassistant.components.sensor import canary +from homeassistant.components.sensor.canary import CanarySensor +from tests.common import (get_test_home_assistant) +from tests.components.test_canary import mock_device, mock_reading, \ + mock_location + +VALID_CONFIG = { + "canary": { + "username": "foo@bar.org", + "password": "bar", + } +} + + +class TestCanarySensorSetup(unittest.TestCase): + """Test the Canary platform.""" + + DEVICES = [] + + def add_devices(self, devices, action): + """Mock add devices.""" + for device in devices: + self.DEVICES.append(device) + + def setUp(self): + """Initialize values for this testcase class.""" + self.hass = get_test_home_assistant() + self.config = copy.deepcopy(VALID_CONFIG) + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + @patch('homeassistant.components.canary.CanaryData') + def test_setup_sensors(self, mock_canary): + """Test the sensor setup.""" + base_canary.setup(self.hass, self.config) + + online_device_at_home = mock_device(20, "Dining Room", True) + offline_device_at_home = mock_device(21, "Front Yard", False) + online_device_at_work = mock_device(22, "Office", True) + + self.hass.data[DATA_CANARY] = mock_canary() + self.hass.data[DATA_CANARY].locations = [ + mock_location("Home", True, devices=[online_device_at_home, + offline_device_at_home]), + mock_location("Work", True, devices=[online_device_at_work]), + ] + + canary.setup_platform(self.hass, self.config, self.add_devices, None) + + self.assertEqual(6, len(self.DEVICES)) + + def test_celsius_temperature_sensor(self): + """Test temperature sensor with celsius.""" + device = mock_device(10, "Family Room") + location = mock_location("Home", True) + + data = Mock() + data.get_readings.return_value = [ + mock_reading(SensorType.TEMPERATURE, 21.1234)] + + sensor = CanarySensor(data, SensorType.TEMPERATURE, location, device) + sensor.update() + + self.assertEqual("Home Family Room Temperature", sensor.name) + self.assertEqual("sensor_canary_10_temperature", sensor.unique_id) + self.assertEqual("°C", sensor.unit_of_measurement) + self.assertEqual(21.1, sensor.state) + + def test_fahrenheit_temperature_sensor(self): + """Test temperature sensor with fahrenheit.""" + device = mock_device(10, "Family Room") + location = mock_location("Home", False) + + data = Mock() + data.get_readings.return_value = [ + mock_reading(SensorType.TEMPERATURE, 21.1567)] + + sensor = CanarySensor(data, SensorType.TEMPERATURE, location, device) + sensor.update() + + self.assertEqual("Home Family Room Temperature", sensor.name) + self.assertEqual("°F", sensor.unit_of_measurement) + self.assertEqual(21.2, sensor.state) + + def test_humidity_sensor(self): + """Test humidity sensor.""" + device = mock_device(10, "Family Room") + location = mock_location("Home") + + data = Mock() + data.get_readings.return_value = [ + mock_reading(SensorType.HUMIDITY, 50.4567)] + + sensor = CanarySensor(data, SensorType.HUMIDITY, location, device) + sensor.update() + + self.assertEqual("Home Family Room Humidity", sensor.name) + self.assertEqual("%", sensor.unit_of_measurement) + self.assertEqual(50.5, sensor.state) + + def test_air_quality_sensor(self): + """Test air quality sensor.""" + device = mock_device(10, "Family Room") + location = mock_location("Home") + + data = Mock() + data.get_readings.return_value = [ + mock_reading(SensorType.AIR_QUALITY, 50.4567)] + + sensor = CanarySensor(data, SensorType.AIR_QUALITY, location, device) + sensor.update() + + self.assertEqual("Home Family Room Air Quality", sensor.name) + self.assertEqual("", sensor.unit_of_measurement) + self.assertEqual(50.5, sensor.state) diff --git a/tests/components/sensor/test_rest.py b/tests/components/sensor/test_rest.py index a083dbfb1a2..eddab8caf4d 100644 --- a/tests/components/sensor/test_rest.py +++ b/tests/components/sensor/test_rest.py @@ -132,10 +132,12 @@ class TestRestSensor(unittest.TestCase): self.unit_of_measurement = 'MB' self.value_template = template('{{ value_json.key }}') self.value_template.hass = self.hass + self.force_update = False self.sensor = rest.RestSensor( self.hass, self.rest, self.name, self.unit_of_measurement, - self.value_template) + self.value_template, [], self.force_update + ) def tearDown(self): """Stop everything that was started.""" @@ -154,6 +156,11 @@ class TestRestSensor(unittest.TestCase): self.assertEqual( self.unit_of_measurement, self.sensor.unit_of_measurement) + def test_force_update(self): + """Test the unit of measurement.""" + self.assertEqual( + self.force_update, self.sensor.force_update) + def test_state(self): """Test the initial state.""" self.sensor.update() @@ -181,12 +188,68 @@ class TestRestSensor(unittest.TestCase): self.rest.update = Mock('rest.RestData.update', side_effect=self.update_side_effect( 'plain_state')) - self.sensor = rest.RestSensor( - self.hass, self.rest, self.name, self.unit_of_measurement, None) + self.sensor = rest.RestSensor(self.hass, self.rest, self.name, + self.unit_of_measurement, None, [], + self.force_update) self.sensor.update() self.assertEqual('plain_state', self.sensor.state) self.assertTrue(self.sensor.available) + def test_update_with_json_attrs(self): + """Test attributes get extracted from a JSON result.""" + self.rest.update = Mock('rest.RestData.update', + side_effect=self.update_side_effect( + '{ "key": "some_json_value" }')) + self.sensor = rest.RestSensor(self.hass, self.rest, self.name, + self.unit_of_measurement, None, ['key'], + self.force_update) + self.sensor.update() + self.assertEqual('some_json_value', + self.sensor.device_state_attributes['key']) + + @patch('homeassistant.components.sensor.rest._LOGGER') + def test_update_with_json_attrs_not_dict(self, mock_logger): + """Test attributes get extracted from a JSON result.""" + self.rest.update = Mock('rest.RestData.update', + side_effect=self.update_side_effect( + '["list", "of", "things"]')) + self.sensor = rest.RestSensor(self.hass, self.rest, self.name, + self.unit_of_measurement, None, ['key'], + self.force_update) + self.sensor.update() + self.assertEqual({}, self.sensor.device_state_attributes) + self.assertTrue(mock_logger.warning.called) + + @patch('homeassistant.components.sensor.rest._LOGGER') + def test_update_with_json_attrs_bad_JSON(self, mock_logger): + """Test attributes get extracted from a JSON result.""" + self.rest.update = Mock('rest.RestData.update', + side_effect=self.update_side_effect( + 'This is text rather than JSON data.')) + self.sensor = rest.RestSensor(self.hass, self.rest, self.name, + self.unit_of_measurement, None, ['key'], + self.force_update) + self.sensor.update() + self.assertEqual({}, self.sensor.device_state_attributes) + self.assertTrue(mock_logger.warning.called) + self.assertTrue(mock_logger.debug.called) + + def test_update_with_json_attrs_and_template(self): + """Test attributes get extracted from a JSON result.""" + self.rest.update = Mock('rest.RestData.update', + side_effect=self.update_side_effect( + '{ "key": "json_state_updated_value" }')) + self.sensor = rest.RestSensor(self.hass, self.rest, self.name, + self.unit_of_measurement, + self.value_template, ['key'], + self.force_update) + self.sensor.update() + + self.assertEqual('json_state_updated_value', self.sensor.state) + self.assertEqual('json_state_updated_value', + self.sensor.device_state_attributes['key'], + self.force_update) + class TestRestData(unittest.TestCase): """Tests for RestData.""" diff --git a/tests/components/test_canary.py b/tests/components/test_canary.py new file mode 100644 index 00000000000..67122813fb7 --- /dev/null +++ b/tests/components/test_canary.py @@ -0,0 +1,85 @@ +"""The tests for the Canary component.""" +import unittest +from unittest.mock import patch, MagicMock, PropertyMock + +import homeassistant.components.canary as canary +from homeassistant import setup +from tests.common import ( + get_test_home_assistant) + + +def mock_device(device_id, name, is_online=True): + """Mock Canary Device class.""" + device = MagicMock() + type(device).device_id = PropertyMock(return_value=device_id) + type(device).name = PropertyMock(return_value=name) + type(device).is_online = PropertyMock(return_value=is_online) + return device + + +def mock_location(name, is_celsius=True, devices=[]): + """Mock Canary Location class.""" + location = MagicMock() + type(location).name = PropertyMock(return_value=name) + type(location).is_celsius = PropertyMock(return_value=is_celsius) + type(location).devices = PropertyMock(return_value=devices) + return location + + +def mock_reading(sensor_type, sensor_value): + """Mock Canary Reading class.""" + reading = MagicMock() + type(reading).sensor_type = PropertyMock(return_value=sensor_type) + type(reading).value = PropertyMock(return_value=sensor_value) + return reading + + +class TestCanary(unittest.TestCase): + """Tests the Canary component.""" + + def setUp(self): + """Initialize values for this test case class.""" + self.hass = get_test_home_assistant() + + def tearDown(self): # pylint: disable=invalid-name + """Stop everything that was started.""" + self.hass.stop() + + @patch('homeassistant.components.canary.CanaryData.update') + @patch('canary.api.Api.login') + def test_setup_with_valid_config(self, mock_login, mock_update): + """Test setup component.""" + config = { + "canary": { + "username": "foo@bar.org", + "password": "bar", + } + } + + self.assertTrue( + setup.setup_component(self.hass, canary.DOMAIN, config)) + + mock_update.assert_called_once_with() + mock_login.assert_called_once_with() + + def test_setup_with_missing_password(self): + """Test setup component.""" + config = { + "canary": { + "username": "foo@bar.org", + } + } + + self.assertFalse( + setup.setup_component(self.hass, canary.DOMAIN, config)) + + def test_setup_with_missing_username(self): + """Test setup component.""" + config = { + "canary": { + "password": "bar", + } + } + + self.assertFalse( + setup.setup_component(self.hass, canary.DOMAIN, config)) diff --git a/tests/components/test_hue.py b/tests/components/test_hue.py new file mode 100644 index 00000000000..227295594db --- /dev/null +++ b/tests/components/test_hue.py @@ -0,0 +1,402 @@ +"""Generic Philips Hue component tests.""" + +import logging +import unittest +from unittest.mock import call, MagicMock, patch + +from homeassistant.components import configurator, hue +from homeassistant.const import CONF_FILENAME, CONF_HOST +from homeassistant.setup import setup_component + +from tests.common import ( + assert_setup_component, get_test_home_assistant, get_test_config_dir, + MockDependency +) + +_LOGGER = logging.getLogger(__name__) + + +class TestSetup(unittest.TestCase): + """Test the Hue component.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.skip_teardown_stop = False + + def tearDown(self): + """Stop everything that was started.""" + if not self.skip_teardown_stop: + self.hass.stop() + + @MockDependency('phue') + def test_setup_no_domain(self, mock_phue): + """If it's not in the config we won't even try.""" + with assert_setup_component(0): + self.assertTrue(setup_component( + self.hass, hue.DOMAIN, {})) + mock_phue.Bridge.assert_not_called() + self.assertEquals({}, self.hass.data[hue.DOMAIN]) + + @MockDependency('phue') + def test_setup_no_host(self, mock_phue): + """No host specified in any way.""" + with assert_setup_component(1): + self.assertTrue(setup_component( + self.hass, hue.DOMAIN, {hue.DOMAIN: {}})) + mock_phue.Bridge.assert_not_called() + self.assertEquals({}, self.hass.data[hue.DOMAIN]) + + @MockDependency('phue') + def test_setup_with_host(self, mock_phue): + """Host specified in the config file.""" + mock_bridge = mock_phue.Bridge + + with assert_setup_component(1): + with patch('homeassistant.helpers.discovery.load_platform') \ + as mock_load: + self.assertTrue(setup_component( + self.hass, hue.DOMAIN, + {hue.DOMAIN: {hue.CONF_BRIDGES: [ + {CONF_HOST: 'localhost'}]}})) + + mock_bridge.assert_called_once_with( + 'localhost', + config_file_path=get_test_config_dir(hue.PHUE_CONFIG_FILE)) + mock_load.assert_called_once_with( + self.hass, 'light', hue.DOMAIN, + {'bridge_id': '127.0.0.1'}) + + self.assertTrue(hue.DOMAIN in self.hass.data) + self.assertEquals(1, len(self.hass.data[hue.DOMAIN])) + + @MockDependency('phue') + def test_setup_with_phue_conf(self, mock_phue): + """No host in the config file, but one is cached in phue.conf.""" + mock_bridge = mock_phue.Bridge + + with assert_setup_component(1): + with patch( + 'homeassistant.components.hue._find_host_from_config', + return_value='localhost'): + with patch('homeassistant.helpers.discovery.load_platform') \ + as mock_load: + self.assertTrue(setup_component( + self.hass, hue.DOMAIN, + {hue.DOMAIN: {hue.CONF_BRIDGES: [ + {CONF_FILENAME: 'phue.conf'}]}})) + + mock_bridge.assert_called_once_with( + 'localhost', + config_file_path=get_test_config_dir( + hue.PHUE_CONFIG_FILE)) + mock_load.assert_called_once_with( + self.hass, 'light', hue.DOMAIN, + {'bridge_id': '127.0.0.1'}) + + self.assertTrue(hue.DOMAIN in self.hass.data) + self.assertEquals(1, len(self.hass.data[hue.DOMAIN])) + + @MockDependency('phue') + def test_setup_with_multiple_hosts(self, mock_phue): + """Multiple hosts specified in the config file.""" + mock_bridge = mock_phue.Bridge + + with assert_setup_component(1): + with patch('homeassistant.helpers.discovery.load_platform') \ + as mock_load: + self.assertTrue(setup_component( + self.hass, hue.DOMAIN, + {hue.DOMAIN: {hue.CONF_BRIDGES: [ + {CONF_HOST: 'localhost'}, + {CONF_HOST: '192.168.0.1'}]}})) + + mock_bridge.assert_has_calls([ + call( + 'localhost', + config_file_path=get_test_config_dir( + hue.PHUE_CONFIG_FILE)), + call( + '192.168.0.1', + config_file_path=get_test_config_dir( + hue.PHUE_CONFIG_FILE))]) + mock_load.mock_bridge.assert_not_called() + mock_load.assert_has_calls([ + call( + self.hass, 'light', hue.DOMAIN, + {'bridge_id': '127.0.0.1'}), + call( + self.hass, 'light', hue.DOMAIN, + {'bridge_id': '192.168.0.1'}), + ], any_order=True) + + self.assertTrue(hue.DOMAIN in self.hass.data) + self.assertEquals(2, len(self.hass.data[hue.DOMAIN])) + + @MockDependency('phue') + def test_bridge_discovered(self, mock_phue): + """Bridge discovery.""" + mock_bridge = mock_phue.Bridge + mock_service = MagicMock() + discovery_info = {'host': '192.168.0.10', 'serial': 'foobar'} + + with patch('homeassistant.helpers.discovery.load_platform') \ + as mock_load: + self.assertTrue(setup_component( + self.hass, hue.DOMAIN, {})) + hue.bridge_discovered(self.hass, mock_service, discovery_info) + + mock_bridge.assert_called_once_with( + '192.168.0.10', + config_file_path=get_test_config_dir('phue-foobar.conf')) + mock_load.assert_called_once_with( + self.hass, 'light', hue.DOMAIN, + {'bridge_id': '192.168.0.10'}) + + self.assertTrue(hue.DOMAIN in self.hass.data) + self.assertEquals(1, len(self.hass.data[hue.DOMAIN])) + + @MockDependency('phue') + def test_bridge_configure_and_discovered(self, mock_phue): + """Bridge is in the config file, then we discover it.""" + mock_bridge = mock_phue.Bridge + mock_service = MagicMock() + discovery_info = {'host': '192.168.1.10', 'serial': 'foobar'} + + with assert_setup_component(1): + with patch('homeassistant.helpers.discovery.load_platform') \ + as mock_load: + # First we set up the component from config + self.assertTrue(setup_component( + self.hass, hue.DOMAIN, + {hue.DOMAIN: {hue.CONF_BRIDGES: [ + {CONF_HOST: '192.168.1.10'}]}})) + + mock_bridge.assert_called_once_with( + '192.168.1.10', + config_file_path=get_test_config_dir( + hue.PHUE_CONFIG_FILE)) + calls_to_mock_load = [ + call( + self.hass, 'light', hue.DOMAIN, + {'bridge_id': '192.168.1.10'}), + ] + mock_load.assert_has_calls(calls_to_mock_load) + + self.assertTrue(hue.DOMAIN in self.hass.data) + self.assertEquals(1, len(self.hass.data[hue.DOMAIN])) + + # Then we discover the same bridge + hue.bridge_discovered(self.hass, mock_service, discovery_info) + + # No additional calls + mock_bridge.assert_called_once_with( + '192.168.1.10', + config_file_path=get_test_config_dir( + hue.PHUE_CONFIG_FILE)) + mock_load.assert_has_calls(calls_to_mock_load) + + # Still only one + self.assertTrue(hue.DOMAIN in self.hass.data) + self.assertEquals(1, len(self.hass.data[hue.DOMAIN])) + + +class TestHueBridge(unittest.TestCase): + """Test the HueBridge class.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.hass.data[hue.DOMAIN] = {} + self.skip_teardown_stop = False + + def tearDown(self): + """Stop everything that was started.""" + if not self.skip_teardown_stop: + self.hass.stop() + + @MockDependency('phue') + def test_setup_bridge_connection_refused(self, mock_phue): + """Test a registration failed with a connection refused exception.""" + mock_bridge = mock_phue.Bridge + mock_bridge.side_effect = ConnectionRefusedError() + + bridge = hue.HueBridge('localhost', self.hass, hue.PHUE_CONFIG_FILE) + bridge.setup() + self.assertFalse(bridge.configured) + self.assertTrue(bridge.config_request_id is None) + + mock_bridge.assert_called_once_with( + 'localhost', + config_file_path=get_test_config_dir(hue.PHUE_CONFIG_FILE)) + + @MockDependency('phue') + def test_setup_bridge_registration_exception(self, mock_phue): + """Test a registration failed with an exception.""" + mock_bridge = mock_phue.Bridge + mock_phue.PhueRegistrationException = Exception + mock_bridge.side_effect = mock_phue.PhueRegistrationException(1, 2) + + bridge = hue.HueBridge('localhost', self.hass, hue.PHUE_CONFIG_FILE) + bridge.setup() + self.assertFalse(bridge.configured) + self.assertFalse(bridge.config_request_id is None) + self.assertTrue(isinstance(bridge.config_request_id, str)) + + mock_bridge.assert_called_once_with( + 'localhost', + config_file_path=get_test_config_dir(hue.PHUE_CONFIG_FILE)) + + @MockDependency('phue') + def test_setup_bridge_registration_succeeds(self, mock_phue): + """Test a registration success sequence.""" + mock_bridge = mock_phue.Bridge + mock_phue.PhueRegistrationException = Exception + mock_bridge.side_effect = [ + # First call, raise because not registered + mock_phue.PhueRegistrationException(1, 2), + # Second call, registration is done + None, + ] + + bridge = hue.HueBridge('localhost', self.hass, hue.PHUE_CONFIG_FILE) + bridge.setup() + self.assertFalse(bridge.configured) + self.assertFalse(bridge.config_request_id is None) + + # Simulate the user confirming the registration + self.hass.services.call( + configurator.DOMAIN, configurator.SERVICE_CONFIGURE, + {configurator.ATTR_CONFIGURE_ID: bridge.config_request_id}) + + self.hass.block_till_done() + self.assertTrue(bridge.configured) + self.assertTrue(bridge.config_request_id is None) + + # We should see a total of two identical calls + args = call( + 'localhost', + config_file_path=get_test_config_dir(hue.PHUE_CONFIG_FILE)) + mock_bridge.assert_has_calls([args, args]) + + # Make sure the request is done + self.assertEqual(1, len(self.hass.states.all())) + self.assertEqual('configured', self.hass.states.all()[0].state) + + @MockDependency('phue') + def test_setup_bridge_registration_fails(self, mock_phue): + """ + Test a registration failure sequence. + + This may happen when we start the registration process, the user + responds to the request but the bridge has become unreachable. + """ + mock_bridge = mock_phue.Bridge + mock_phue.PhueRegistrationException = Exception + mock_bridge.side_effect = [ + # First call, raise because not registered + mock_phue.PhueRegistrationException(1, 2), + # Second call, the bridge has gone away + ConnectionRefusedError(), + ] + + bridge = hue.HueBridge('localhost', self.hass, hue.PHUE_CONFIG_FILE) + bridge.setup() + self.assertFalse(bridge.configured) + self.assertFalse(bridge.config_request_id is None) + + # Simulate the user confirming the registration + self.hass.services.call( + configurator.DOMAIN, configurator.SERVICE_CONFIGURE, + {configurator.ATTR_CONFIGURE_ID: bridge.config_request_id}) + + self.hass.block_till_done() + self.assertFalse(bridge.configured) + self.assertFalse(bridge.config_request_id is None) + + # We should see a total of two identical calls + args = call( + 'localhost', + config_file_path=get_test_config_dir(hue.PHUE_CONFIG_FILE)) + mock_bridge.assert_has_calls([args, args]) + + # The request should still be pending + self.assertEqual(1, len(self.hass.states.all())) + self.assertEqual('configure', self.hass.states.all()[0].state) + + @MockDependency('phue') + def test_setup_bridge_registration_retry(self, mock_phue): + """ + Test a registration retry sequence. + + This may happen when we start the registration process, the user + responds to the request but we fail to confirm it with the bridge. + """ + mock_bridge = mock_phue.Bridge + mock_phue.PhueRegistrationException = Exception + mock_bridge.side_effect = [ + # First call, raise because not registered + mock_phue.PhueRegistrationException(1, 2), + # Second call, for whatever reason authentication fails + mock_phue.PhueRegistrationException(1, 2), + ] + + bridge = hue.HueBridge('localhost', self.hass, hue.PHUE_CONFIG_FILE) + bridge.setup() + self.assertFalse(bridge.configured) + self.assertFalse(bridge.config_request_id is None) + + # Simulate the user confirming the registration + self.hass.services.call( + configurator.DOMAIN, configurator.SERVICE_CONFIGURE, + {configurator.ATTR_CONFIGURE_ID: bridge.config_request_id}) + + self.hass.block_till_done() + self.assertFalse(bridge.configured) + self.assertFalse(bridge.config_request_id is None) + + # We should see a total of two identical calls + args = call( + 'localhost', + config_file_path=get_test_config_dir(hue.PHUE_CONFIG_FILE)) + mock_bridge.assert_has_calls([args, args]) + + # Make sure the request is done + self.assertEqual(1, len(self.hass.states.all())) + self.assertEqual('configure', self.hass.states.all()[0].state) + self.assertEqual( + 'Failed to register, please try again.', + self.hass.states.all()[0].attributes.get(configurator.ATTR_ERRORS)) + + @MockDependency('phue') + def test_hue_activate_scene(self, mock_phue): + """Test the hue_activate_scene service.""" + with patch('homeassistant.helpers.discovery.load_platform'): + bridge = hue.HueBridge('localhost', self.hass, + hue.PHUE_CONFIG_FILE) + bridge.setup() + + # No args + self.hass.services.call(hue.DOMAIN, hue.SERVICE_HUE_SCENE, + blocking=True) + bridge.bridge.run_scene.assert_not_called() + + # Only one arg + self.hass.services.call( + hue.DOMAIN, hue.SERVICE_HUE_SCENE, + {hue.ATTR_GROUP_NAME: 'group'}, + blocking=True) + bridge.bridge.run_scene.assert_not_called() + + self.hass.services.call( + hue.DOMAIN, hue.SERVICE_HUE_SCENE, + {hue.ATTR_SCENE_NAME: 'scene'}, + blocking=True) + bridge.bridge.run_scene.assert_not_called() + + # Both required args + self.hass.services.call( + hue.DOMAIN, hue.SERVICE_HUE_SCENE, + {hue.ATTR_GROUP_NAME: 'group', hue.ATTR_SCENE_NAME: 'scene'}, + blocking=True) + bridge.bridge.run_scene.assert_called_once_with('group', 'scene') diff --git a/tests/components/test_prometheus.py b/tests/components/test_prometheus.py index dd8cbfe55e0..052292b015d 100644 --- a/tests/components/test_prometheus.py +++ b/tests/components/test_prometheus.py @@ -30,4 +30,6 @@ def test_view(prometheus_client): # pylint: disable=redefined-outer-name assert len(body) > 3 # At least two comment lines and a metric for line in body: if line: - assert line.startswith('# ') or line.startswith('process_') + assert line.startswith('# ') \ + or line.startswith('process_') \ + or line.startswith('python_info') diff --git a/tests/components/test_snips.py b/tests/components/test_snips.py index 5e49bbd0382..a3e6fac0295 100644 --- a/tests/components/test_snips.py +++ b/tests/components/test_snips.py @@ -34,7 +34,7 @@ def test_snips_call_action(hass, mqtt_mock): intents = async_mock_intent(hass, 'Lights') - async_fire_mqtt_message(hass, 'hermes/nlu/intentParsed', + async_fire_mqtt_message(hass, 'hermes/intent/activateLights', EXAMPLE_MSG) yield from hass.async_block_till_done() assert len(intents) == 1