diff --git a/.coveragerc b/.coveragerc index d5eb32e670c280..2b96400d1e64de 100644 --- a/.coveragerc +++ b/.coveragerc @@ -247,6 +247,7 @@ omit = homeassistant/components/binary_sensor/rest.py homeassistant/components/binary_sensor/tapsaff.py homeassistant/components/browser.py + homeassistant/components/calendar/todoist.py homeassistant/components/camera/bloomsky.py homeassistant/components/camera/ffmpeg.py homeassistant/components/camera/foscam.py diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index 2ce574ca15e583..a8852b910c24e5 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -126,6 +126,12 @@ def get_arguments() -> argparse.Namespace: type=int, default=None, help='Enables daily log rotation and keeps up to the specified days') + parser.add_argument( + '--log-file', + type=str, + default=None, + help='Log file to write to. If not set, CONFIG/home-assistant.log ' + 'is used') parser.add_argument( '--runner', action='store_true', @@ -256,13 +262,14 @@ def setup_and_run_hass(config_dir: str, } hass = bootstrap.from_config_dict( config, config_dir=config_dir, verbose=args.verbose, - skip_pip=args.skip_pip, log_rotate_days=args.log_rotate_days) + skip_pip=args.skip_pip, log_rotate_days=args.log_rotate_days, + log_file=args.log_file) else: config_file = ensure_config_file(config_dir) print('Config directory:', config_dir) hass = bootstrap.from_config_file( config_file, verbose=args.verbose, skip_pip=args.skip_pip, - log_rotate_days=args.log_rotate_days) + log_rotate_days=args.log_rotate_days, log_file=args.log_file) if hass is None: return None diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 7831036ff597e6..1fa113ab597ed7 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -38,7 +38,8 @@ def from_config_dict(config: Dict[str, Any], enable_log: bool=True, verbose: bool=False, skip_pip: bool=False, - log_rotate_days: Any=None) \ + log_rotate_days: Any=None, + log_file: Any=None) \ -> Optional[core.HomeAssistant]: """Try to configure Home Assistant from a configuration dictionary. @@ -56,7 +57,7 @@ def from_config_dict(config: Dict[str, Any], hass = hass.loop.run_until_complete( async_from_config_dict( config, hass, config_dir, enable_log, verbose, skip_pip, - log_rotate_days) + log_rotate_days, log_file) ) return hass @@ -69,7 +70,8 @@ def async_from_config_dict(config: Dict[str, Any], enable_log: bool=True, verbose: bool=False, skip_pip: bool=False, - log_rotate_days: Any=None) \ + log_rotate_days: Any=None, + log_file: Any=None) \ -> Optional[core.HomeAssistant]: """Try to configure Home Assistant from a configuration dictionary. @@ -88,7 +90,7 @@ def async_from_config_dict(config: Dict[str, Any], yield from hass.async_add_job(conf_util.process_ha_config_upgrade, hass) if enable_log: - async_enable_logging(hass, verbose, log_rotate_days) + async_enable_logging(hass, verbose, log_rotate_days, log_file) hass.config.skip_pip = skip_pip if skip_pip: @@ -153,7 +155,8 @@ def from_config_file(config_path: str, hass: Optional[core.HomeAssistant]=None, verbose: bool=False, skip_pip: bool=True, - log_rotate_days: Any=None): + log_rotate_days: Any=None, + log_file: Any=None): """Read the configuration file and try to start all the functionality. Will add functionality to 'hass' parameter if given, @@ -165,7 +168,7 @@ def from_config_file(config_path: str, # run task hass = hass.loop.run_until_complete( async_from_config_file( - config_path, hass, verbose, skip_pip, log_rotate_days) + config_path, hass, verbose, skip_pip, log_rotate_days, log_file) ) return hass @@ -176,7 +179,8 @@ def async_from_config_file(config_path: str, hass: core.HomeAssistant, verbose: bool=False, skip_pip: bool=True, - log_rotate_days: Any=None): + log_rotate_days: Any=None, + log_file: Any=None): """Read the configuration file and try to start all the functionality. Will add functionality to 'hass' parameter. @@ -187,7 +191,7 @@ def async_from_config_file(config_path: str, hass.config.config_dir = config_dir yield from async_mount_local_lib_path(config_dir, hass.loop) - async_enable_logging(hass, verbose, log_rotate_days) + async_enable_logging(hass, verbose, log_rotate_days, log_file) try: config_dict = yield from hass.async_add_job( @@ -205,7 +209,7 @@ def async_from_config_file(config_path: str, @core.callback def async_enable_logging(hass: core.HomeAssistant, verbose: bool=False, - log_rotate_days=None) -> None: + log_rotate_days=None, log_file=None) -> None: """Set up the logging. This method must be run in the event loop. @@ -239,13 +243,18 @@ def async_enable_logging(hass: core.HomeAssistant, verbose: bool=False, pass # Log errors to a file if we have write access to file or config dir - err_log_path = hass.config.path(ERROR_LOG_FILENAME) + if log_file is None: + err_log_path = hass.config.path(ERROR_LOG_FILENAME) + else: + err_log_path = os.path.abspath(log_file) + err_path_exists = os.path.isfile(err_log_path) + err_dir = os.path.dirname(err_log_path) # Check if we can write to the error log if it exists or that # we can create files in the containing directory if not. if (err_path_exists and os.access(err_log_path, os.W_OK)) or \ - (not err_path_exists and os.access(hass.config.config_dir, os.W_OK)): + (not err_path_exists and os.access(err_dir, os.W_OK)): if log_rotate_days: err_handler = logging.handlers.TimedRotatingFileHandler( diff --git a/homeassistant/components/alarm_control_panel/alarmdecoder.py b/homeassistant/components/alarm_control_panel/alarmdecoder.py index f54774b8923194..3b58eb0b71d458 100644 --- a/homeassistant/components/alarm_control_panel/alarmdecoder.py +++ b/homeassistant/components/alarm_control_panel/alarmdecoder.py @@ -57,19 +57,19 @@ def _message_callback(self, message): if message.alarm_sounding or message.fire_alarm: if self._state != STATE_ALARM_TRIGGERED: self._state = STATE_ALARM_TRIGGERED - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() elif message.armed_away: if self._state != STATE_ALARM_ARMED_AWAY: self._state = STATE_ALARM_ARMED_AWAY - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() elif message.armed_home: if self._state != STATE_ALARM_ARMED_HOME: self._state = STATE_ALARM_ARMED_HOME - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() else: if self._state != STATE_ALARM_DISARMED: self._state = STATE_ALARM_DISARMED - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def name(self): diff --git a/homeassistant/components/alarm_control_panel/demo.py b/homeassistant/components/alarm_control_panel/demo.py index 8ebf0a93c38aa9..00dae5c2779563 100644 --- a/homeassistant/components/alarm_control_panel/demo.py +++ b/homeassistant/components/alarm_control_panel/demo.py @@ -5,10 +5,26 @@ https://home-assistant.io/components/demo/ """ import homeassistant.components.alarm_control_panel.manual as manual +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_TRIGGERED, CONF_PENDING_TIME) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Demo alarm control panel platform.""" add_devices([ - manual.ManualAlarm(hass, 'Alarm', '1234', 5, 10, False), + manual.ManualAlarm(hass, 'Alarm', '1234', 5, 10, False, { + STATE_ALARM_ARMED_AWAY: { + CONF_PENDING_TIME: 5 + }, + STATE_ALARM_ARMED_HOME: { + CONF_PENDING_TIME: 5 + }, + STATE_ALARM_ARMED_NIGHT: { + CONF_PENDING_TIME: 5 + }, + STATE_ALARM_TRIGGERED: { + CONF_PENDING_TIME: 5 + }, + }), ]) diff --git a/homeassistant/components/alarm_control_panel/envisalink.py b/homeassistant/components/alarm_control_panel/envisalink.py index f6d388a6c5b7e6..026d2324ed3ab9 100644 --- a/homeassistant/components/alarm_control_panel/envisalink.py +++ b/homeassistant/components/alarm_control_panel/envisalink.py @@ -106,7 +106,7 @@ def async_added_to_hass(self): def _update_callback(self, partition): """Update Home Assistant state, if needed.""" if partition is None or int(partition) == self._partition_number: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def code_format(self): diff --git a/homeassistant/components/alarm_control_panel/manual.py b/homeassistant/components/alarm_control_panel/manual.py index f345ccc4dcdf15..237959ab10d384 100644 --- a/homeassistant/components/alarm_control_panel/manual.py +++ b/homeassistant/components/alarm_control_panel/manual.py @@ -4,6 +4,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/alarm_control_panel.manual/ """ +import copy import datetime import logging @@ -24,9 +25,28 @@ DEFAULT_TRIGGER_TIME = 120 DEFAULT_DISARM_AFTER_TRIGGER = False +SUPPORTED_PENDING_STATES = [STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, STATE_ALARM_TRIGGERED] + ATTR_POST_PENDING_STATE = 'post_pending_state' -PLATFORM_SCHEMA = vol.Schema({ + +def _state_validator(config): + config = copy.deepcopy(config) + for state in SUPPORTED_PENDING_STATES: + if CONF_PENDING_TIME not in config[state]: + config[state][CONF_PENDING_TIME] = config[CONF_PENDING_TIME] + + return config + + +STATE_SETTING_SCHEMA = vol.Schema({ + vol.Optional(CONF_PENDING_TIME): + vol.All(vol.Coerce(int), vol.Range(min=0)) +}) + + +PLATFORM_SCHEMA = vol.Schema(vol.All({ vol.Required(CONF_PLATFORM): 'manual', vol.Optional(CONF_NAME, default=DEFAULT_ALARM_NAME): cv.string, vol.Optional(CONF_CODE): cv.string, @@ -36,7 +56,11 @@ vol.All(vol.Coerce(int), vol.Range(min=1)), 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, +}, _state_validator)) _LOGGER = logging.getLogger(__name__) @@ -49,7 +73,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): config.get(CONF_CODE), config.get(CONF_PENDING_TIME, DEFAULT_PENDING_TIME), config.get(CONF_TRIGGER_TIME, DEFAULT_TRIGGER_TIME), - config.get(CONF_DISARM_AFTER_TRIGGER, DEFAULT_DISARM_AFTER_TRIGGER) + config.get(CONF_DISARM_AFTER_TRIGGER, DEFAULT_DISARM_AFTER_TRIGGER), + config )]) @@ -63,19 +88,23 @@ class ManualAlarm(alarm.AlarmControlPanel): or disarm if `disarm_after_trigger` is true. """ - def __init__(self, hass, name, code, pending_time, - trigger_time, disarm_after_trigger): + def __init__(self, hass, name, code, pending_time, trigger_time, + disarm_after_trigger, config): """Init the manual alarm panel.""" 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) self._disarm_after_trigger = disarm_after_trigger self._pre_trigger_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]) + @property def should_poll(self): """Return the plling state.""" @@ -89,17 +118,10 @@ def name(self): @property def state(self): """Return the state of the device.""" - if self._state in (STATE_ALARM_ARMED_HOME, - STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_NIGHT) and \ - self._pending_time and self._state_ts + self._pending_time > \ - dt_util.utcnow(): - return STATE_ALARM_PENDING - if self._state == STATE_ALARM_TRIGGERED and self._trigger_time: - if self._state_ts + self._pending_time > dt_util.utcnow(): + if self._within_pending_time(self._state): return STATE_ALARM_PENDING - elif (self._state_ts + self._pending_time + + elif (self._state_ts + self._pending_time_by_state[self._state] + self._trigger_time) < dt_util.utcnow(): if self._disarm_after_trigger: return STATE_ALARM_DISARMED @@ -107,8 +129,16 @@ def state(self): self._state = self._pre_trigger_state return self._state + if self._state in SUPPORTED_PENDING_STATES and \ + self._within_pending_time(self._state): + return STATE_ALARM_PENDING + return self._state + def _within_pending_time(self, state): + pending_time = self._pending_time_by_state[state] + return self._state_ts + pending_time > dt_util.utcnow() + @property def code_format(self): """One or more characters.""" @@ -128,58 +158,47 @@ def alarm_arm_home(self, code=None): if not self._validate_code(code, STATE_ALARM_ARMED_HOME): return - self._state = STATE_ALARM_ARMED_HOME - self._state_ts = dt_util.utcnow() - self.schedule_update_ha_state() - - if self._pending_time: - track_point_in_time( - self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time) + self._update_state(STATE_ALARM_ARMED_HOME) def alarm_arm_away(self, code=None): """Send arm away command.""" if not self._validate_code(code, STATE_ALARM_ARMED_AWAY): return - self._state = STATE_ALARM_ARMED_AWAY - self._state_ts = dt_util.utcnow() - self.schedule_update_ha_state() - - if self._pending_time: - track_point_in_time( - self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time) + self._update_state(STATE_ALARM_ARMED_AWAY) def alarm_arm_night(self, code=None): """Send arm night command.""" if not self._validate_code(code, STATE_ALARM_ARMED_NIGHT): return - self._state = STATE_ALARM_ARMED_NIGHT - self._state_ts = dt_util.utcnow() - self.schedule_update_ha_state() - - if self._pending_time: - track_point_in_time( - self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time) + 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 - self._state = STATE_ALARM_TRIGGERED + + self._update_state(STATE_ALARM_TRIGGERED) + + def _update_state(self, state): + self._state = state self._state_ts = dt_util.utcnow() self.schedule_update_ha_state() - if self._trigger_time: + pending_time = self._pending_time_by_state[state] + + if state == STATE_ALARM_TRIGGERED and self._trigger_time: track_point_in_time( self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time) + self._state_ts + pending_time) track_point_in_time( self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time + self._trigger_time) + self._state_ts + self._trigger_time + pending_time) + elif state in SUPPORTED_PENDING_STATES and pending_time: + track_point_in_time( + self._hass, self.async_update_ha_state, + self._state_ts + pending_time) def _validate_code(self, code, state): """Validate given code.""" diff --git a/homeassistant/components/alarm_control_panel/mqtt.py b/homeassistant/components/alarm_control_panel/mqtt.py index 33bfe464eea06e..fca935388c1812 100644 --- a/homeassistant/components/alarm_control_panel/mqtt.py +++ b/homeassistant/components/alarm_control_panel/mqtt.py @@ -87,7 +87,7 @@ def message_received(topic, payload, qos): _LOGGER.warning("Received unexpected payload: %s", payload) return self._state = payload - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() return mqtt.async_subscribe( self.hass, self._state_topic, message_received, self._qos) diff --git a/homeassistant/components/alexa/__init__.py b/homeassistant/components/alexa/__init__.py new file mode 100644 index 00000000000000..65243aa83ceb2b --- /dev/null +++ b/homeassistant/components/alexa/__init__.py @@ -0,0 +1,52 @@ +""" +Support for Alexa skill service end point. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/alexa/ +""" +import asyncio +import logging + +import voluptuous as vol + +from homeassistant.helpers import config_validation as cv + +from .const import ( + DOMAIN, CONF_UID, CONF_TITLE, CONF_AUDIO, CONF_TEXT, CONF_DISPLAY_URL) +from . import flash_briefings, intent + +_LOGGER = logging.getLogger(__name__) + + +DEPENDENCIES = ['http'] + +CONF_FLASH_BRIEFINGS = 'flash_briefings' + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: { + CONF_FLASH_BRIEFINGS: { + cv.string: vol.All(cv.ensure_list, [{ + vol.Optional(CONF_UID): cv.string, + vol.Required(CONF_TITLE): cv.template, + vol.Optional(CONF_AUDIO): cv.template, + vol.Required(CONF_TEXT, default=""): cv.template, + vol.Optional(CONF_DISPLAY_URL): cv.template, + }]), + } + } +}, extra=vol.ALLOW_EXTRA) + + +@asyncio.coroutine +def async_setup(hass, config): + """Activate Alexa component.""" + config = config.get(DOMAIN, {}) + flash_briefings_config = config.get(CONF_FLASH_BRIEFINGS) + + intent.async_setup(hass) + + if flash_briefings_config: + flash_briefings.async_setup(hass, flash_briefings_config) + + return True diff --git a/homeassistant/components/alexa/const.py b/homeassistant/components/alexa/const.py new file mode 100644 index 00000000000000..9550b6dbade607 --- /dev/null +++ b/homeassistant/components/alexa/const.py @@ -0,0 +1,18 @@ +"""Constants for the Alexa integration.""" +DOMAIN = 'alexa' + +# Flash briefing constants +CONF_UID = 'uid' +CONF_TITLE = 'title' +CONF_AUDIO = 'audio' +CONF_TEXT = 'text' +CONF_DISPLAY_URL = 'display_url' + +ATTR_UID = 'uid' +ATTR_UPDATE_DATE = 'updateDate' +ATTR_TITLE_TEXT = 'titleText' +ATTR_STREAM_URL = 'streamUrl' +ATTR_MAIN_TEXT = 'mainText' +ATTR_REDIRECTION_URL = 'redirectionURL' + +DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.0Z' diff --git a/homeassistant/components/alexa/flash_briefings.py b/homeassistant/components/alexa/flash_briefings.py new file mode 100644 index 00000000000000..ec7e3521c0a4da --- /dev/null +++ b/homeassistant/components/alexa/flash_briefings.py @@ -0,0 +1,96 @@ +""" +Support for Alexa skill service end point. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/alexa/ +""" +import copy +import logging +from datetime import datetime +import uuid + +from homeassistant.core import callback +from homeassistant.helpers import template +from homeassistant.components import http + +from .const import ( + CONF_UID, CONF_TITLE, CONF_AUDIO, CONF_TEXT, CONF_DISPLAY_URL, ATTR_UID, + ATTR_UPDATE_DATE, ATTR_TITLE_TEXT, ATTR_STREAM_URL, ATTR_MAIN_TEXT, + ATTR_REDIRECTION_URL, DATE_FORMAT) + + +_LOGGER = logging.getLogger(__name__) + +FLASH_BRIEFINGS_API_ENDPOINT = '/api/alexa/flash_briefings/{briefing_id}' + + +@callback +def async_setup(hass, flash_briefing_config): + """Activate Alexa component.""" + hass.http.register_view( + AlexaFlashBriefingView(hass, flash_briefing_config)) + + +class AlexaFlashBriefingView(http.HomeAssistantView): + """Handle Alexa Flash Briefing skill requests.""" + + url = FLASH_BRIEFINGS_API_ENDPOINT + name = 'api:alexa:flash_briefings' + + def __init__(self, hass, flash_briefings): + """Initialize Alexa view.""" + super().__init__() + self.flash_briefings = copy.deepcopy(flash_briefings) + template.attach(hass, self.flash_briefings) + + @callback + def get(self, request, briefing_id): + """Handle Alexa Flash Briefing request.""" + _LOGGER.debug('Received Alexa flash briefing request for: %s', + briefing_id) + + if self.flash_briefings.get(briefing_id) is None: + err = 'No configured Alexa flash briefing was found for: %s' + _LOGGER.error(err, briefing_id) + return b'', 404 + + briefing = [] + + for item in self.flash_briefings.get(briefing_id, []): + output = {} + if item.get(CONF_TITLE) is not None: + if isinstance(item.get(CONF_TITLE), template.Template): + output[ATTR_TITLE_TEXT] = item[CONF_TITLE].async_render() + else: + output[ATTR_TITLE_TEXT] = item.get(CONF_TITLE) + + if item.get(CONF_TEXT) is not None: + if isinstance(item.get(CONF_TEXT), template.Template): + output[ATTR_MAIN_TEXT] = item[CONF_TEXT].async_render() + else: + output[ATTR_MAIN_TEXT] = item.get(CONF_TEXT) + + uid = item.get(CONF_UID) + if uid is None: + uid = str(uuid.uuid4()) + output[ATTR_UID] = uid + + if item.get(CONF_AUDIO) is not None: + if isinstance(item.get(CONF_AUDIO), template.Template): + output[ATTR_STREAM_URL] = item[CONF_AUDIO].async_render() + else: + output[ATTR_STREAM_URL] = item.get(CONF_AUDIO) + + if item.get(CONF_DISPLAY_URL) is not None: + if isinstance(item.get(CONF_DISPLAY_URL), + template.Template): + output[ATTR_REDIRECTION_URL] = \ + item[CONF_DISPLAY_URL].async_render() + else: + output[ATTR_REDIRECTION_URL] = item.get(CONF_DISPLAY_URL) + + output[ATTR_UPDATE_DATE] = datetime.now().strftime(DATE_FORMAT) + + briefing.append(output) + + return self.json(briefing) diff --git a/homeassistant/components/alexa.py b/homeassistant/components/alexa/intent.py similarity index 60% rename from homeassistant/components/alexa.py rename to homeassistant/components/alexa/intent.py index 25b6537e25583e..a0d0062414db85 100644 --- a/homeassistant/components/alexa.py +++ b/homeassistant/components/alexa/intent.py @@ -5,52 +5,19 @@ https://home-assistant.io/components/alexa/ """ import asyncio -import copy import enum import logging -import uuid -from datetime import datetime - -import voluptuous as vol from homeassistant.core import callback from homeassistant.const import HTTP_BAD_REQUEST -from homeassistant.helpers import intent, template, config_validation as cv +from homeassistant.helpers import intent from homeassistant.components import http -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN INTENTS_API_ENDPOINT = '/api/alexa' -FLASH_BRIEFINGS_API_ENDPOINT = '/api/alexa/flash_briefings/{briefing_id}' - -CONF_ACTION = 'action' -CONF_CARD = 'card' -CONF_INTENTS = 'intents' -CONF_SPEECH = 'speech' - -CONF_TYPE = 'type' -CONF_TITLE = 'title' -CONF_CONTENT = 'content' -CONF_TEXT = 'text' - -CONF_FLASH_BRIEFINGS = 'flash_briefings' -CONF_UID = 'uid' -CONF_TITLE = 'title' -CONF_AUDIO = 'audio' -CONF_TEXT = 'text' -CONF_DISPLAY_URL = 'display_url' - -ATTR_UID = 'uid' -ATTR_UPDATE_DATE = 'updateDate' -ATTR_TITLE_TEXT = 'titleText' -ATTR_STREAM_URL = 'streamUrl' -ATTR_MAIN_TEXT = 'mainText' -ATTR_REDIRECTION_URL = 'redirectionURL' - -DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.0Z' -DOMAIN = 'alexa' -DEPENDENCIES = ['http'] +_LOGGER = logging.getLogger(__name__) class SpeechType(enum.Enum): @@ -73,30 +40,10 @@ class CardType(enum.Enum): link_account = "LinkAccount" -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: { - CONF_FLASH_BRIEFINGS: { - cv.string: vol.All(cv.ensure_list, [{ - vol.Required(CONF_UID, default=str(uuid.uuid4())): cv.string, - vol.Required(CONF_TITLE): cv.template, - vol.Optional(CONF_AUDIO): cv.template, - vol.Required(CONF_TEXT, default=""): cv.template, - vol.Optional(CONF_DISPLAY_URL): cv.template, - }]), - } - } -}, extra=vol.ALLOW_EXTRA) - - -@asyncio.coroutine -def async_setup(hass, config): +@callback +def async_setup(hass): """Activate Alexa component.""" - flash_briefings = config[DOMAIN].get(CONF_FLASH_BRIEFINGS, {}) - hass.http.register_view(AlexaIntentsView) - hass.http.register_view(AlexaFlashBriefingView(hass, flash_briefings)) - - return True class AlexaIntentsView(http.HomeAssistantView): @@ -255,66 +202,3 @@ def as_dict(self): 'sessionAttributes': self.session_attributes, 'response': response, } - - -class AlexaFlashBriefingView(http.HomeAssistantView): - """Handle Alexa Flash Briefing skill requests.""" - - url = FLASH_BRIEFINGS_API_ENDPOINT - name = 'api:alexa:flash_briefings' - - def __init__(self, hass, flash_briefings): - """Initialize Alexa view.""" - super().__init__() - self.flash_briefings = copy.deepcopy(flash_briefings) - template.attach(hass, self.flash_briefings) - - @callback - def get(self, request, briefing_id): - """Handle Alexa Flash Briefing request.""" - _LOGGER.debug('Received Alexa flash briefing request for: %s', - briefing_id) - - if self.flash_briefings.get(briefing_id) is None: - err = 'No configured Alexa flash briefing was found for: %s' - _LOGGER.error(err, briefing_id) - return b'', 404 - - briefing = [] - - for item in self.flash_briefings.get(briefing_id, []): - output = {} - if item.get(CONF_TITLE) is not None: - if isinstance(item.get(CONF_TITLE), template.Template): - output[ATTR_TITLE_TEXT] = item[CONF_TITLE].async_render() - else: - output[ATTR_TITLE_TEXT] = item.get(CONF_TITLE) - - if item.get(CONF_TEXT) is not None: - if isinstance(item.get(CONF_TEXT), template.Template): - output[ATTR_MAIN_TEXT] = item[CONF_TEXT].async_render() - else: - output[ATTR_MAIN_TEXT] = item.get(CONF_TEXT) - - if item.get(CONF_UID) is not None: - output[ATTR_UID] = item.get(CONF_UID) - - if item.get(CONF_AUDIO) is not None: - if isinstance(item.get(CONF_AUDIO), template.Template): - output[ATTR_STREAM_URL] = item[CONF_AUDIO].async_render() - else: - output[ATTR_STREAM_URL] = item.get(CONF_AUDIO) - - if item.get(CONF_DISPLAY_URL) is not None: - if isinstance(item.get(CONF_DISPLAY_URL), - template.Template): - output[ATTR_REDIRECTION_URL] = \ - item[CONF_DISPLAY_URL].async_render() - else: - output[ATTR_REDIRECTION_URL] = item.get(CONF_DISPLAY_URL) - - output[ATTR_UPDATE_DATE] = datetime.now().strftime(DATE_FORMAT) - - briefing.append(output) - - return self.json(briefing) diff --git a/homeassistant/components/android_ip_webcam.py b/homeassistant/components/android_ip_webcam.py index 2fb039f0ab3c68..2883fca9ab6d98 100644 --- a/homeassistant/components/android_ip_webcam.py +++ b/homeassistant/components/android_ip_webcam.py @@ -263,7 +263,7 @@ def async_ipcam_update(host): """Update callback.""" if self._host != host: return - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) async_dispatcher_connect( self.hass, SIGNAL_UPDATE_DATA, async_ipcam_update) diff --git a/homeassistant/components/apple_tv.py b/homeassistant/components/apple_tv.py index 7a2ff7610f7f59..4fce508ba7e54a 100644 --- a/homeassistant/components/apple_tv.py +++ b/homeassistant/components/apple_tv.py @@ -10,6 +10,7 @@ import voluptuous as vol +from typing import Union, TypeVar, Sequence from homeassistant.const import (CONF_HOST, CONF_NAME, ATTR_ENTITY_ID) from homeassistant.config import load_yaml_config_file from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -45,8 +46,19 @@ NOTIFICATION_SCAN_ID = 'apple_tv_scan_notification' NOTIFICATION_SCAN_TITLE = 'Apple TV Scan' +T = TypeVar('T') + + +# This version of ensure_list interprets an empty dict as no value +def ensure_list(value: Union[T, Sequence[T]]) -> Sequence[T]: + """Wrap value in list if it is not one.""" + if value is None or (isinstance(value, dict) and not value): + return [] + return value if isinstance(value, list) else [value] + + CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.All(cv.ensure_list, [vol.Schema({ + DOMAIN: vol.All(ensure_list, [vol.Schema({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_LOGIN_ID): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, @@ -133,6 +145,10 @@ def async_service_handler(service): """Handler for service calls.""" entity_ids = service.data.get(ATTR_ENTITY_ID) + if service.service == SERVICE_SCAN: + hass.async_add_job(scan_for_apple_tvs, hass) + return + if entity_ids: devices = [device for device in hass.data[DATA_ENTITIES] if device.entity_id in entity_ids] @@ -140,16 +156,16 @@ def async_service_handler(service): devices = hass.data[DATA_ENTITIES] for device in devices: + if service.service != SERVICE_AUTHENTICATE: + continue + atv = device.atv - if service.service == SERVICE_AUTHENTICATE: - credentials = yield from atv.airplay.generate_credentials() - yield from atv.airplay.load_credentials(credentials) - _LOGGER.debug('Generated new credentials: %s', credentials) - yield from atv.airplay.start_authentication() - hass.async_add_job(request_configuration, - hass, config, atv, credentials) - elif service.service == SERVICE_SCAN: - hass.async_add_job(scan_for_apple_tvs, hass) + credentials = yield from atv.airplay.generate_credentials() + yield from atv.airplay.load_credentials(credentials) + _LOGGER.debug('Generated new credentials: %s', credentials) + yield from atv.airplay.start_authentication() + hass.async_add_job(request_configuration, + hass, config, atv, credentials) @asyncio.coroutine def atv_discovered(service, info): diff --git a/homeassistant/components/binary_sensor/alarmdecoder.py b/homeassistant/components/binary_sensor/alarmdecoder.py index 495feaf64ab868..bc05e4d84d8016 100644 --- a/homeassistant/components/binary_sensor/alarmdecoder.py +++ b/homeassistant/components/binary_sensor/alarmdecoder.py @@ -102,11 +102,11 @@ 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.hass.async_add_job(self.async_update_ha_state()) + self.async_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.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/bayesian.py b/homeassistant/components/binary_sensor/bayesian.py index 4c62735a6f9fbe..13908fb547281f 100644 --- a/homeassistant/components/binary_sensor/bayesian.py +++ b/homeassistant/components/binary_sensor/bayesian.py @@ -102,7 +102,13 @@ def __init__(self, name, prior, observations, probability_threshold, self.current_obs = OrderedDict({}) - self.entity_obs = {obs['entity_id']: obs for obs in self._observations} + to_observe = set(obs['entity_id'] for obs in self._observations) + + self.entity_obs = dict.fromkeys(to_observe, []) + + for ind, obs in enumerate(self._observations): + obs["id"] = ind + self.entity_obs[obs['entity_id']].append(obs) self.watchers = { 'numeric_state': self._process_numeric_state, @@ -120,17 +126,17 @@ def async_threshold_sensor_state_listener(entity, old_state, if new_state.state == STATE_UNKNOWN: return - entity_obs = self.entity_obs[entity] - platform = entity_obs['platform'] + entity_obs_list = self.entity_obs[entity] + + for entity_obs in entity_obs_list: + platform = entity_obs['platform'] - self.watchers[platform](entity_obs) + self.watchers[platform](entity_obs) prior = self.prior - print(self.current_obs.values()) for obs in self.current_obs.values(): prior = update_probability(prior, obs['prob_true'], obs['prob_false']) - self.probability = prior self.hass.async_add_job(self.async_update_ha_state, True) @@ -141,20 +147,20 @@ def async_threshold_sensor_state_listener(entity, old_state, def _update_current_obs(self, entity_observation, should_trigger): """Update current observation.""" - entity = entity_observation['entity_id'] + obs_id = entity_observation['id'] if should_trigger: prob_true = entity_observation['prob_given_true'] prob_false = entity_observation.get( 'prob_given_false', 1 - prob_true) - self.current_obs[entity] = { + self.current_obs[obs_id] = { 'prob_true': prob_true, 'prob_false': prob_false } else: - self.current_obs.pop(entity, None) + self.current_obs.pop(obs_id, None) def _process_numeric_state(self, entity_observation): """Add entity to current_obs if numeric state conditions are met.""" @@ -201,7 +207,7 @@ def device_state_attributes(self): """Return the state attributes of the sensor.""" return { 'observations': [val for val in self.current_obs.values()], - 'probability': self.probability, + 'probability': round(self.probability, 2), 'probability_threshold': self._probability_threshold } diff --git a/homeassistant/components/binary_sensor/envisalink.py b/homeassistant/components/binary_sensor/envisalink.py index 5fbc1eb90a192f..7d35c0c9e94959 100644 --- a/homeassistant/components/binary_sensor/envisalink.py +++ b/homeassistant/components/binary_sensor/envisalink.py @@ -80,4 +80,4 @@ def device_class(self): def _update_callback(self, zone): """Update the zone's state, if needed.""" if zone is None or int(zone) == self._zone_number: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/ffmpeg_motion.py b/homeassistant/components/binary_sensor/ffmpeg_motion.py index 1bbf39dd6e0636..47b1be988bf778 100644 --- a/homeassistant/components/binary_sensor/ffmpeg_motion.py +++ b/homeassistant/components/binary_sensor/ffmpeg_motion.py @@ -73,7 +73,7 @@ def __init__(self, config): def _async_callback(self, state): """HA-FFmpeg callback for noise detection.""" self._state = state - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def is_on(self): diff --git a/homeassistant/components/binary_sensor/mqtt.py b/homeassistant/components/binary_sensor/mqtt.py index 3702b32d5865a0..7d40544d601b2b 100644 --- a/homeassistant/components/binary_sensor/mqtt.py +++ b/homeassistant/components/binary_sensor/mqtt.py @@ -86,7 +86,7 @@ def message_received(topic, payload, qos): elif payload == self._payload_off: self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() return mqtt.async_subscribe( self.hass, self._state_topic, message_received, self._qos) diff --git a/homeassistant/components/binary_sensor/mystrom.py b/homeassistant/components/binary_sensor/mystrom.py index 08ab1f4a8b7389..2afaa032745ba7 100644 --- a/homeassistant/components/binary_sensor/mystrom.py +++ b/homeassistant/components/binary_sensor/mystrom.py @@ -92,4 +92,4 @@ def is_on(self): def async_on_update(self, value): """Receive an update.""" self._state = value - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/template.py b/homeassistant/components/binary_sensor/template.py index 413804f085667d..84afd01303fda2 100644 --- a/homeassistant/components/binary_sensor/template.py +++ b/homeassistant/components/binary_sensor/template.py @@ -161,7 +161,7 @@ def async_check_state(self): def set_state(): """Set state of template binary sensor.""" self._state = state - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() # state without delay if (state and not self._delay_on) or \ diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 4e088c8a640b0d..5198381b9767a3 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -12,6 +12,7 @@ from homeassistant.components.google import ( CONF_OFFSET, CONF_DEVICE_ID, CONF_NAME) from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa from homeassistant.helpers.config_validation import time_period_str from homeassistant.helpers.entity import Entity, generate_entity_id from homeassistant.helpers.entity_component import EntityComponent diff --git a/homeassistant/components/calendar/services.yaml b/homeassistant/components/calendar/services.yaml new file mode 100644 index 00000000000000..952e2302091438 --- /dev/null +++ b/homeassistant/components/calendar/services.yaml @@ -0,0 +1,19 @@ +todoist: + new_task: + description: Create a new task and add it to a project. + fields: + content: + description: The name of the task. [Required] + example: Pick up the mail + project: + description: The name of the project this task should belong to. Defaults to Inbox. [Optional] + example: Errands + labels: + description: Any labels that you want to apply to this task, separated by a comma. [Optional] + example: Chores,Deliveries + priority: + description: The priority of this task, from 1 (normal) to 4 (urgent). [Optional] + example: 2 + due_date: + description: The day this task is due, in format YYYY-MM-DD. [Optional] + example: "2018-04-01" diff --git a/homeassistant/components/calendar/todoist.py b/homeassistant/components/calendar/todoist.py new file mode 100644 index 00000000000000..ae9a1c9afa85dd --- /dev/null +++ b/homeassistant/components/calendar/todoist.py @@ -0,0 +1,544 @@ +""" +Support for Todoist task management (https://todoist.com). + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/calendar.todoist/ +""" + + +from datetime import datetime +from datetime import timedelta +import logging +import os + +import voluptuous as vol + +from homeassistant.components.calendar import ( + CalendarEventDevice, PLATFORM_SCHEMA) +from homeassistant.components.google import ( + CONF_DEVICE_ID) +from homeassistant.config import load_yaml_config_file +from homeassistant.const import ( + CONF_ID, CONF_NAME, CONF_TOKEN) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.template import DATE_STR_FORMAT +from homeassistant.util import dt +from homeassistant.util import Throttle + +REQUIREMENTS = ['todoist-python==7.0.17'] + +_LOGGER = logging.getLogger(__name__) +DOMAIN = 'todoist' + +# Calendar Platform: Does this calendar event last all day? +ALL_DAY = 'all_day' +# Attribute: All tasks in this project +ALL_TASKS = 'all_tasks' +# Todoist API: "Completed" flag -- 1 if complete, else 0 +CHECKED = 'checked' +# Attribute: Is this task complete? +COMPLETED = 'completed' +# Todoist API: What is this task about? +# Service Call: What is this task about? +CONTENT = 'content' +# Calendar Platform: Get a calendar event's description +DESCRIPTION = 'description' +# Calendar Platform: Used in the '_get_date()' method +DATETIME = 'dateTime' +# Attribute: When is this task due? +# Service Call: When is this task due? +DUE_DATE = 'due_date' +# Todoist API: Look up a task's due date +DUE_DATE_UTC = 'due_date_utc' +# Attribute: Is this task due today? +DUE_TODAY = 'due_today' +# Calendar Platform: When a calendar event ends +END = 'end' +# Todoist API: Look up a Project/Label/Task ID +ID = 'id' +# Todoist API: Fetch all labels +# Service Call: What are the labels attached to this task? +LABELS = 'labels' +# Todoist API: "Name" value +NAME = 'name' +# Attribute: Is this task overdue? +OVERDUE = 'overdue' +# Attribute: What is this task's priority? +# Todoist API: Get a task's priority +# Service Call: What is this task's priority? +PRIORITY = 'priority' +# Todoist API: Look up the Project ID a Task belongs to +PROJECT_ID = 'project_id' +# Service Call: What Project do you want a Task added to? +PROJECT_NAME = 'project' +# Todoist API: Fetch all Projects +PROJECTS = 'projects' +# Calendar Platform: When does a calendar event start? +START = 'start' +# Calendar Platform: What is the next calendar event about? +SUMMARY = 'summary' +# Todoist API: Fetch all Tasks +TASKS = 'items' + +SERVICE_NEW_TASK = 'new_task' +NEW_TASK_SERVICE_SCHEMA = vol.Schema({ + vol.Required(CONTENT): cv.string, + vol.Optional(PROJECT_NAME, default='inbox'): vol.All(cv.string, vol.Lower), + vol.Optional(LABELS): cv.ensure_list_csv, + vol.Optional(PRIORITY): vol.All(vol.Coerce(int), + vol.Range(min=1, max=4)), + vol.Optional(DUE_DATE): cv.string +}) + +CONF_EXTRA_PROJECTS = 'custom_projects' +CONF_PROJECT_DUE_DATE = 'due_date_days' +CONF_PROJECT_WHITELIST = 'include_projects' +CONF_PROJECT_LABEL_WHITELIST = 'labels' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_TOKEN): cv.string, + vol.Optional(CONF_EXTRA_PROJECTS, default=[]): + vol.All(cv.ensure_list, vol.Schema([ + vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_PROJECT_DUE_DATE): vol.Coerce(int), + vol.Optional(CONF_PROJECT_WHITELIST, default=[]): + vol.All(cv.ensure_list, [vol.All(cv.string, vol.Lower)]), + vol.Optional(CONF_PROJECT_LABEL_WHITELIST, default=[]): + vol.All(cv.ensure_list, [vol.All(cv.string, vol.Lower)]) + }) + ])) +}) + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Todoist platform.""" + # Check token: + token = config.get(CONF_TOKEN) + + # Look up IDs based on (lowercase) names. + project_id_lookup = {} + label_id_lookup = {} + + from todoist.api import TodoistAPI + api = TodoistAPI(token) + api.sync() + + # Setup devices: + # Grab all projects. + projects = api.state[PROJECTS] + + # Grab all labels + labels = api.state[LABELS] + + # Add all Todoist-defined projects. + project_devices = [] + for project in projects: + # Project is an object, not a dict! + # Because of that, we convert what we need to a dict. + project_data = { + CONF_NAME: project[NAME], + CONF_ID: project[ID] + } + project_devices.append( + TodoistProjectDevice(hass, project_data, labels, api) + ) + # Cache the names so we can easily look up name->ID. + project_id_lookup[project[NAME].lower()] = project[ID] + + # Cache all label names + for label in labels: + label_id_lookup[label[NAME].lower()] = label[ID] + + # Check config for more projects. + extra_projects = config.get(CONF_EXTRA_PROJECTS) + for project in extra_projects: + # Special filter: By date + project_due_date = project.get(CONF_PROJECT_DUE_DATE) + + # Special filter: By label + project_label_filter = project.get(CONF_PROJECT_LABEL_WHITELIST) + + # Special filter: By name + # Names must be converted into IDs. + project_name_filter = project.get(CONF_PROJECT_WHITELIST) + project_id_filter = [ + project_id_lookup[project_name.lower()] + for project_name in project_name_filter] + + # Create the custom project and add it to the devices array. + project_devices.append( + TodoistProjectDevice( + hass, project, labels, api, project_due_date, + project_label_filter, project_id_filter + ) + ) + + add_devices(project_devices) + + # Services: + descriptions = load_yaml_config_file( + os.path.join(os.path.dirname(__file__), 'services.yaml')) + + def handle_new_task(call): + """Called when a user creates a new Todoist Task from HASS.""" + project_name = call.data[PROJECT_NAME] + project_id = project_id_lookup[project_name] + + # Create the task + item = api.items.add(call.data[CONTENT], project_id) + + if LABELS in call.data: + task_labels = call.data[LABELS] + label_ids = [ + label_id_lookup[label.lower()] + for label in task_labels] + item.update(labels=label_ids) + + if PRIORITY in call.data: + item.update(priority=call.data[PRIORITY]) + + if DUE_DATE in call.data: + due_date = dt.parse_datetime(call.data[DUE_DATE]) + if due_date is None: + due = dt.parse_date(call.data[DUE_DATE]) + due_date = datetime(due.year, due.month, due.day) + # Format it in the manner Todoist expects + due_date = dt.as_utc(due_date) + date_format = '%Y-%m-%dT%H:%M' + due_date = datetime.strftime(due_date, date_format) + item.update(due_date_utc=due_date) + # Commit changes + api.commit() + _LOGGER.debug("Created Todoist task: %s", call.data[CONTENT]) + + hass.services.register(DOMAIN, SERVICE_NEW_TASK, handle_new_task, + descriptions[DOMAIN][SERVICE_NEW_TASK], + schema=NEW_TASK_SERVICE_SCHEMA) + + +class TodoistProjectDevice(CalendarEventDevice): + """A device for getting the next Task from a Todoist Project.""" + + def __init__(self, hass, data, labels, token, + latest_task_due_date=None, whitelisted_labels=None, + whitelisted_projects=None): + """Create the Todoist Calendar Event Device.""" + self.data = TodoistProjectData( + data, labels, token, latest_task_due_date, + whitelisted_labels, whitelisted_projects + ) + + # Set up the calendar side of things + calendar_format = { + CONF_NAME: data[CONF_NAME], + # Set Entity ID to use the name so we can identify calendars + CONF_DEVICE_ID: data[CONF_NAME] + } + + super().__init__(hass, calendar_format) + + def update(self): + """Update all Todoist Calendars.""" + # Set basic calendar data + super().update() + + # Set Todoist-specific data that can't easily be grabbed + self._cal_data[ALL_TASKS] = [ + task[SUMMARY] for task in self.data.all_project_tasks] + + def cleanup(self): + """Clean up all calendar data.""" + super().cleanup() + self._cal_data[ALL_TASKS] = [] + + @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 + + # Add additional attributes. + attributes[DUE_TODAY] = self.data.event[DUE_TODAY] + attributes[OVERDUE] = self.data.event[OVERDUE] + attributes[ALL_TASKS] = self._cal_data[ALL_TASKS] + attributes[PRIORITY] = self.data.event[PRIORITY] + attributes[LABELS] = self.data.event[LABELS] + + return attributes + + +class TodoistProjectData(object): + """ + Class used by the Task Device service object to hold all Todoist Tasks. + + This is analagous to the GoogleCalendarData found in the Google Calendar + component. + + Takes an object with a 'name' field and optionally an 'id' field (either + user-defined or from the Todoist API), a Todoist API token, and an optional + integer specifying the latest number of days from now a task can be due (7 + means everything due in the next week, 0 means today, etc.). + + This object has an exposed 'event' property (used by the Calendar platform + to determine the next calendar event) and an exposed 'update' method (used + by the Calendar platform to poll for new calendar events). + + The 'event' is a representation of a Todoist Task, with defined parameters + of 'due_today' (is the task due today?), 'all_day' (does the task have a + due date?), 'task_labels' (all labels assigned to the task), 'message' + (the content of the task, e.g. 'Fetch Mail'), 'description' (a URL pointing + to the task on the Todoist website), 'end_time' (what time the event is + due), 'start_time' (what time this event was last updated), 'overdue' (is + the task past its due date?), 'priority' (1-4, how important the task is, + with 4 being the most important), and 'all_tasks' (all tasks in this + project, sorted by how important they are). + + 'offset_reached', 'location', and 'friendly_name' are defined by the + platform itself, but are not used by this component at all. + + The 'update' method polls the Todoist API for new projects/tasks, as well + as any updates to current projects/tasks. This is throttled to every + MIN_TIME_BETWEEN_UPDATES minutes. + """ + + def __init__(self, project_data, labels, api, + latest_task_due_date=None, whitelisted_labels=None, + whitelisted_projects=None): + """Initialize a Todoist Project.""" + self.event = None + + self._api = api + self._name = project_data.get(CONF_NAME) + # If no ID is defined, fetch all tasks. + self._id = project_data.get(CONF_ID) + + # All labels the user has defined, for easy lookup. + self._labels = labels + # Not tracked: order, indent, comment_count. + + self.all_project_tasks = [] + + # The latest date a task can be due (for making lists of everything + # due today, or everything due in the next week, for example). + if latest_task_due_date is not None: + self._latest_due_date = dt.utcnow() + timedelta( + days=latest_task_due_date) + else: + self._latest_due_date = None + + # Only tasks with one of these labels will be included. + if whitelisted_labels is not None: + self._label_whitelist = whitelisted_labels + else: + self._label_whitelist = [] + + # This project includes only projects with these names. + if whitelisted_projects is not None: + self._project_id_whitelist = whitelisted_projects + else: + self._project_id_whitelist = [] + + def create_todoist_task(self, data): + """ + Create a dictionary based on a Task passed from the Todoist API. + + Will return 'None' if the task is to be filtered out. + """ + task = {} + # Fields are required to be in all returned task objects. + task[SUMMARY] = data[CONTENT] + task[COMPLETED] = data[CHECKED] == 1 + task[PRIORITY] = data[PRIORITY] + task[DESCRIPTION] = 'https://todoist.com/showTask?id={}'.format( + data[ID]) + + # All task Labels (optional parameter). + task[LABELS] = [ + label[NAME].lower() for label in self._labels + if label[ID] in data[LABELS]] + + if self._label_whitelist and ( + not any(label in task[LABELS] + for label in self._label_whitelist)): + # We're not on the whitelist, return invalid task. + return None + + # Due dates (optional parameter). + # The due date is the END date -- the task cannot be completed + # past this time. + # That means that the START date is the earliest time one can + # complete the task. + # Generally speaking, that means right now. + task[START] = dt.utcnow() + if data[DUE_DATE_UTC] is not None: + due_date = data[DUE_DATE_UTC] + + # Due dates are represented in RFC3339 format, in UTC. + # Home Assistant exclusively uses UTC, so it'll + # handle the conversion. + time_format = '%a %d %b %Y %H:%M:%S %z' + # HASS' built-in parse time function doesn't like + # Todoist's time format; strptime has to be used. + task[END] = datetime.strptime(due_date, time_format) + + if self._latest_due_date is not None and ( + task[END] > self._latest_due_date): + # This task is out of range of our due date; + # it shouldn't be counted. + return None + + task[DUE_TODAY] = task[END].date() == datetime.today().date() + + # Special case: Task is overdue. + if task[END] <= task[START]: + task[OVERDUE] = True + # Set end time to the current time plus 1 hour. + # We're pretty much guaranteed to update within that 1 hour, + # so it should be fine. + task[END] = task[START] + timedelta(hours=1) + else: + task[OVERDUE] = False + else: + # If we ask for everything due before a certain date, don't count + # things which have no due dates. + if self._latest_due_date is not None: + return None + + # Define values for tasks without due dates + task[END] = None + task[ALL_DAY] = True + task[DUE_TODAY] = False + task[OVERDUE] = False + + # Not tracked: id, comments, project_id order, indent, recurring. + return task + + @staticmethod + def select_best_task(project_tasks): + """ + Search through a list of events for the "best" event to select. + + The "best" event is determined by the following criteria: + * A proposed event must not be completed + * A proposed event must have a end date (otherwise we go with + the event at index 0, selected above) + * A proposed event must be on the same day or earlier as our + current event + * If a proposed event is an earlier day than what we have so + far, select it + * If a proposed event is on the same day as our current event + and the proposed event has a higher priority than our current + event, select it + * If a proposed event is on the same day as our current event, + has the same priority as our current event, but is due earlier + in the day, select it + """ + # Start at the end of the list, so if tasks don't have a due date + # the newest ones are the most important. + + event = project_tasks[-1] + + for proposed_event in project_tasks: + if event == proposed_event: + continue + if proposed_event[COMPLETED]: + # Event is complete! + continue + if proposed_event[END] is None: + # No end time: + if event[END] is None and ( + proposed_event[PRIORITY] < event[PRIORITY]): + # They also have no end time, + # but we have a higher priority. + event = proposed_event + continue + else: + continue + elif event[END] is None: + # We have an end time, they do not. + event = proposed_event + continue + if proposed_event[END].date() > event[END].date(): + # Event is too late. + continue + elif proposed_event[END].date() < event[END].date(): + # Event is earlier than current, select it. + event = proposed_event + continue + else: + if proposed_event[PRIORITY] > event[PRIORITY]: + # Proposed event has a higher priority. + event = proposed_event + continue + elif proposed_event[PRIORITY] == event[PRIORITY] and ( + proposed_event[END] < event[END]): + event = proposed_event + continue + return event + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data.""" + if self._id is None: + project_task_data = [ + task for task in self._api.state[TASKS] + if not self._project_id_whitelist or + task[PROJECT_ID] in self._project_id_whitelist] + else: + project_task_data = self._api.projects.get_data(self._id)[TASKS] + + # If we have no data, we can just return right away. + if not project_task_data: + self.event = None + return True + + # Keep an updated list of all tasks in this project. + project_tasks = [] + + for task in project_task_data: + todoist_task = self.create_todoist_task(task) + if todoist_task is not None: + # A None task means it is invalid for this project + project_tasks.append(todoist_task) + + if not project_tasks: + # We had no valid tasks + return True + + # Organize the best tasks (so users can see all the tasks + # they have, organized) + while len(project_tasks) > 0: + best_task = self.select_best_task(project_tasks) + _LOGGER.debug("Found Todoist Task: %s", best_task[SUMMARY]) + project_tasks.remove(best_task) + self.all_project_tasks.append(best_task) + + self.event = self.all_project_tasks[0] + + # Convert datetime to a string again + if self.event is not None: + if self.event[START] is not None: + self.event[START] = { + DATETIME: self.event[START].strftime(DATE_STR_FORMAT) + } + if self.event[END] is not None: + self.event[END] = { + DATETIME: self.event[END].strftime(DATE_STR_FORMAT) + } + else: + # HASS gets cranky if a calendar event never ends + # Let's set our "due date" to tomorrow + self.event[END] = { + DATETIME: ( + datetime.utcnow() + + timedelta(days=1) + ).strftime(DATE_STR_FORMAT) + } + _LOGGER.debug("Updated %s", self._name) + return True diff --git a/homeassistant/components/camera/foscam.py b/homeassistant/components/camera/foscam.py index 8ea90d5a44e27d..3f2761e332a5c0 100644 --- a/homeassistant/components/camera/foscam.py +++ b/homeassistant/components/camera/foscam.py @@ -15,7 +15,7 @@ _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pyfoscam==1.2'] +REQUIREMENTS = ['libpyfoscam==1.0'] CONF_IP = 'ip' @@ -53,7 +53,7 @@ def __init__(self, device_info): self._name = device_info.get(CONF_NAME) self._motion_status = False - from foscam.foscam import FoscamCamera + from libpyfoscam import FoscamCamera self._foscam_session = FoscamCamera(ip_address, port, self._username, self._password, verbose=False) diff --git a/homeassistant/components/camera/uvc.py b/homeassistant/components/camera/uvc.py index 3203a10b39125e..685b6d643642f3 100644 --- a/homeassistant/components/camera/uvc.py +++ b/homeassistant/components/camera/uvc.py @@ -14,7 +14,7 @@ from homeassistant.components.camera import Camera, PLATFORM_SCHEMA import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['uvcclient==0.10.0'] +REQUIREMENTS = ['uvcclient==0.10.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/climate/generic_thermostat.py b/homeassistant/components/climate/generic_thermostat.py index 9442b7da194462..6af06323fd0a6d 100644 --- a/homeassistant/components/climate/generic_thermostat.py +++ b/homeassistant/components/climate/generic_thermostat.py @@ -211,7 +211,7 @@ def _async_switch_changed(self, entity_id, old_state, new_state): """Handle heater switch state changes.""" if new_state is None: return - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @callback def _async_keep_alive(self, time): diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 8804f6d113fab5..44796f97166c17 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -4,10 +4,11 @@ import voluptuous as vol -from . import http_api, cloud_api +from . import http_api, auth_api from .const import DOMAIN +REQUIREMENTS = ['warrant==0.2.0'] DEPENDENCIES = ['http'] CONF_MODE = 'mode' MODE_DEV = 'development' @@ -40,10 +41,7 @@ def async_setup(hass, config): 'mode': mode } - cloud = yield from cloud_api.async_load_auth(hass) - - if cloud is not None: - data['cloud'] = cloud + data['auth'] = yield from hass.async_add_job(auth_api.load_auth, hass) yield from http_api.async_setup(hass) return True diff --git a/homeassistant/components/cloud/auth_api.py b/homeassistant/components/cloud/auth_api.py new file mode 100644 index 00000000000000..0baadeece46763 --- /dev/null +++ b/homeassistant/components/cloud/auth_api.py @@ -0,0 +1,270 @@ +"""Package to offer tools to authenticate with the cloud.""" +import json +import logging +import os + +from .const import AUTH_FILE, SERVERS +from .util import get_mode + +_LOGGER = logging.getLogger(__name__) + + +class CloudError(Exception): + """Base class for cloud related errors.""" + + +class Unauthenticated(CloudError): + """Raised when authentication failed.""" + + +class UserNotFound(CloudError): + """Raised when a user is not found.""" + + +class UserNotConfirmed(CloudError): + """Raised when a user has not confirmed email yet.""" + + +class ExpiredCode(CloudError): + """Raised when an expired code is encoutered.""" + + +class InvalidCode(CloudError): + """Raised when an invalid code is submitted.""" + + +class PasswordChangeRequired(CloudError): + """Raised when a password change is required.""" + + def __init__(self, message='Password change required.'): + """Initialize a password change required error.""" + super().__init__(message) + + +class UnknownError(CloudError): + """Raised when an unknown error occurrs.""" + + +AWS_EXCEPTIONS = { + 'UserNotFoundException': UserNotFound, + 'NotAuthorizedException': Unauthenticated, + 'ExpiredCodeException': ExpiredCode, + 'UserNotConfirmedException': UserNotConfirmed, + 'PasswordResetRequiredException': PasswordChangeRequired, + 'CodeMismatchException': InvalidCode, +} + + +def _map_aws_exception(err): + """Map AWS exception to our exceptions.""" + ex = AWS_EXCEPTIONS.get(err.response['Error']['Code'], UnknownError) + return ex(err.response['Error']['Message']) + + +def load_auth(hass): + """Load authentication from disk and verify it.""" + info = _read_info(hass) + + if info is None: + return Auth(hass) + + auth = Auth(hass, _cognito( + hass, + id_token=info['id_token'], + access_token=info['access_token'], + refresh_token=info['refresh_token'], + )) + + if auth.validate_auth(): + return auth + + return Auth(hass) + + +def register(hass, email, password): + """Register a new account.""" + from botocore.exceptions import ClientError + + cognito = _cognito(hass, username=email) + try: + cognito.register(email, password) + except ClientError as err: + raise _map_aws_exception(err) + + +def confirm_register(hass, confirmation_code, email): + """Confirm confirmation code after registration.""" + from botocore.exceptions import ClientError + + cognito = _cognito(hass, username=email) + try: + cognito.confirm_sign_up(confirmation_code, email) + except ClientError as err: + raise _map_aws_exception(err) + + +def forgot_password(hass, email): + """Initiate forgotten password flow.""" + from botocore.exceptions import ClientError + + cognito = _cognito(hass, username=email) + try: + cognito.initiate_forgot_password() + except ClientError as err: + raise _map_aws_exception(err) + + +def confirm_forgot_password(hass, confirmation_code, email, new_password): + """Confirm forgotten password code and change password.""" + from botocore.exceptions import ClientError + + cognito = _cognito(hass, username=email) + try: + cognito.confirm_forgot_password(confirmation_code, new_password) + except ClientError as err: + raise _map_aws_exception(err) + + +class Auth(object): + """Class that holds Cloud authentication.""" + + def __init__(self, hass, cognito=None): + """Initialize Hass cloud info object.""" + self.hass = hass + self.cognito = cognito + self.account = None + + @property + def is_logged_in(self): + """Return if user is logged in.""" + return self.account is not None + + def validate_auth(self): + """Validate that the contained auth is valid.""" + from botocore.exceptions import ClientError + + try: + self._refresh_account_info() + except ClientError as err: + if err.response['Error']['Code'] != 'NotAuthorizedException': + _LOGGER.error('Unexpected error verifying auth: %s', err) + return False + + try: + self.renew_access_token() + self._refresh_account_info() + except ClientError: + _LOGGER.error('Unable to refresh auth token: %s', err) + return False + + return True + + def login(self, username, password): + """Login using a username and password.""" + from botocore.exceptions import ClientError + from warrant.exceptions import ForceChangePasswordException + + cognito = _cognito(self.hass, username=username) + + try: + cognito.authenticate(password=password) + self.cognito = cognito + self._refresh_account_info() + _write_info(self.hass, self) + + except ForceChangePasswordException as err: + raise PasswordChangeRequired + + except ClientError as err: + raise _map_aws_exception(err) + + def _refresh_account_info(self): + """Refresh the account info. + + Raises boto3 exceptions. + """ + self.account = self.cognito.get_user() + + def renew_access_token(self): + """Refresh token.""" + from botocore.exceptions import ClientError + + try: + self.cognito.renew_access_token() + _write_info(self.hass, self) + return True + except ClientError as err: + _LOGGER.error('Error refreshing token: %s', err) + return False + + def logout(self): + """Invalidate token.""" + from botocore.exceptions import ClientError + + try: + self.cognito.logout() + self.account = None + _write_info(self.hass, self) + except ClientError as err: + raise _map_aws_exception(err) + + +def _read_info(hass): + """Read auth file.""" + path = hass.config.path(AUTH_FILE) + + if not os.path.isfile(path): + return None + + with open(path) as file: + return json.load(file).get(get_mode(hass)) + + +def _write_info(hass, auth): + """Write auth info for specified mode. + + Pass in None for data to remove authentication for that mode. + """ + path = hass.config.path(AUTH_FILE) + mode = get_mode(hass) + + if os.path.isfile(path): + with open(path) as file: + content = json.load(file) + else: + content = {} + + if auth.is_logged_in: + content[mode] = { + 'id_token': auth.cognito.id_token, + 'access_token': auth.cognito.access_token, + 'refresh_token': auth.cognito.refresh_token, + } + else: + content.pop(mode, None) + + with open(path, 'wt') as file: + file.write(json.dumps(content, indent=4, sort_keys=True)) + + +def _cognito(hass, **kwargs): + """Get the client credentials.""" + from warrant import Cognito + + mode = get_mode(hass) + + info = SERVERS.get(mode) + + if info is None: + raise ValueError('Mode {} is not supported.'.format(mode)) + + cognito = Cognito( + user_pool_id=info['identity_pool_id'], + client_id=info['client_id'], + user_pool_region=info['region'], + access_key=info['access_key_id'], + secret_key=info['secret_access_key'], + **kwargs + ) + + return cognito diff --git a/homeassistant/components/cloud/cloud_api.py b/homeassistant/components/cloud/cloud_api.py deleted file mode 100644 index 6429da145167da..00000000000000 --- a/homeassistant/components/cloud/cloud_api.py +++ /dev/null @@ -1,297 +0,0 @@ -"""Package to offer tools to communicate with the cloud.""" -import asyncio -from datetime import timedelta -import json -import logging -import os -from urllib.parse import urljoin - -import aiohttp -import async_timeout - -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.util.dt import utcnow - -from .const import AUTH_FILE, REQUEST_TIMEOUT, SERVERS -from .util import get_mode - -_LOGGER = logging.getLogger(__name__) - - -URL_CREATE_TOKEN = 'o/token/' -URL_REVOKE_TOKEN = 'o/revoke_token/' -URL_ACCOUNT = 'account.json' - - -class CloudError(Exception): - """Base class for cloud related errors.""" - - def __init__(self, reason=None, status=None): - """Initialize a cloud error.""" - super().__init__(reason) - self.status = status - - -class Unauthenticated(CloudError): - """Raised when authentication failed.""" - - -class UnknownError(CloudError): - """Raised when an unknown error occurred.""" - - -@asyncio.coroutine -def async_load_auth(hass): - """Load authentication from disk and verify it.""" - auth = yield from hass.async_add_job(_read_auth, hass) - - if not auth: - return None - - cloud = Cloud(hass, auth) - - try: - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - auth_check = yield from cloud.async_refresh_account_info() - - if not auth_check: - _LOGGER.error('Unable to validate credentials.') - return None - - return cloud - - except asyncio.TimeoutError: - _LOGGER.error('Unable to reach server to validate credentials.') - return None - - -@asyncio.coroutine -def async_login(hass, username, password, scope=None): - """Get a token using a username and password. - - Returns a coroutine. - """ - data = { - 'grant_type': 'password', - 'username': username, - 'password': password - } - if scope is not None: - data['scope'] = scope - - auth = yield from _async_get_token(hass, data) - - yield from hass.async_add_job(_write_auth, hass, auth) - - return Cloud(hass, auth) - - -@asyncio.coroutine -def _async_get_token(hass, data): - """Get a new token and return it as a dictionary. - - Raises exceptions when errors occur: - - Unauthenticated - - UnknownError - """ - session = async_get_clientsession(hass) - auth = aiohttp.BasicAuth(*_client_credentials(hass)) - - try: - req = yield from session.post( - _url(hass, URL_CREATE_TOKEN), - data=data, - auth=auth - ) - - if req.status == 401: - _LOGGER.error('Cloud login failed: %d', req.status) - raise Unauthenticated(status=req.status) - elif req.status != 200: - _LOGGER.error('Cloud login failed: %d', req.status) - raise UnknownError(status=req.status) - - response = yield from req.json() - response['expires_at'] = \ - (utcnow() + timedelta(seconds=response['expires_in'])).isoformat() - - return response - - except aiohttp.ClientError: - raise UnknownError() - - -class Cloud: - """Store Hass Cloud info.""" - - def __init__(self, hass, auth): - """Initialize Hass cloud info object.""" - self.hass = hass - self.auth = auth - self.account = None - - @property - def access_token(self): - """Return access token.""" - return self.auth['access_token'] - - @property - def refresh_token(self): - """Get refresh token.""" - return self.auth['refresh_token'] - - @asyncio.coroutine - def async_refresh_account_info(self): - """Refresh the account info.""" - req = yield from self.async_request('get', URL_ACCOUNT) - - if req.status != 200: - return False - - self.account = yield from req.json() - return True - - @asyncio.coroutine - def async_refresh_access_token(self): - """Get a token using a refresh token.""" - try: - self.auth = yield from _async_get_token(self.hass, { - 'grant_type': 'refresh_token', - 'refresh_token': self.refresh_token, - }) - - yield from self.hass.async_add_job( - _write_auth, self.hass, self.auth) - - return True - except CloudError: - return False - - @asyncio.coroutine - def async_revoke_access_token(self): - """Revoke active access token.""" - session = async_get_clientsession(self.hass) - client_id, client_secret = _client_credentials(self.hass) - data = { - 'token': self.access_token, - 'client_id': client_id, - 'client_secret': client_secret - } - try: - req = yield from session.post( - _url(self.hass, URL_REVOKE_TOKEN), - data=data, - ) - - if req.status != 200: - _LOGGER.error('Cloud logout failed: %d', req.status) - raise UnknownError(status=req.status) - - self.auth = None - yield from self.hass.async_add_job( - _write_auth, self.hass, None) - - except aiohttp.ClientError: - raise UnknownError() - - @asyncio.coroutine - def async_request(self, method, path, **kwargs): - """Make a request to Home Assistant cloud. - - Will refresh the token if necessary. - """ - session = async_get_clientsession(self.hass) - url = _url(self.hass, path) - - if 'headers' not in kwargs: - kwargs['headers'] = {} - - kwargs['headers']['authorization'] = \ - 'Bearer {}'.format(self.access_token) - - request = yield from session.request(method, url, **kwargs) - - if request.status != 403: - return request - - # Maybe token expired. Try refreshing it. - reauth = yield from self.async_refresh_access_token() - - if not reauth: - return request - - # Release old connection back to the pool. - yield from request.release() - - kwargs['headers']['authorization'] = \ - 'Bearer {}'.format(self.access_token) - - # If we are not already fetching the account info, - # refresh the account info. - - if path != URL_ACCOUNT: - yield from self.async_refresh_account_info() - - request = yield from session.request(method, url, **kwargs) - - return request - - -def _read_auth(hass): - """Read auth file.""" - path = hass.config.path(AUTH_FILE) - - if not os.path.isfile(path): - return None - - with open(path) as file: - return json.load(file).get(get_mode(hass)) - - -def _write_auth(hass, data): - """Write auth info for specified mode. - - Pass in None for data to remove authentication for that mode. - """ - path = hass.config.path(AUTH_FILE) - mode = get_mode(hass) - - if os.path.isfile(path): - with open(path) as file: - content = json.load(file) - else: - content = {} - - if data is None: - content.pop(mode, None) - else: - content[mode] = data - - with open(path, 'wt') as file: - file.write(json.dumps(content, indent=4, sort_keys=True)) - - -def _client_credentials(hass): - """Get the client credentials. - - Async friendly. - """ - mode = get_mode(hass) - - if mode not in SERVERS: - raise ValueError('Mode {} is not supported.'.format(mode)) - - return SERVERS[mode]['client_id'], SERVERS[mode]['client_secret'] - - -def _url(hass, path): - """Generate a url for the cloud. - - Async friendly. - """ - mode = get_mode(hass) - - if mode not in SERVERS: - raise ValueError('Mode {} is not supported.'.format(mode)) - - return urljoin(SERVERS[mode]['host'], path) diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index f55a4be21a2ff8..81beab1891b7c1 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -5,10 +5,10 @@ SERVERS = { 'development': { - 'host': 'http://localhost:8000', - 'client_id': 'HBhQxeV8H4aFBcs7jrZUeeDud0FjGEJJSZ9G6gNu', - 'client_secret': ('V1qw2NhB32cSAlP7DOezjgWNgn7ZKgq0jvVZoYSI0KCmg9rg7q4' - 'BSzoebnQnX6tuHCJiZjm2479mZmmtf2LOUdnSqOqkSpjc3js7Wu' - 'VBJrRyfgTVd43kbrEQtuOiaUpK') + 'client_id': '3k755iqfcgv8t12o4pl662mnos', + 'identity_pool_id': 'us-west-2_vDOfweDJo', + 'region': 'us-west-2', + 'access_key_id': 'AKIAJGRK7MILPRJTT2ZQ', + 'secret_access_key': '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' } } diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 661cc8a7ba1d7d..941df7648a6545 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -1,14 +1,16 @@ """The HTTP api to control the cloud integration.""" import asyncio +from functools import wraps import logging import voluptuous as vol import async_timeout -from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http import ( + HomeAssistantView, RequestDataValidator) -from . import cloud_api -from .const import DOMAIN, REQUEST_TIMEOUT +from . import auth_api +from .const import REQUEST_TIMEOUT _LOGGER = logging.getLogger(__name__) @@ -19,59 +21,66 @@ def async_setup(hass): hass.http.register_view(CloudLoginView) hass.http.register_view(CloudLogoutView) hass.http.register_view(CloudAccountView) + hass.http.register_view(CloudRegisterView) + hass.http.register_view(CloudConfirmRegisterView) + hass.http.register_view(CloudForgotPasswordView) + hass.http.register_view(CloudConfirmForgotPasswordView) -class CloudLoginView(HomeAssistantView): - """Login to Home Assistant cloud.""" +_CLOUD_ERRORS = { + auth_api.UserNotFound: (400, "User does not exist."), + auth_api.UserNotConfirmed: (400, 'Email not confirmed.'), + auth_api.Unauthenticated: (401, 'Authentication failed.'), + auth_api.PasswordChangeRequired: (400, 'Password change required.'), + auth_api.ExpiredCode: (400, 'Confirmation code has expired.'), + auth_api.InvalidCode: (400, 'Invalid confirmation code.'), + asyncio.TimeoutError: (502, 'Unable to reach the Home Assistant cloud.') +} - url = '/api/cloud/login' - name = 'api:cloud:login' - schema = vol.Schema({ - vol.Required('username'): str, - vol.Required('password'): str, - }) +def _handle_cloud_errors(handler): + """Helper method to handle auth errors.""" @asyncio.coroutine - def post(self, request): - """Validate config and return results.""" + @wraps(handler) + def error_handler(view, request, *args, **kwargs): + """Handle exceptions that raise from the wrapped request handler.""" try: - data = yield from request.json() - except ValueError: - _LOGGER.error('Login with invalid JSON') - return self.json_message('Invalid JSON.', 400) + result = yield from handler(view, request, *args, **kwargs) + return result - try: - self.schema(data) - except vol.Invalid as err: - _LOGGER.error('Login with invalid formatted data') - return self.json_message( - 'Message format incorrect: {}'.format(err), 400) + except (auth_api.CloudError, asyncio.TimeoutError) as err: + err_info = _CLOUD_ERRORS.get(err.__class__) + if err_info is None: + err_info = (502, 'Unexpected error: {}'.format(err)) + status, msg = err_info + return view.json_message(msg, status_code=status, + message_code=err.__class__.__name__) - hass = request.app['hass'] - phase = 1 - try: - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - cloud = yield from cloud_api.async_login( - hass, data['username'], data['password']) + return error_handler - phase += 1 - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - yield from cloud.async_refresh_account_info() +class CloudLoginView(HomeAssistantView): + """Login to Home Assistant cloud.""" - except cloud_api.Unauthenticated: - return self.json_message( - 'Authentication failed (phase {}).'.format(phase), 401) - except cloud_api.UnknownError: - return self.json_message( - 'Unknown error occurred (phase {}).'.format(phase), 500) - except asyncio.TimeoutError: - return self.json_message( - 'Unable to reach Home Assistant cloud ' - '(phase {}).'.format(phase), 502) + url = '/api/cloud/login' + name = 'api:cloud:login' - hass.data[DOMAIN]['cloud'] = cloud - return self.json(cloud.account) + @asyncio.coroutine + @_handle_cloud_errors + @RequestDataValidator(vol.Schema({ + vol.Required('email'): str, + vol.Required('password'): str, + })) + def post(self, request, data): + """Handle login request.""" + hass = request.app['hass'] + auth = hass.data['cloud']['auth'] + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + yield from hass.async_add_job(auth.login, data['email'], + data['password']) + + return self.json(_auth_data(auth)) class CloudLogoutView(HomeAssistantView): @@ -81,39 +90,133 @@ class CloudLogoutView(HomeAssistantView): name = 'api:cloud:logout' @asyncio.coroutine + @_handle_cloud_errors def post(self, request): - """Validate config and return results.""" + """Handle logout request.""" hass = request.app['hass'] - try: - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - yield from \ - hass.data[DOMAIN]['cloud'].async_revoke_access_token() + auth = hass.data['cloud']['auth'] - hass.data[DOMAIN].pop('cloud') + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + yield from hass.async_add_job(auth.logout) - return self.json({ - 'result': 'ok', - }) - except asyncio.TimeoutError: - return self.json_message("Could not reach the server.", 502) - except cloud_api.UnknownError as err: - return self.json_message( - "Error communicating with the server ({}).".format(err.status), - 502) + return self.json_message('ok') class CloudAccountView(HomeAssistantView): - """Log out of the Home Assistant cloud.""" + """View to retrieve account info.""" url = '/api/cloud/account' name = 'api:cloud:account' @asyncio.coroutine def get(self, request): - """Validate config and return results.""" + """Get account info.""" hass = request.app['hass'] + auth = hass.data['cloud']['auth'] - if 'cloud' not in hass.data[DOMAIN]: + if not auth.is_logged_in: return self.json_message('Not logged in', 400) - return self.json(hass.data[DOMAIN]['cloud'].account) + return self.json(_auth_data(auth)) + + +class CloudRegisterView(HomeAssistantView): + """Register on the Home Assistant cloud.""" + + url = '/api/cloud/register' + name = 'api:cloud:register' + + @asyncio.coroutine + @_handle_cloud_errors + @RequestDataValidator(vol.Schema({ + vol.Required('email'): str, + vol.Required('password'): vol.All(str, vol.Length(min=6)), + })) + def post(self, request, data): + """Handle registration request.""" + hass = request.app['hass'] + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + yield from hass.async_add_job( + auth_api.register, hass, data['email'], data['password']) + + return self.json_message('ok') + + +class CloudConfirmRegisterView(HomeAssistantView): + """Confirm registration on the Home Assistant cloud.""" + + url = '/api/cloud/confirm_register' + name = 'api:cloud:confirm_register' + + @asyncio.coroutine + @_handle_cloud_errors + @RequestDataValidator(vol.Schema({ + vol.Required('confirmation_code'): str, + vol.Required('email'): str, + })) + def post(self, request, data): + """Handle registration confirmation request.""" + hass = request.app['hass'] + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + yield from hass.async_add_job( + auth_api.confirm_register, hass, data['confirmation_code'], + data['email']) + + return self.json_message('ok') + + +class CloudForgotPasswordView(HomeAssistantView): + """View to start Forgot Password flow..""" + + url = '/api/cloud/forgot_password' + name = 'api:cloud:forgot_password' + + @asyncio.coroutine + @_handle_cloud_errors + @RequestDataValidator(vol.Schema({ + vol.Required('email'): str, + })) + def post(self, request, data): + """Handle forgot password request.""" + hass = request.app['hass'] + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + yield from hass.async_add_job( + auth_api.forgot_password, hass, data['email']) + + return self.json_message('ok') + + +class CloudConfirmForgotPasswordView(HomeAssistantView): + """View to finish Forgot Password flow..""" + + url = '/api/cloud/confirm_forgot_password' + name = 'api:cloud:confirm_forgot_password' + + @asyncio.coroutine + @_handle_cloud_errors + @RequestDataValidator(vol.Schema({ + vol.Required('confirmation_code'): str, + vol.Required('email'): str, + vol.Required('new_password'): vol.All(str, vol.Length(min=6)) + })) + def post(self, request, data): + """Handle forgot password confirm request.""" + hass = request.app['hass'] + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + yield from hass.async_add_job( + auth_api.confirm_forgot_password, hass, + data['confirmation_code'], data['email'], + data['new_password']) + + return self.json_message('ok') + + +def _auth_data(auth): + """Generate the auth data JSON response.""" + return { + 'email': auth.account.email + } diff --git a/homeassistant/components/config/zwave.py b/homeassistant/components/config/zwave.py index a40e1f640436ff..53fa200a1b12e4 100644 --- a/homeassistant/components/config/zwave.py +++ b/homeassistant/components/config/zwave.py @@ -55,6 +55,7 @@ def get(self, request, node_id): 'label': entity_values.primary.label, 'index': entity_values.primary.index, 'instance': entity_values.primary.instance, + 'poll_intensity': entity_values.primary.poll_intensity, } return self.json(values_data) diff --git a/homeassistant/components/cover/knx.py b/homeassistant/components/cover/knx.py index e4c2931983d518..296d8d36394f9a 100644 --- a/homeassistant/components/cover/knx.py +++ b/homeassistant/components/cover/knx.py @@ -215,7 +215,7 @@ def stop_auto_updater(self): def auto_updater_hook(self, now): """Callback for autoupdater.""" # pylint: disable=unused-argument - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self.device.position_reached(): self.stop_auto_updater() diff --git a/homeassistant/components/cover/mqtt.py b/homeassistant/components/cover/mqtt.py index eab64fd7abbaef..8e197cc2e0251a 100644 --- a/homeassistant/components/cover/mqtt.py +++ b/homeassistant/components/cover/mqtt.py @@ -178,7 +178,7 @@ def tilt_updated(topic, payload, qos): level = self.find_percentage_in_range(float(payload)) self._tilt_value = level - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @callback def message_received(topic, payload, qos): @@ -203,7 +203,7 @@ def message_received(topic, payload, qos): payload) return - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._state_topic is None: # Force into optimistic mode. @@ -275,7 +275,7 @@ def async_open_cover(self, **kwargs): if self._optimistic: # Optimistically assume that cover has changed state. self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_close_cover(self, **kwargs): @@ -289,7 +289,7 @@ def async_close_cover(self, **kwargs): if self._optimistic: # Optimistically assume that cover has changed state. self._state = True - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_stop_cover(self, **kwargs): @@ -309,7 +309,7 @@ def async_open_cover_tilt(self, **kwargs): self._retain) if self._tilt_optimistic: self._tilt_value = self._tilt_open_position - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_close_cover_tilt(self, **kwargs): @@ -319,7 +319,7 @@ def async_close_cover_tilt(self, **kwargs): self._retain) if self._tilt_optimistic: self._tilt_value = self._tilt_closed_position - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_set_cover_tilt_position(self, **kwargs): diff --git a/homeassistant/components/cover/template.py b/homeassistant/components/cover/template.py index f9e059d3927880..2e3ad7fff16cf2 100644 --- a/homeassistant/components/cover/template.py +++ b/homeassistant/components/cover/template.py @@ -197,7 +197,7 @@ def async_added_to_hass(self): @callback def template_cover_state_listener(entity, old_state, new_state): """Handle target device state changes.""" - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) @callback def template_cover_startup(event): @@ -205,7 +205,7 @@ def template_cover_startup(event): async_track_state_change( self.hass, self._entities, template_cover_state_listener) - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_START, template_cover_startup) @@ -271,7 +271,7 @@ def async_open_cover(self, **kwargs): yield from self._position_script.async_run({"position": 100}) if self._optimistic: self._position = 100 - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_close_cover(self, **kwargs): @@ -282,7 +282,7 @@ def async_close_cover(self, **kwargs): yield from self._position_script.async_run({"position": 0}) if self._optimistic: self._position = 0 - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_stop_cover(self, **kwargs): @@ -297,7 +297,7 @@ def async_set_cover_position(self, **kwargs): yield from self._position_script.async_run( {"position": self._position}) if self._optimistic: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_open_cover_tilt(self, **kwargs): @@ -305,7 +305,7 @@ def async_open_cover_tilt(self, **kwargs): self._tilt_value = 100 yield from self._tilt_script.async_run({"tilt": self._tilt_value}) if self._tilt_optimistic: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_close_cover_tilt(self, **kwargs): @@ -314,7 +314,7 @@ def async_close_cover_tilt(self, **kwargs): yield from self._tilt_script.async_run( {"tilt": self._tilt_value}) if self._tilt_optimistic: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_set_cover_tilt_position(self, **kwargs): @@ -322,7 +322,7 @@ def async_set_cover_tilt_position(self, **kwargs): self._tilt_value = kwargs[ATTR_TILT_POSITION] yield from self._tilt_script.async_run({"tilt": self._tilt_value}) if self._tilt_optimistic: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_update(self): diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index b23008336ac062..5c5c3c7c92e60e 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -42,7 +42,7 @@ WAYPOINT_LAT_KEY = 'lat' WAYPOINT_LON_KEY = 'lon' -WAYPOINT_TOPIC = 'owntracks/{}/{}/waypoint' +WAYPOINT_TOPIC = 'owntracks/{}/{}/waypoints' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_MAX_GPS_ACCURACY): vol.Coerce(float), diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index c757d9d1ce3065..1f8b12eef6b7f7 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -21,7 +21,7 @@ from homeassistant.helpers.discovery import async_load_platform, async_discover import homeassistant.util.dt as dt_util -REQUIREMENTS = ['netdisco==1.1.0'] +REQUIREMENTS = ['netdisco==1.2.0'] DOMAIN = 'discovery' @@ -34,6 +34,7 @@ SERVICE_AXIS = 'axis' SERVICE_APPLE_TV = 'apple_tv' SERVICE_WINK = 'wink' +SERVICE_XIAOMI_GW = 'xiaomi_gw' SERVICE_HANDLERS = { SERVICE_HASS_IOS_APP: ('ios', None), @@ -44,6 +45,7 @@ SERVICE_AXIS: ('axis', None), SERVICE_APPLE_TV: ('apple_tv', None), SERVICE_WINK: ('wink', None), + SERVICE_XIAOMI_GW: ('xiaomi', None), 'philips_hue': ('light', 'hue'), 'google_cast': ('media_player', 'cast'), 'panasonic_viera': ('media_player', 'panasonic_viera'), diff --git a/homeassistant/components/eight_sleep.py b/homeassistant/components/eight_sleep.py index 40a5d884aed018..dda556ba6a4464 100644 --- a/homeassistant/components/eight_sleep.py +++ b/homeassistant/components/eight_sleep.py @@ -209,7 +209,7 @@ def async_added_to_hass(self): @callback def async_eight_user_update(): """Update callback.""" - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) async_dispatcher_connect( self.hass, SIGNAL_UPDATE_USER, async_eight_user_update) @@ -233,7 +233,7 @@ def async_added_to_hass(self): @callback def async_eight_heat_update(): """Update callback.""" - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) async_dispatcher_connect( self.hass, SIGNAL_UPDATE_HEAT, async_eight_heat_update) diff --git a/homeassistant/components/fan/mqtt.py b/homeassistant/components/fan/mqtt.py index bc732aa0aff837..58ac08ce16f0b6 100644 --- a/homeassistant/components/fan/mqtt.py +++ b/homeassistant/components/fan/mqtt.py @@ -160,7 +160,7 @@ def state_received(topic, payload, qos): self._state = True elif payload == self._payload[STATE_OFF]: self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._topic[CONF_STATE_TOPIC] is not None: yield from mqtt.async_subscribe( @@ -177,7 +177,7 @@ def speed_received(topic, payload, qos): self._speed = SPEED_MEDIUM elif payload == self._payload[SPEED_HIGH]: self._speed = SPEED_HIGH - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._topic[CONF_SPEED_STATE_TOPIC] is not None: yield from mqtt.async_subscribe( @@ -193,7 +193,7 @@ def oscillation_received(topic, payload, qos): self._oscillation = True elif payload == self._payload[OSCILLATE_OFF_PAYLOAD]: self._oscillation = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._topic[CONF_OSCILLATION_STATE_TOPIC] is not None: yield from mqtt.async_subscribe( @@ -287,7 +287,7 @@ def async_set_speed(self, speed: str) -> None: if self._optimistic_speed: self._speed = speed - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_oscillate(self, oscillating: bool) -> None: @@ -309,4 +309,4 @@ def async_oscillate(self, oscillating: bool) -> None: if self._optimistic_oscillation: self._oscillation = oscillating - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/ffmpeg.py b/homeassistant/components/ffmpeg.py index 887d07e5855a15..f5efa1ef6238a1 100644 --- a/homeassistant/components/ffmpeg.py +++ b/homeassistant/components/ffmpeg.py @@ -242,7 +242,7 @@ def async_shutdown_handle(event): def async_start_handle(event): """Start FFmpeg process.""" yield from self._async_start_ffmpeg(None) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_START, async_start_handle) diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index 21215e14d23787..87ccbf550755ba 100644 --- a/homeassistant/components/frontend/version.py +++ b/homeassistant/components/frontend/version.py @@ -3,10 +3,10 @@ FINGERPRINTS = { "compatibility.js": "1686167ff210e001f063f5c606b2e74b", "core.js": "2a7d01e45187c7d4635da05065b5e54e", - "frontend.html": "c04709d3517dd3fd34b2f7d6bba6ec8e", + "frontend.html": "6b0a95408d9ee869d0fe20c374077ed4", "mdi.html": "89074face5529f5fe6fbae49ecb3e88b", "micromarkdown-js.html": "93b5ec4016f0bba585521cf4d18dec1a", - "panels/ha-panel-config.html": "0091008947ed61a6691c28093a6a6fcd", + "panels/ha-panel-config.html": "0b985cbf668b16bca9f34727036c7139", "panels/ha-panel-dev-event.html": "d409e7ab537d9fe629126d122345279c", "panels/ha-panel-dev-info.html": "b0e55eb657fd75f21aba2426ac0cedc0", "panels/ha-panel-dev-mqtt.html": "94b222b013a98583842de3e72d5888c6", diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html index d6a15a0d610c4e..2dc0bb5f156d8f 100644 --- a/homeassistant/components/frontend/www_static/frontend.html +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -8,7 +8,7 @@ .flex-1{-ms-flex:1 1 0.000000001px;-webkit-flex:1;flex:1;-webkit-flex-basis:0.000000001px;flex-basis:0.000000001px;}.flex-2{-ms-flex:2;-webkit-flex:2;flex:2;}.flex-3{-ms-flex:3;-webkit-flex:3;flex:3;}.flex-4{-ms-flex:4;-webkit-flex:4;flex:4;}.flex-5{-ms-flex:5;-webkit-flex:5;flex:5;}.flex-6{-ms-flex:6;-webkit-flex:6;flex:6;}.flex-7{-ms-flex:7;-webkit-flex:7;flex:7;}.flex-8{-ms-flex:8;-webkit-flex:8;flex:8;}.flex-9{-ms-flex:9;-webkit-flex:9;flex:9;}.flex-10{-ms-flex:10;-webkit-flex:10;flex:10;}.flex-11{-ms-flex:11;-webkit-flex:11;flex:11;}.flex-12{-ms-flex:12;-webkit-flex:12;flex:12;} \ No newline at end of file + ha-script-editor{height:100%;} \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz index 08a7f5002cd0f9..66644926537bed 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz differ diff --git a/homeassistant/components/frontend/www_static/service_worker.js b/homeassistant/components/frontend/www_static/service_worker.js index dc4770853e0b8a..f23669fb07eef2 100644 --- a/homeassistant/components/frontend/www_static/service_worker.js +++ b/homeassistant/components/frontend/www_static/service_worker.js @@ -37,7 +37,7 @@ /* eslint-disable indent, no-unused-vars, no-multiple-empty-lines, max-nested-callbacks, space-before-function-paren, quotes, comma-spacing */ 'use strict'; -var precacheConfig = [["/","eceffe0debe81636e1eb8604e6eefbd6"],["/frontend/panels/dev-event-d409e7ab537d9fe629126d122345279c.html","936814991f2a5e23d61d29f0d40f81b8"],["/frontend/panels/dev-info-b0e55eb657fd75f21aba2426ac0cedc0.html","1fa953b0224470f70d4e87bbe4dff191"],["/frontend/panels/dev-mqtt-94b222b013a98583842de3e72d5888c6.html","dc3ddfac58397feda97317358f0aecbb"],["/frontend/panels/dev-service-422b2c181ee0713fa31d45a64e605baf.html","ae7d26b1c8c3309fd3c65944f89ea03f"],["/frontend/panels/dev-state-7948d3dba058f31517d880df8ed0e857.html","ff8156bb1a52490fcc07466556fce0e1"],["/frontend/panels/dev-template-928e7b81b9c113b70edc9f4a1d051827.html","312c8313800b44c83bcb8dc2df30c759"],["/frontend/panels/map-565db019147162080c21af962afc097f.html","a1a360042395682335e2f471dddad309"],["/static/compatibility-1686167ff210e001f063f5c606b2e74b.js","6ee7b5e2dd82b510c3bd92f7e215988e"],["/static/core-2a7d01e45187c7d4635da05065b5e54e.js","90a0a8a6a6dd0ca41b16f40e7d23924d"],["/static/frontend-c04709d3517dd3fd34b2f7d6bba6ec8e.html","e072f7bbe595bcb104d117a45592459d"],["/static/mdi-89074face5529f5fe6fbae49ecb3e88b.html","97754e463f9e56a95c813d4d8e792347"],["static/fonts/roboto/Roboto-Bold.ttf","d329cc8b34667f114a95422aaad1b063"],["static/fonts/roboto/Roboto-Light.ttf","7b5fb88f12bec8143f00e21bc3222124"],["static/fonts/roboto/Roboto-Medium.ttf","fe13e4170719c2fc586501e777bde143"],["static/fonts/roboto/Roboto-Regular.ttf","ac3f799d5bbaf5196fab15ab8de8431c"],["static/icons/favicon-192x192.png","419903b8422586a7e28021bbe9011175"],["static/icons/favicon.ico","04235bda7843ec2fceb1cbe2bc696cf4"],["static/images/card_media_player_bg.png","a34281d1c1835d338a642e90930e61aa"]]; +var precacheConfig = [["/","e22b4dfa3b4277935d374eb30b36b7a7"],["/frontend/panels/dev-event-d409e7ab537d9fe629126d122345279c.html","936814991f2a5e23d61d29f0d40f81b8"],["/frontend/panels/dev-info-b0e55eb657fd75f21aba2426ac0cedc0.html","1fa953b0224470f70d4e87bbe4dff191"],["/frontend/panels/dev-mqtt-94b222b013a98583842de3e72d5888c6.html","dc3ddfac58397feda97317358f0aecbb"],["/frontend/panels/dev-service-422b2c181ee0713fa31d45a64e605baf.html","ae7d26b1c8c3309fd3c65944f89ea03f"],["/frontend/panels/dev-state-7948d3dba058f31517d880df8ed0e857.html","ff8156bb1a52490fcc07466556fce0e1"],["/frontend/panels/dev-template-928e7b81b9c113b70edc9f4a1d051827.html","312c8313800b44c83bcb8dc2df30c759"],["/frontend/panels/map-565db019147162080c21af962afc097f.html","a1a360042395682335e2f471dddad309"],["/static/compatibility-1686167ff210e001f063f5c606b2e74b.js","6ee7b5e2dd82b510c3bd92f7e215988e"],["/static/core-2a7d01e45187c7d4635da05065b5e54e.js","90a0a8a6a6dd0ca41b16f40e7d23924d"],["/static/frontend-6b0a95408d9ee869d0fe20c374077ed4.html","2fced25e314a02654197adbfe36f1063"],["/static/mdi-89074face5529f5fe6fbae49ecb3e88b.html","97754e463f9e56a95c813d4d8e792347"],["static/fonts/roboto/Roboto-Bold.ttf","d329cc8b34667f114a95422aaad1b063"],["static/fonts/roboto/Roboto-Light.ttf","7b5fb88f12bec8143f00e21bc3222124"],["static/fonts/roboto/Roboto-Medium.ttf","fe13e4170719c2fc586501e777bde143"],["static/fonts/roboto/Roboto-Regular.ttf","ac3f799d5bbaf5196fab15ab8de8431c"],["static/icons/favicon-192x192.png","419903b8422586a7e28021bbe9011175"],["static/icons/favicon.ico","04235bda7843ec2fceb1cbe2bc696cf4"],["static/images/card_media_player_bg.png","a34281d1c1835d338a642e90930e61aa"]]; var cacheName = 'sw-precache-v3--' + (self.registration ? self.registration.scope : ''); diff --git a/homeassistant/components/frontend/www_static/service_worker.js.gz b/homeassistant/components/frontend/www_static/service_worker.js.gz index 14edb98db2b161..2b44cab0a33464 100644 Binary files a/homeassistant/components/frontend/www_static/service_worker.js.gz and b/homeassistant/components/frontend/www_static/service_worker.js.gz differ diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index d8647dea0c3ab9..c444cf1abbfd38 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -6,6 +6,7 @@ """ import asyncio import json +from functools import wraps import logging import ssl from ipaddress import ip_network @@ -364,9 +365,12 @@ def json(self, result, status_code=200): return web.Response( body=msg, content_type=CONTENT_TYPE_JSON, status=status_code) - def json_message(self, error, status_code=200): + def json_message(self, message, status_code=200, message_code=None): """Return a JSON message response.""" - return self.json({'message': error}, status_code) + data = {'message': message} + if message_code is not None: + data['code'] = message_code + return self.json(data, status_code) @asyncio.coroutine # pylint: disable=no-self-use @@ -443,3 +447,41 @@ def handle(request): return web.Response(body=result, status=status_code) return handle + + +class RequestDataValidator: + """Decorator that will validate the incoming data. + + Takes in a voluptuous schema and adds 'post_data' as + keyword argument to the function call. + + Will return a 400 if no JSON provided or doesn't match schema. + """ + + def __init__(self, schema): + """Initialize the decorator.""" + self._schema = schema + + def __call__(self, method): + """Decorate a function.""" + @asyncio.coroutine + @wraps(method) + def wrapper(view, request, *args, **kwargs): + """Wrap a request handler with data validation.""" + try: + data = yield from request.json() + except ValueError: + _LOGGER.error('Invalid JSON received.') + return view.json_message('Invalid JSON.', 400) + + try: + kwargs['data'] = self._schema(data) + except vol.Invalid as err: + _LOGGER.error('Data does not match schema: %s', err) + return view.json_message( + 'Message format incorrect: {}'.format(err), 400) + + result = yield from method(view, request, *args, **kwargs) + return result + + return wrapper diff --git a/homeassistant/components/light/mqtt.py b/homeassistant/components/light/mqtt.py index ac72a7052f11bf..a66cecd3ef8de7 100644 --- a/homeassistant/components/light/mqtt.py +++ b/homeassistant/components/light/mqtt.py @@ -220,7 +220,7 @@ def state_received(topic, payload, qos): self._state = True elif payload == self._payload['off']: self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._topic[CONF_STATE_TOPIC] is not None: yield from mqtt.async_subscribe( @@ -233,7 +233,7 @@ def brightness_received(topic, payload, qos): device_value = float(templates[CONF_BRIGHTNESS](payload)) percent_bright = device_value / self._brightness_scale self._brightness = int(percent_bright * 255) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._topic[CONF_BRIGHTNESS_STATE_TOPIC] is not None: yield from mqtt.async_subscribe( @@ -250,7 +250,7 @@ def rgb_received(topic, payload, qos): """Handle new MQTT messages for RGB.""" self._rgb = [int(val) for val in templates[CONF_RGB](payload).split(',')] - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._topic[CONF_RGB_STATE_TOPIC] is not None: yield from mqtt.async_subscribe( @@ -266,7 +266,7 @@ def rgb_received(topic, payload, qos): def color_temp_received(topic, payload, qos): """Handle new MQTT messages for color temperature.""" self._color_temp = int(templates[CONF_COLOR_TEMP](payload)) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._topic[CONF_COLOR_TEMP_STATE_TOPIC] is not None: yield from mqtt.async_subscribe( @@ -282,7 +282,7 @@ def color_temp_received(topic, payload, qos): def effect_received(topic, payload, qos): """Handle new MQTT messages for effect.""" self._effect = templates[CONF_EFFECT](payload) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._topic[CONF_EFFECT_STATE_TOPIC] is not None: yield from mqtt.async_subscribe( @@ -300,7 +300,7 @@ def white_value_received(topic, payload, qos): device_value = float(templates[CONF_WHITE_VALUE](payload)) percent_white = device_value / self._white_value_scale self._white_value = int(percent_white * 255) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._topic[CONF_WHITE_VALUE_STATE_TOPIC] is not None: yield from mqtt.async_subscribe( @@ -317,7 +317,7 @@ def xy_received(topic, payload, qos): """Handle new MQTT messages for color.""" self._xy = [float(val) for val in templates[CONF_XY](payload).split(',')] - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._topic[CONF_XY_STATE_TOPIC] is not None: yield from mqtt.async_subscribe( @@ -483,7 +483,7 @@ def async_turn_on(self, **kwargs): should_update = True if should_update: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_turn_off(self, **kwargs): @@ -498,4 +498,4 @@ def async_turn_off(self, **kwargs): if self._optimistic: # Optimistically assume that switch has changed state. self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/light/mqtt_json.py b/homeassistant/components/light/mqtt_json.py index 4fee11389096d9..5663e1fc50d273 100755 --- a/homeassistant/components/light/mqtt_json.py +++ b/homeassistant/components/light/mqtt_json.py @@ -226,7 +226,7 @@ def state_received(topic, payload, qos): except ValueError: _LOGGER.warning("Invalid XY color value received") - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._topic[CONF_STATE_TOPIC] is not None: yield from mqtt.async_subscribe( @@ -373,7 +373,7 @@ def async_turn_on(self, **kwargs): should_update = True if should_update: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_turn_off(self, **kwargs): @@ -393,4 +393,4 @@ def async_turn_off(self, **kwargs): if self._optimistic: # Optimistically assume that the light has changed state. self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/light/mqtt_template.py b/homeassistant/components/light/mqtt_template.py index 07fd6d45d8c75a..6dabedbd444a38 100755 --- a/homeassistant/components/light/mqtt_template.py +++ b/homeassistant/components/light/mqtt_template.py @@ -211,7 +211,7 @@ def state_received(topic, payload, qos): else: _LOGGER.warning("Unsupported effect value received") - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._topics[CONF_STATE_TOPIC] is not None: yield from mqtt.async_subscribe( @@ -323,7 +323,7 @@ def async_turn_on(self, **kwargs): ) if self._optimistic: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_turn_off(self, **kwargs): @@ -345,7 +345,7 @@ def async_turn_off(self, **kwargs): ) if self._optimistic: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def supported_features(self): diff --git a/homeassistant/components/light/template.py b/homeassistant/components/light/template.py index 07703d6c067d7f..26ae051795558a 100644 --- a/homeassistant/components/light/template.py +++ b/homeassistant/components/light/template.py @@ -127,6 +127,11 @@ def brightness(self): """Return the brightness of the light.""" return self._brightness + @property + def name(self): + """Return the display name of this light.""" + return self._name + @property def supported_features(self): """Flag supported features.""" @@ -155,7 +160,7 @@ def async_added_to_hass(self): @callback def template_light_state_listener(entity, old_state, new_state): """Handle target device state changes.""" - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) @callback def template_light_startup(event): @@ -165,7 +170,7 @@ def template_light_startup(event): async_track_state_change( self.hass, self._entities, template_light_state_listener) - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_START, template_light_startup) @@ -192,7 +197,7 @@ def async_turn_on(self, **kwargs): self.hass.async_add_job(self._on_script.async_run()) if optimistic_set: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_turn_off(self, **kwargs): @@ -200,7 +205,7 @@ def async_turn_off(self, **kwargs): self.hass.async_add_job(self._off_script.async_run()) if self._template is None: self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_update(self): diff --git a/homeassistant/components/light/tradfri.py b/homeassistant/components/light/tradfri.py index fa21af996cb3ce..0f56982dae511b 100644 --- a/homeassistant/components/light/tradfri.py +++ b/homeassistant/components/light/tradfri.py @@ -179,8 +179,8 @@ def turn_on(self, **kwargs): self._api(self._light_control.set_state(True)) if ATTR_RGB_COLOR in kwargs and self._light_data.hex_color is not None: - self._api(self._light.light_control.set_hex_color( - color_util.color_rgb_to_hex(*kwargs[ATTR_RGB_COLOR]))) + self._api(self._light.light_control.set_rgb_color( + *kwargs[ATTR_RGB_COLOR])) elif ATTR_COLOR_TEMP in kwargs and \ self._light_data.hex_color is not None and self._ok_temps: diff --git a/homeassistant/components/light/xiaomi_philipslight.py b/homeassistant/components/light/xiaomi_philipslight.py index 8df25153a733e2..a6cd77028cb65b 100644 --- a/homeassistant/components/light/xiaomi_philipslight.py +++ b/homeassistant/components/light/xiaomi_philipslight.py @@ -28,7 +28,7 @@ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) -REQUIREMENTS = ['python-mirobo==0.1.3'] +REQUIREMENTS = ['python-mirobo==0.2.0'] # The light does not accept cct values < 1 CCT_MIN = 1 @@ -163,7 +163,7 @@ def async_turn_on(self, **kwargs): result = yield from self._try_command( "Setting brightness failed: %s", - self._light.set_bright, percent_brightness) + self._light.set_brightness, percent_brightness) if result: self._brightness = brightness @@ -181,7 +181,7 @@ def async_turn_on(self, **kwargs): result = yield from self._try_command( "Setting color temperature failed: %s cct", - self._light.set_cct, percent_color_temp) + self._light.set_color_temperature, percent_color_temp) if result: self._color_temp = color_temp @@ -207,13 +207,13 @@ def async_update(self): from mirobo import DeviceException try: state = yield from self.hass.async_add_job(self._light.status) - _LOGGER.debug("Got new state: %s", state.data) + _LOGGER.debug("Got new state: %s", state) self._state = state.is_on - self._brightness = int(255 * 0.01 * state.bright) - self._color_temp = self.translate(state.cct, CCT_MIN, CCT_MAX, - self.max_mireds, - self.min_mireds) + self._brightness = int(255 * 0.01 * state.brightness) + self._color_temp = self.translate(state.color_temperature, + CCT_MIN, CCT_MAX, + self.max_mireds, self.min_mireds) except DeviceException as ex: _LOGGER.error("Got exception while fetching the state: %s", ex) diff --git a/homeassistant/components/light/zha.py b/homeassistant/components/light/zha.py index e7ba394a977d1a..a18fdc9dec6a5c 100644 --- a/homeassistant/components/light/zha.py +++ b/homeassistant/components/light/zha.py @@ -105,19 +105,19 @@ def async_turn_on(self, **kwargs): duration ) self._state = 1 - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() return yield from self._endpoint.on_off.on() self._state = 1 - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_turn_off(self, **kwargs): """Turn the entity off.""" yield from self._endpoint.on_off.off() self._state = 0 - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def brightness(self): diff --git a/homeassistant/components/lock/mqtt.py b/homeassistant/components/lock/mqtt.py index de14d21a09b71a..b2533145a20004 100644 --- a/homeassistant/components/lock/mqtt.py +++ b/homeassistant/components/lock/mqtt.py @@ -93,7 +93,7 @@ def message_received(topic, payload, qos): elif payload == self._payload_unlock: self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._state_topic is None: # Force into optimistic mode. @@ -134,7 +134,7 @@ def async_lock(self, **kwargs): if self._optimistic: # Optimistically assume that switch has changed state. self._state = True - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_unlock(self, **kwargs): @@ -148,4 +148,4 @@ def async_unlock(self, **kwargs): if self._optimistic: # Optimistically assume that switch has changed state. self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/mailbox/__init__.py b/homeassistant/components/mailbox/__init__.py index 9d73101603579b..21b2dc7279fc41 100644 --- a/homeassistant/components/mailbox/__init__.py +++ b/homeassistant/components/mailbox/__init__.py @@ -111,7 +111,7 @@ def __init__(self, hass, mailbox): @callback def _mailbox_updated(event): - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) hass.bus.async_listen(EVENT, _mailbox_updated) diff --git a/homeassistant/components/media_player/apple_tv.py b/homeassistant/components/media_player/apple_tv.py index 3ecb1c0922ef6d..6bd962ef443550 100644 --- a/homeassistant/components/media_player/apple_tv.py +++ b/homeassistant/components/media_player/apple_tv.py @@ -93,7 +93,7 @@ def state(self): if not self._power.turned_on: return STATE_OFF - if self._playing is not None: + if self._playing: from pyatv import const state = self._playing.play_state if state == const.PLAY_STATE_NO_MEDIA or \ @@ -112,7 +112,7 @@ def state(self): def playstatus_update(self, updater, playing): """Print what is currently playing when it changes.""" self._playing = playing - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @callback def playstatus_error(self, updater, exception): @@ -126,12 +126,12 @@ def playstatus_error(self, updater, exception): # implemented here later. updater.start(initial_delay=10) self._playing = None - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def media_content_type(self): """Content type of current playing media.""" - if self._playing is not None: + if self._playing: from pyatv import const media_type = self._playing.media_type if media_type == const.MEDIA_TYPE_VIDEO: @@ -144,13 +144,13 @@ def media_content_type(self): @property def media_duration(self): """Duration of current playing media in seconds.""" - if self._playing is not None: + if self._playing: return self._playing.total_time @property def media_position(self): """Position of current playing media in seconds.""" - if self._playing is not None: + if self._playing: return self._playing.position @property @@ -168,18 +168,23 @@ def async_play_media(self, media_type, media_id, **kwargs): @property def media_image_hash(self): """Hash value for media image.""" - if self._playing is not None and self.state != STATE_IDLE: + state = self.state + if self._playing and state not in [STATE_OFF, STATE_IDLE]: return self._playing.hash @asyncio.coroutine def async_get_media_image(self): """Fetch media image of current playing image.""" - return (yield from self.atv.metadata.artwork()), 'image/png' + state = self.state + if self._playing and state not in [STATE_OFF, STATE_IDLE]: + return (yield from self.atv.metadata.artwork()), 'image/png' + + return None, None @property def media_title(self): """Title of current playing media.""" - if self._playing is not None: + if self._playing: if self.state == STATE_IDLE: return 'Nothing playing' title = self._playing.title @@ -215,7 +220,7 @@ def async_media_play_pause(self): This method must be run in the event loop and returns a coroutine. """ - if self._playing is not None: + if self._playing: state = self.state if state == STATE_PAUSED: return self.atv.remote_control.play() @@ -227,7 +232,7 @@ def async_media_play(self): This method must be run in the event loop and returns a coroutine. """ - if self._playing is not None: + if self._playing: return self.atv.remote_control.play() def async_media_stop(self): @@ -235,7 +240,7 @@ def async_media_stop(self): This method must be run in the event loop and returns a coroutine. """ - if self._playing is not None: + if self._playing: return self.atv.remote_control.stop() def async_media_pause(self): @@ -243,7 +248,7 @@ def async_media_pause(self): This method must be run in the event loop and returns a coroutine. """ - if self._playing is not None: + if self._playing: return self.atv.remote_control.pause() def async_media_next_track(self): @@ -251,7 +256,7 @@ def async_media_next_track(self): This method must be run in the event loop and returns a coroutine. """ - if self._playing is not None: + if self._playing: return self.atv.remote_control.next() def async_media_previous_track(self): @@ -259,7 +264,7 @@ def async_media_previous_track(self): This method must be run in the event loop and returns a coroutine. """ - if self._playing is not None: + if self._playing: return self.atv.remote_control.previous() def async_media_seek(self, position): @@ -267,5 +272,5 @@ def async_media_seek(self, position): This method must be run in the event loop and returns a coroutine. """ - if self._playing is not None: + if self._playing: return self.atv.remote_control.set_position(position) diff --git a/homeassistant/components/media_player/emby.py b/homeassistant/components/media_player/emby.py index 8df6bc4fd1b92d..ebb8a670488fd4 100644 --- a/homeassistant/components/media_player/emby.py +++ b/homeassistant/components/media_player/emby.py @@ -159,7 +159,7 @@ def async_update_callback(self, msg): self.media_status_last_position = None self.media_status_received = None - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def hidden(self): diff --git a/homeassistant/components/media_player/kodi.py b/homeassistant/components/media_player/kodi.py index a51238e9aaf639..00dd90938c81e0 100644 --- a/homeassistant/components/media_player/kodi.py +++ b/homeassistant/components/media_player/kodi.py @@ -325,7 +325,7 @@ def async_on_speed_event(self, sender, data): # If a new item is playing, force a complete refresh force_refresh = data['item']['id'] != self._item.get('id') - self.hass.async_add_job(self.async_update_ha_state(force_refresh)) + self.async_schedule_update_ha_state(force_refresh) @callback def async_on_stop(self, sender, data): @@ -337,14 +337,14 @@ def async_on_stop(self, sender, data): self._players = [] self._properties = {} self._item = {} - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @callback def async_on_volume_changed(self, sender, data): """Handle the volume changes.""" self._app_properties['volume'] = data['volume'] self._app_properties['muted'] = data['muted'] - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @callback def async_on_quit(self, sender, data): @@ -403,7 +403,7 @@ def ws_loop_wrapper(): # to reconnect on the next poll. pass # Update HA state after Kodi disconnects - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() # Create a task instead of adding a tracking job, since this task will # run until the websocket connection is closed. diff --git a/homeassistant/components/media_player/mpd.py b/homeassistant/components/media_player/mpd.py index 55df1e367a47b4..44dd9a7ea299f1 100644 --- a/homeassistant/components/media_player/mpd.py +++ b/homeassistant/components/media_player/mpd.py @@ -11,12 +11,13 @@ from homeassistant.components.media_player import ( MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, PLATFORM_SCHEMA, - SUPPORT_PREVIOUS_TRACK, SUPPORT_STOP, - SUPPORT_VOLUME_SET, SUPPORT_PLAY_MEDIA, SUPPORT_PLAY, MEDIA_TYPE_PLAYLIST, + SUPPORT_PREVIOUS_TRACK, SUPPORT_STOP, SUPPORT_PLAY, + SUPPORT_VOLUME_SET, SUPPORT_PLAY_MEDIA, MEDIA_TYPE_PLAYLIST, SUPPORT_SELECT_SOURCE, SUPPORT_CLEAR_PLAYLIST, SUPPORT_SHUFFLE_SET, - SUPPORT_SEEK, MediaPlayerDevice) + SUPPORT_SEEK, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, + SUPPORT_TURN_OFF, SUPPORT_TURN_ON, MediaPlayerDevice) from homeassistant.const import ( - STATE_OFF, STATE_ON, STATE_PAUSED, STATE_PLAYING, + STATE_OFF, STATE_PAUSED, STATE_PLAYING, CONF_PORT, CONF_PASSWORD, CONF_HOST, CONF_NAME) import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle @@ -30,11 +31,11 @@ PLAYLIST_UPDATE_INTERVAL = timedelta(seconds=120) -SUPPORT_MPD = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | \ - SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \ +SUPPORT_MPD = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_STEP | \ + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_VOLUME_MUTE | \ SUPPORT_PLAY_MEDIA | SUPPORT_PLAY | SUPPORT_SELECT_SOURCE | \ SUPPORT_CLEAR_PLAYLIST | SUPPORT_SHUFFLE_SET | SUPPORT_SEEK | \ - SUPPORT_STOP + SUPPORT_STOP | SUPPORT_TURN_OFF | SUPPORT_TURN_ON PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, @@ -74,6 +75,8 @@ def __init__(self, server, port, password, name): self._playlists = [] self._currentplaylist = None self._is_connected = False + self._muted = False + self._muted_volume = 0 # set up MPD client self._client = mpd.MPDClient() @@ -142,8 +145,15 @@ def state(self): return STATE_PLAYING elif self._status['state'] == 'pause': return STATE_PAUSED + elif self._status['state'] == 'stop': + return STATE_OFF + + return STATE_OFF - return STATE_ON + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return self._muted @property def media_content_id(self): @@ -255,6 +265,15 @@ def media_previous_track(self): """Service to send the MPD the command for previous track.""" self._client.previous() + def mute_volume(self, mute): + """Mute. Emulated with set_volume_level.""" + if mute is True: + self._muted_volume = self.volume_level + self.set_volume_level(0) + elif mute is False: + self.set_volume_level(self._muted_volume) + self._muted = mute + def play_media(self, media_type, media_id, **kwargs): """Send the media player the command for playing a playlist.""" _LOGGER.debug(str.format("Playing playlist: {0}", media_id)) @@ -282,6 +301,15 @@ def set_shuffle(self, shuffle): """Enable/disable shuffle mode.""" self._client.random(int(shuffle)) + def turn_off(self): + """Service to send the MPD the command to stop playing.""" + self._client.stop() + + def turn_on(self): + """Service to send the MPD the command to start playing.""" + self._client.play() + self._update_playlists(no_throttle=True) + def clear_playlist(self): """Clear players playlist.""" self._client.clear() diff --git a/homeassistant/components/media_player/snapcast.py b/homeassistant/components/media_player/snapcast.py index 1715f0f18299bd..3f1607831e5c20 100644 --- a/homeassistant/components/media_player/snapcast.py +++ b/homeassistant/components/media_player/snapcast.py @@ -159,19 +159,19 @@ def async_select_source(self, source): streams = self._group.streams_by_name() if source in streams: yield from self._group.set_stream(streams[source].identifier) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_mute_volume(self, mute): """Send the mute command.""" yield from self._group.set_muted(mute) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_set_volume_level(self, volume): """Set the volume level.""" yield from self._group.set_volume(round(volume * 100)) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() def snapshot(self): """Snapshot the group state.""" @@ -235,13 +235,13 @@ def should_poll(self): def async_mute_volume(self, mute): """Send the mute command.""" yield from self._client.set_muted(mute) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_set_volume_level(self, volume): """Set the volume level.""" yield from self._client.set_volume(round(volume * 100)) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() def snapshot(self): """Snapshot the client state.""" diff --git a/homeassistant/components/media_player/universal.py b/homeassistant/components/media_player/universal.py index daf874a31ddc88..e25f9d182524c6 100644 --- a/homeassistant/components/media_player/universal.py +++ b/homeassistant/components/media_player/universal.py @@ -148,7 +148,7 @@ def __init__(self, hass, name, children, commands, attributes): @callback def async_on_dependency_update(*_): """Update ha state when dependencies update.""" - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) depend = copy(children) for entity in attributes.values(): diff --git a/homeassistant/components/mqtt_statestream.py b/homeassistant/components/mqtt_statestream.py new file mode 100644 index 00000000000000..76154e4ab587dd --- /dev/null +++ b/homeassistant/components/mqtt_statestream.py @@ -0,0 +1,45 @@ +""" +Publish simple item state changes via MQTT. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/mqtt_statestream/ +""" +import asyncio + +import voluptuous as vol + +from homeassistant.const import MATCH_ALL +from homeassistant.core import callback +from homeassistant.components.mqtt import valid_publish_topic +from homeassistant.helpers.event import async_track_state_change + +CONF_BASE_TOPIC = 'base_topic' +DEPENDENCIES = ['mqtt'] +DOMAIN = 'mqtt_statestream' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_BASE_TOPIC): valid_publish_topic + }) +}, extra=vol.ALLOW_EXTRA) + + +@asyncio.coroutine +def async_setup(hass, config): + """Set up the MQTT state feed.""" + conf = config.get(DOMAIN, {}) + base_topic = conf.get(CONF_BASE_TOPIC) + if not base_topic.endswith('/'): + base_topic = base_topic + '/' + + @callback + def _state_publisher(entity_id, old_state, new_state): + if new_state is None: + return + payload = new_state.state + + topic = base_topic + entity_id.replace('.', '/') + hass.components.mqtt.async_publish(topic, payload, 1, True) + + async_track_state_change(hass, MATCH_ALL, _state_publisher) + return True diff --git a/homeassistant/components/mysensors.py b/homeassistant/components/mysensors.py index c37116fb32dcf3..71be416c59c88c 100644 --- a/homeassistant/components/mysensors.py +++ b/homeassistant/components/mysensors.py @@ -637,7 +637,7 @@ def available(self): def _async_update_callback(self): """Update the entity.""" - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) @asyncio.coroutine def async_added_to_hass(self): diff --git a/homeassistant/components/notify/html5.py b/homeassistant/components/notify/html5.py index 7151b41824845e..6b1cdf814fad85 100644 --- a/homeassistant/components/notify/html5.py +++ b/homeassistant/components/notify/html5.py @@ -25,7 +25,7 @@ from homeassistant.components.frontend import add_manifest_json_key from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['pywebpush==1.0.6', 'PyJWT==1.5.2'] +REQUIREMENTS = ['pywebpush==1.1.0', 'PyJWT==1.5.3'] DEPENDENCIES = ['frontend'] diff --git a/homeassistant/components/notify/twitter.py b/homeassistant/components/notify/twitter.py index 25e6fc00a2f7ef..d4e969e95ec184 100644 --- a/homeassistant/components/notify/twitter.py +++ b/homeassistant/components/notify/twitter.py @@ -116,6 +116,9 @@ def upload_media_then_callback(self, callback, media_path=None): self.log_error_resp(resp) return None + if resp.json().get('processing_info') is None: + return callback(media_id) + self.check_status_until_done(media_id, callback) def media_info(self, media_path): diff --git a/homeassistant/components/plant.py b/homeassistant/components/plant.py index 9b9e11e0fbb0bb..3a6876e3e12dde 100644 --- a/homeassistant/components/plant.py +++ b/homeassistant/components/plant.py @@ -212,7 +212,7 @@ def _update_state(self): self._icon = 'mdi:thumb-up' self._problems = PROBLEM_NONE _LOGGER.debug("New data processed") - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def should_poll(self): diff --git a/homeassistant/components/python_script.py b/homeassistant/components/python_script.py index 386abba59aed21..f80dea839443da 100644 --- a/homeassistant/components/python_script.py +++ b/homeassistant/components/python_script.py @@ -2,6 +2,7 @@ import glob import os import logging +import datetime import voluptuous as vol @@ -63,7 +64,8 @@ def execute_script(hass, name, data=None): def execute(hass, filename, source, data=None): """Execute Python source.""" from RestrictedPython import compile_restricted_exec - from RestrictedPython.Guards import safe_builtins, full_write_guard + from RestrictedPython.Guards import safe_builtins, full_write_guard, \ + guarded_iter_unpack_sequence, guarded_unpack_sequence from RestrictedPython.Utilities import utility_builtins from RestrictedPython.Eval import default_guarded_getitem @@ -94,13 +96,16 @@ def protected_getattr(obj, name, default=None): builtins = safe_builtins.copy() builtins.update(utility_builtins) + builtins['datetime'] = datetime restricted_globals = { '__builtins__': builtins, '_print_': StubPrinter, '_getattr_': protected_getattr, '_write_': full_write_guard, '_getiter_': iter, - '_getitem_': default_guarded_getitem + '_getitem_': default_guarded_getitem, + '_iter_unpack_sequence_': guarded_iter_unpack_sequence, + '_unpack_sequence_': guarded_unpack_sequence, } logger = logging.getLogger('{}.{}'.format(__name__, filename)) local = { diff --git a/homeassistant/components/rflink.py b/homeassistant/components/rflink.py index fe3e954c571fc3..74e533d70ec2cb 100644 --- a/homeassistant/components/rflink.py +++ b/homeassistant/components/rflink.py @@ -272,7 +272,7 @@ def handle_event(self, event): self._handle_event(event) # Propagate changes through ha - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() # Put command onto bus for user to subscribe to if self._should_fire_event and identify_event_type( diff --git a/homeassistant/components/sensor/alarmdecoder.py b/homeassistant/components/sensor/alarmdecoder.py index dba1697f026ec0..6b026298db0833 100644 --- a/homeassistant/components/sensor/alarmdecoder.py +++ b/homeassistant/components/sensor/alarmdecoder.py @@ -50,7 +50,7 @@ def async_added_to_hass(self): def _message_callback(self, message): if self._display != message.text: self._display = message.text - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def icon(self): diff --git a/homeassistant/components/sensor/arwn.py b/homeassistant/components/sensor/arwn.py index 4aa8e20cb75333..6fd098746512b6 100644 --- a/homeassistant/components/sensor/arwn.py +++ b/homeassistant/components/sensor/arwn.py @@ -123,7 +123,7 @@ def set_event(self, event): """Update the sensor with the most recent event.""" self.event = {} self.event.update(event) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def state(self): diff --git a/homeassistant/components/sensor/envisalink.py b/homeassistant/components/sensor/envisalink.py index 7f1ee5c0d414ff..24cb224570c06e 100644 --- a/homeassistant/components/sensor/envisalink.py +++ b/homeassistant/components/sensor/envisalink.py @@ -77,4 +77,4 @@ def device_state_attributes(self): def _update_callback(self, partition): """Update the partition state in HA, if needed.""" if partition is None or int(partition) == self._partition_number: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/sensor/mopar.py b/homeassistant/components/sensor/mopar.py index 0184cb2afdf4e4..66eea20ec70559 100644 --- a/homeassistant/components/sensor/mopar.py +++ b/homeassistant/components/sensor/mopar.py @@ -18,7 +18,7 @@ import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['motorparts==1.0.0'] +REQUIREMENTS = ['motorparts==1.0.2'] _LOGGER = logging.getLogger(__name__) @@ -86,6 +86,7 @@ def __init__(self, session): self.vehicles = [] self.vhrs = {} self.tow_guides = {} + self.update() @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self, **kwargs): diff --git a/homeassistant/components/sensor/mqtt.py b/homeassistant/components/sensor/mqtt.py index 63b015b3dfd9cb..70b1294c13f4a6 100644 --- a/homeassistant/components/sensor/mqtt.py +++ b/homeassistant/components/sensor/mqtt.py @@ -100,7 +100,7 @@ def message_received(topic, payload, qos): payload = self._template.async_render_with_possible_json_value( payload, self._state) self._state = payload - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() return mqtt.async_subscribe( self.hass, self._state_topic, message_received, self._qos) @@ -110,7 +110,7 @@ def value_is_expired(self, *_): """Triggered when value is expired.""" self._expiration_trigger = None self._state = STATE_UNKNOWN - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def should_poll(self): diff --git a/homeassistant/components/sensor/mqtt_room.py b/homeassistant/components/sensor/mqtt_room.py index 3d0dbd68afadbd..e14922a1579268 100644 --- a/homeassistant/components/sensor/mqtt_room.py +++ b/homeassistant/components/sensor/mqtt_room.py @@ -96,7 +96,7 @@ def update_state(device_id, room, distance): self._distance = distance self._updated = dt.utcnow() - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @callback def message_received(topic, payload, qos): diff --git a/homeassistant/components/sensor/onewire.py b/homeassistant/components/sensor/onewire.py index 5cbbe6ed0aacb5..b36e7bdf267de4 100644 --- a/homeassistant/components/sensor/onewire.py +++ b/homeassistant/components/sensor/onewire.py @@ -1,5 +1,5 @@ """ -Support for 1-Wire temperature sensors. +Support for 1-Wire environment sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.onewire/ @@ -22,7 +22,22 @@ CONF_NAMES = 'names' DEFAULT_MOUNT_DIR = '/sys/bus/w1/devices/' -DEVICE_FAMILIES = ('10', '22', '28', '3B', '42') +DEVICE_SENSORS = {'10': {'temperature': 'temperature'}, + '12': {'temperature': 'TAI8570/temperature', + 'pressure': 'TAI8570/pressure'}, + '22': {'temperature': 'temperature'}, + '26': {'temperature': 'temperature', + 'humidity': 'humidity', + 'pressure': 'B1-R1-A/pressure'}, + '28': {'temperature': 'temperature'}, + '3B': {'temperature': 'temperature'}, + '42': {'temperature': 'temperature'}} + +SENSOR_TYPES = { + 'temperature': ['temperature', TEMP_CELSIUS], + 'humidity': ['humidity', '%'], + 'pressure': ['pressure', 'mb'], +} PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAMES): {cv.string: cv.string}, @@ -34,63 +49,54 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the one wire Sensors.""" base_dir = config.get(CONF_MOUNT_DIR) - sensor_ids = [] - device_files = [] + devs = [] + device_names = {} + if 'names' in config: + if isinstance(config['names'], dict): + device_names = config['names'] + if base_dir == DEFAULT_MOUNT_DIR: - for device_family in DEVICE_FAMILIES: + for device_family in DEVICE_SENSORS: for device_folder in glob(os.path.join(base_dir, device_family + '[.-]*')): - sensor_ids.append(os.path.split(device_folder)[1]) - device_files.append(os.path.join(device_folder, 'w1_slave')) + sensor_id = os.path.split(device_folder)[1] + device_file = os.path.join(device_folder, 'w1_slave') + devs.append(OneWire(device_names.get(sensor_id, sensor_id), + device_file, 'temperature')) else: for family_file_path in glob(os.path.join(base_dir, '*', 'family')): family_file = open(family_file_path, "r") family = family_file.read() - if family in DEVICE_FAMILIES: - sensor_id = os.path.split( - os.path.split(family_file_path)[0])[1] - sensor_ids.append(sensor_id) - device_files.append(os.path.join( - os.path.split(family_file_path)[0], 'temperature')) - - if device_files == []: + if family in DEVICE_SENSORS: + for sensor_key, sensor_value in DEVICE_SENSORS[family].items(): + sensor_id = os.path.split( + os.path.split(family_file_path)[0])[1] + device_file = os.path.join( + os.path.split(family_file_path)[0], sensor_value) + devs.append(OneWire(device_names.get(sensor_id, sensor_id), + device_file, sensor_key)) + + if devs == []: _LOGGER.error("No onewire sensor found. Check if dtoverlay=w1-gpio " "is in your /boot/config.txt. " "Check the mount_dir parameter if it's defined") return - devs = [] - names = sensor_ids - - for key in config.keys(): - if key == 'names': - # Only one name given - if isinstance(config['names'], str): - names = [config['names']] - # Map names and sensors in given order - elif isinstance(config['names'], list): - names = config['names'] - # Map names to ids. - elif isinstance(config['names'], dict): - names = [] - for sensor_id in sensor_ids: - names.append(config['names'].get(sensor_id, sensor_id)) - for device_file, name in zip(device_files, names): - devs.append(OneWire(name, device_file)) add_devices(devs, True) class OneWire(Entity): """Implementation of an One wire Sensor.""" - def __init__(self, name, device_file): + def __init__(self, name, device_file, sensor_type): """Initialize the sensor.""" - self._name = name + self._name = name+' '+sensor_type.capitalize() self._device_file = device_file + self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] self._state = None - def _read_temp_raw(self): - """Read the temperature as it is returned by the sensor.""" + def _read_value_raw(self): + """Read the value as it is returned by the sensor.""" ds_device_file = open(self._device_file, 'r') lines = ds_device_file.readlines() ds_device_file.close() @@ -109,34 +115,32 @@ def state(self): @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" - return TEMP_CELSIUS + return self._unit_of_measurement def update(self): """Get the latest data from the device.""" - temp = -99 + value = None if self._device_file.startswith(DEFAULT_MOUNT_DIR): - lines = self._read_temp_raw() + lines = self._read_value_raw() while lines[0].strip()[-3:] != 'YES': time.sleep(0.2) - lines = self._read_temp_raw() + lines = self._read_value_raw() equals_pos = lines[1].find('t=') if equals_pos != -1: - temp_string = lines[1][equals_pos+2:] - temp = round(float(temp_string) / 1000.0, 1) + value_string = lines[1][equals_pos+2:] + value = round(float(value_string) / 1000.0, 1) else: try: ds_device_file = open(self._device_file, 'r') - temp_read = ds_device_file.readlines() + value_read = ds_device_file.readlines() ds_device_file.close() - if len(temp_read) == 1: - temp = round(float(temp_read[0]), 1) + if len(value_read) == 1: + value = round(float(value_read[0]), 1) except ValueError: - _LOGGER.warning("Invalid temperature value read from %s", + _LOGGER.warning("Invalid value read from %s", self._device_file) except FileNotFoundError: _LOGGER.warning( "Cannot read from sensor: %s", self._device_file) - if temp < -55 or temp > 125: - return - self._state = temp + self._state = value diff --git a/homeassistant/components/sensor/otp.py b/homeassistant/components/sensor/otp.py index 5d7808ea4c7270..6ceed11a6b943d 100644 --- a/homeassistant/components/sensor/otp.py +++ b/homeassistant/components/sensor/otp.py @@ -62,7 +62,7 @@ def async_added_to_hass(self): @callback def _call_loop(self): self._state = self._otp.now() - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() # Update must occur at even TIME_STEP, e.g. 12:00:00, 12:00:30, # 12:01:00, etc. in order to have synced time (see RFC6238) diff --git a/homeassistant/components/sensor/systemmonitor.py b/homeassistant/components/sensor/systemmonitor.py index 69a82fb0facede..1a8d67de93e4d0 100755 --- a/homeassistant/components/sensor/systemmonitor.py +++ b/homeassistant/components/sensor/systemmonitor.py @@ -16,7 +16,7 @@ import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -REQUIREMENTS = ['psutil==5.3.0'] +REQUIREMENTS = ['psutil==5.3.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/template.py b/homeassistant/components/sensor/template.py index fdd0ef9c2ad686..e59864dea2be90 100644 --- a/homeassistant/components/sensor/template.py +++ b/homeassistant/components/sensor/template.py @@ -100,7 +100,7 @@ def async_added_to_hass(self): @callback def template_sensor_state_listener(entity, old_state, new_state): """Handle device state changes.""" - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) @callback def template_sensor_startup(event): @@ -108,7 +108,7 @@ def template_sensor_startup(event): async_track_state_change( self.hass, self._entities, template_sensor_state_listener) - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_START, template_sensor_startup) diff --git a/homeassistant/components/sensor/time_date.py b/homeassistant/components/sensor/time_date.py index a59ee01bac2d1d..69723aea19a7ca 100644 --- a/homeassistant/components/sensor/time_date.py +++ b/homeassistant/components/sensor/time_date.py @@ -129,6 +129,6 @@ def _update_internal_state(self, time_date): def point_in_time_listener(self, time_date): """Get the latest data and update state.""" self._update_internal_state(time_date) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() async_track_point_in_utc_time( self.hass, self.point_in_time_listener, self.get_next_interval()) diff --git a/homeassistant/components/sensor/torque.py b/homeassistant/components/sensor/torque.py index 3ce277f794bbb9..98fad475d52a2e 100644 --- a/homeassistant/components/sensor/torque.py +++ b/homeassistant/components/sensor/torque.py @@ -141,4 +141,4 @@ def icon(self): def async_on_update(self, value): """Receive an update.""" self._state = value - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/sensor/wunderground.py b/homeassistant/components/sensor/wunderground.py index 3a72432610ce78..8f9a5ef1862d6f 100644 --- a/homeassistant/components/sensor/wunderground.py +++ b/homeassistant/components/sensor/wunderground.py @@ -708,7 +708,7 @@ def icon(self): def entity_picture(self): """Return the entity picture.""" url = self._cfg_expand("entity_picture") - if url is not None: + if isinstance(url, str): return re.sub(r'^http://', 'https://', url, flags=re.IGNORECASE) @property diff --git a/homeassistant/components/sun.py b/homeassistant/components/sun.py index 9d3d82bd8fc5f3..90c7f69e64ad90 100644 --- a/homeassistant/components/sun.py +++ b/homeassistant/components/sun.py @@ -126,7 +126,7 @@ def point_in_time_listener(self, now): """Run when the state of the sun has changed.""" self.update_sun_position(now) self.update_as_of(now) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() # Schedule next update at next_change+1 second so sun state has changed async_track_point_in_utc_time( @@ -137,4 +137,4 @@ def point_in_time_listener(self, now): def timer_update(self, time): """Needed to update solar elevation and azimuth.""" self.update_sun_position(time) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/switch/android_ip_webcam.py b/homeassistant/components/switch/android_ip_webcam.py index 8c8f04b6161bfe..df86b3fbb8ff6e 100644 --- a/homeassistant/components/switch/android_ip_webcam.py +++ b/homeassistant/components/switch/android_ip_webcam.py @@ -72,7 +72,7 @@ def async_turn_on(self, **kwargs): else: yield from self._ipcam.change_setting(self._setting, True) self._state = True - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_turn_off(self, **kwargs): @@ -86,7 +86,7 @@ def async_turn_off(self, **kwargs): else: yield from self._ipcam.change_setting(self._setting, False) self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def icon(self): diff --git a/homeassistant/components/switch/broadlink.py b/homeassistant/components/switch/broadlink.py index 6ea738d82bc190..c12d13860e2275 100644 --- a/homeassistant/components/switch/broadlink.py +++ b/homeassistant/components/switch/broadlink.py @@ -14,9 +14,11 @@ import voluptuous as vol from homeassistant.util.dt import utcnow +from homeassistant.util import Throttle from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) from homeassistant.const import ( - CONF_FRIENDLY_NAME, CONF_SWITCHES, CONF_COMMAND_OFF, CONF_COMMAND_ON, + CONF_FRIENDLY_NAME, CONF_SWITCHES, + CONF_COMMAND_OFF, CONF_COMMAND_ON, CONF_TIMEOUT, CONF_HOST, CONF_MAC, CONF_TYPE) import homeassistant.helpers.config_validation as cv @@ -24,20 +26,24 @@ _LOGGER = logging.getLogger(__name__) +TIME_BETWEEN_UPDATES = timedelta(seconds=5) + DOMAIN = 'broadlink' DEFAULT_NAME = 'Broadlink switch' DEFAULT_TIMEOUT = 10 DEFAULT_RETRY = 3 SERVICE_LEARN = 'learn_command' SERVICE_SEND = 'send_packet' +CONF_SLOTS = 'slots' RM_TYPES = ['rm', 'rm2', 'rm_mini', 'rm_pro_phicomm', 'rm2_home_plus', 'rm2_home_plus_gdt', 'rm2_pro_plus', 'rm2_pro_plus2', 'rm2_pro_plus_bl', 'rm_mini_shate'] SP1_TYPES = ['sp1'] SP2_TYPES = ['sp2', 'honeywell_sp2', 'sp3', 'spmini2', 'spminiplus'] +MP1_TYPES = ["mp1"] -SWITCH_TYPES = RM_TYPES + SP1_TYPES + SP2_TYPES +SWITCH_TYPES = RM_TYPES + SP1_TYPES + SP2_TYPES + MP1_TYPES SWITCH_SCHEMA = vol.Schema({ vol.Optional(CONF_COMMAND_OFF, default=None): cv.string, @@ -45,9 +51,17 @@ vol.Optional(CONF_FRIENDLY_NAME): cv.string, }) +MP1_SWITCH_SLOT_SCHEMA = vol.Schema({ + vol.Optional('slot_1'): cv.string, + vol.Optional('slot_2'): cv.string, + vol.Optional('slot_3'): cv.string, + vol.Optional('slot_4'): cv.string +}) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_SWITCHES, default={}): vol.Schema({cv.slug: SWITCH_SCHEMA}), + vol.Optional(CONF_SLOTS, default={}): MP1_SWITCH_SLOT_SCHEMA, vol.Required(CONF_HOST): cv.string, vol.Required(CONF_MAC): cv.string, vol.Optional(CONF_FRIENDLY_NAME, default=DEFAULT_NAME): cv.string, @@ -59,7 +73,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up Broadlink switches.""" import broadlink - devices = config.get(CONF_SWITCHES, {}) + devices = config.get(CONF_SWITCHES) + slots = config.get('slots', {}) ip_addr = config.get(CONF_HOST) friendly_name = config.get(CONF_FRIENDLY_NAME) mac_addr = binascii.unhexlify( @@ -114,6 +129,11 @@ def _send_packet(call): if retry == DEFAULT_RETRY-1: _LOGGER.error("Failed to send packet to device") + def _get_mp1_slot_name(switch_friendly_name, slot): + if not slots['slot_{}'.format(slot)]: + return '{} slot {}'.format(switch_friendly_name, slot) + return slots['slot_{}'.format(slot)] + if switch_type in RM_TYPES: broadlink_device = broadlink.rm((ip_addr, 80), mac_addr) hass.services.register(DOMAIN, SERVICE_LEARN + '_' + @@ -136,6 +156,15 @@ def _send_packet(call): elif switch_type in SP2_TYPES: broadlink_device = broadlink.sp2((ip_addr, 80), mac_addr) switches = [BroadlinkSP2Switch(friendly_name, broadlink_device)] + elif switch_type in MP1_TYPES: + switches = [] + broadlink_device = broadlink.mp1((ip_addr, 80), mac_addr) + parent_device = BroadlinkMP1Switch(broadlink_device) + for i in range(1, 5): + slot = BroadlinkMP1Slot( + _get_mp1_slot_name(friendly_name, i), + broadlink_device, i, parent_device) + switches.append(slot) broadlink_device.timeout = config.get(CONF_TIMEOUT) try: @@ -268,3 +297,84 @@ def _update(self, retry=2): if state is None and retry > 0: return self._update(retry-1) self._state = state + + +class BroadlinkMP1Slot(BroadlinkRMSwitch): + """Representation of a slot of Broadlink switch.""" + + def __init__(self, friendly_name, device, slot, parent_device): + """Initialize the slot of switch.""" + super().__init__(friendly_name, device, None, None) + self._command_on = 1 + self._command_off = 0 + self._slot = slot + self._parent_device = parent_device + + @property + def assumed_state(self): + """Return true if unable to access real state of entity.""" + return False + + def _sendpacket(self, packet, retry=2): + """Send packet to device.""" + try: + self._device.set_power(self._slot, packet) + except (socket.timeout, ValueError) as error: + if retry < 1: + _LOGGER.error(error) + return False + if not self._auth(): + return False + return self._sendpacket(packet, max(0, retry-1)) + return True + + @property + def should_poll(self): + """Polling needed.""" + return True + + def update(self): + """Trigger update for all switches on the parent device.""" + self._parent_device.update() + self._state = self._parent_device.get_outlet_status(self._slot) + + +class BroadlinkMP1Switch(object): + """Representation of a Broadlink switch - To fetch states of all slots.""" + + def __init__(self, device): + """Initialize the switch.""" + self._device = device + self._states = None + + def get_outlet_status(self, slot): + """Get status of outlet from cached status list.""" + return self._states['s{}'.format(slot)] + + @Throttle(TIME_BETWEEN_UPDATES) + def update(self): + """Fetch new state data for this device.""" + self._update() + + def _update(self, retry=2): + try: + states = self._device.check_power() + except (socket.timeout, ValueError) as error: + if retry < 1: + _LOGGER.error(error) + return + if not self._auth(): + return + return self._update(max(0, retry-1)) + if states is None and retry > 0: + return self._update(max(0, retry-1)) + self._states = states + + def _auth(self, retry=2): + try: + auth = self._device.auth() + except socket.timeout: + auth = False + if not auth and retry > 0: + return self._auth(retry-1) + return auth diff --git a/homeassistant/components/switch/mqtt.py b/homeassistant/components/switch/mqtt.py index 308cce4de46800..21820b4a015e41 100644 --- a/homeassistant/components/switch/mqtt.py +++ b/homeassistant/components/switch/mqtt.py @@ -111,7 +111,7 @@ def state_message_received(topic, payload, qos): elif payload == self._payload_off: self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @callback def availability_message_received(topic, payload, qos): @@ -121,7 +121,7 @@ def availability_message_received(topic, payload, qos): elif payload == self._payload_not_available: self._available = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._state_topic is None: # Force into optimistic mode. @@ -173,7 +173,7 @@ def async_turn_on(self, **kwargs): if self._optimistic: # Optimistically assume that switch has changed state. self._state = True - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_turn_off(self, **kwargs): @@ -187,4 +187,4 @@ def async_turn_off(self, **kwargs): if self._optimistic: # Optimistically assume that switch has changed state. self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/switch/template.py b/homeassistant/components/switch/template.py index fc076f32e883e0..9b73d668c8c094 100644 --- a/homeassistant/components/switch/template.py +++ b/homeassistant/components/switch/template.py @@ -103,7 +103,7 @@ def async_added_to_hass(self): @callback def template_switch_state_listener(entity, old_state, new_state): """Handle target device state changes.""" - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) @callback def template_switch_startup(event): @@ -111,7 +111,7 @@ def template_switch_startup(event): async_track_state_change( self.hass, self._entities, template_switch_state_listener) - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_START, template_switch_startup) diff --git a/homeassistant/components/vacuum/xiaomi.py b/homeassistant/components/vacuum/xiaomi.py index 95d7478aa9fc54..dad717960497dd 100644 --- a/homeassistant/components/vacuum/xiaomi.py +++ b/homeassistant/components/vacuum/xiaomi.py @@ -21,7 +21,7 @@ ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_TOKEN, STATE_OFF, STATE_ON) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-mirobo==0.1.3'] +REQUIREMENTS = ['python-mirobo==0.2.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/xiaomi.py b/homeassistant/components/xiaomi.py index 1d14a76d251706..ac197d2d942f42 100644 --- a/homeassistant/components/xiaomi.py +++ b/homeassistant/components/xiaomi.py @@ -4,10 +4,10 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity +from homeassistant.components.discovery import SERVICE_XIAOMI_GW from homeassistant.const import (ATTR_BATTERY_LEVEL, EVENT_HOMEASSISTANT_STOP, CONF_MAC) - REQUIREMENTS = ['https://github.com/Danielhiversen/PyXiaomiGateway/archive/' '0.3.2.zip#PyXiaomiGateway==0.3.2'] @@ -57,9 +57,22 @@ def _validate_conf(config): def setup(hass, config): """Set up the Xiaomi component.""" - gateways = config[DOMAIN][CONF_GATEWAYS] - interface = config[DOMAIN][CONF_INTERFACE] - discovery_retry = config[DOMAIN][CONF_DISCOVERY_RETRY] + gateways = [] + interface = 'any' + discovery_retry = 3 + if DOMAIN in config: + gateways = config[DOMAIN][CONF_GATEWAYS] + interface = config[DOMAIN][CONF_INTERFACE] + discovery_retry = config[DOMAIN][CONF_DISCOVERY_RETRY] + + def xiaomi_gw_discovered(service, discovery_info): + """Called when Xiaomi Gateway device(s) has been found.""" + # We don't need to do anything here, the purpose of HA's + # discovery service is to just trigger loading of this + # component, and then its own discovery process kicks in. + _LOGGER.info("Discovered: %s", discovery_info) + + discovery.listen(hass, SERVICE_XIAOMI_GW, xiaomi_gw_discovered) from PyXiaomiGateway import PyXiaomiGateway hass.data[PY_XIAOMI_GATEWAY] = PyXiaomiGateway(hass.add_job, gateways, diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index 853966279b6250..c88c55e258fb4f 100755 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -85,6 +85,12 @@ vol.Optional(const.ATTR_CONFIG_SIZE, default=2): vol.Coerce(int) }) +SET_POLL_INTENSITY_SCHEMA = vol.Schema({ + vol.Required(const.ATTR_NODE_ID): vol.Coerce(int), + vol.Required(const.ATTR_VALUE_ID): vol.Coerce(int), + vol.Required(const.ATTR_POLL_INTENSITY): vol.Coerce(int), +}) + PRINT_CONFIG_PARAMETER_SCHEMA = vol.Schema({ vol.Required(const.ATTR_NODE_ID): vol.Coerce(int), vol.Required(const.ATTR_CONFIG_PARAMETER): vol.Coerce(int), @@ -415,6 +421,29 @@ def rename_value(service): "Renamed Z-Wave value (Node %d Value %d) to %s", node_id, value_id, name) + def set_poll_intensity(service): + """Set the polling intensity of a node value.""" + node_id = service.data.get(const.ATTR_NODE_ID) + value_id = service.data.get(const.ATTR_VALUE_ID) + node = network.nodes[node_id] + value = node.values[value_id] + intensity = service.data.get(const.ATTR_POLL_INTENSITY) + if intensity == 0: + if value.disable_poll(): + _LOGGER.info("Polling disabled (Node %d Value %d)", + node_id, value_id) + return + _LOGGER.info("Polling disabled failed (Node %d Value %d)", + node_id, value_id) + else: + if value.enable_poll(intensity): + _LOGGER.info( + "Set polling intensity (Node %d Value %d) to %s", + node_id, value_id, intensity) + return + _LOGGER.info("Set polling intensity failed (Node %d Value %d)", + node_id, value_id) + def remove_failed_node(service): """Remove failed node.""" node_id = service.data.get(const.ATTR_NODE_ID) @@ -651,6 +680,10 @@ def start_zwave(_service_or_event): descriptions[ const.SERVICE_RESET_NODE_METERS], schema=RESET_NODE_METERS_SCHEMA) + hass.services.register(DOMAIN, const.SERVICE_SET_POLL_INTENSITY, + set_poll_intensity, + descriptions[const.SERVICE_SET_POLL_INTENSITY], + schema=SET_POLL_INTENSITY_SCHEMA) # Setup autoheal if autoheal: @@ -775,8 +808,6 @@ def _check_entity_ready(self): node_config.get(CONF_POLLING_INTENSITY), int) if polling_intensity: self.primary.enable_poll(polling_intensity) - else: - self.primary.disable_poll() platform = get_platform(component, DOMAIN) device = platform.get_device( @@ -887,6 +918,7 @@ def device_state_attributes(self): const.ATTR_NODE_ID: self.node_id, const.ATTR_VALUE_INDEX: self.values.primary.index, const.ATTR_VALUE_INSTANCE: self.values.primary.instance, + const.ATTR_VALUE_ID: str(self.values.primary.value_id), 'old_entity_id': self.old_entity_id, 'new_entity_id': self.new_entity_id, } diff --git a/homeassistant/components/zwave/const.py b/homeassistant/components/zwave/const.py index a238d01d520048..dced1689dba494 100644 --- a/homeassistant/components/zwave/const.py +++ b/homeassistant/components/zwave/const.py @@ -15,6 +15,7 @@ ATTR_CONFIG_PARAMETER = "parameter" ATTR_CONFIG_SIZE = "size" ATTR_CONFIG_VALUE = "value" +ATTR_POLL_INTENSITY = "poll_intensity" ATTR_VALUE_INDEX = "value_index" ATTR_VALUE_INSTANCE = "value_instance" NETWORK_READY_WAIT_SECS = 30 @@ -38,6 +39,7 @@ SERVICE_PRINT_NODE = "print_node" SERVICE_REMOVE_FAILED_NODE = "remove_failed_node" SERVICE_REPLACE_FAILED_NODE = "replace_failed_node" +SERVICE_SET_POLL_INTENSITY = "set_poll_intensity" SERVICE_SET_WAKEUP = "set_wakeup" SERVICE_STOP_NETWORK = "stop_network" SERVICE_START_NETWORK = "start_network" diff --git a/homeassistant/components/zwave/services.yaml b/homeassistant/components/zwave/services.yaml index ea8a6eaa036608..92b5fa25d201c4 100644 --- a/homeassistant/components/zwave/services.yaml +++ b/homeassistant/components/zwave/services.yaml @@ -56,6 +56,20 @@ set_config_parameter: size: description: (Optional) Set the size of the parameter value. Only needed if no parameters are available. +set_poll_intensity: + description: Set the polling interval to a nodes value + fields: + node_id: + description: ID of the node to set polling to. + example: 10 + value_id: + description: ID of the value to set polling to. + example: 72037594255792737 + poll_intensity: + description: The intensity to poll, 0 = disabled, 1 = Every time through list, 2 = Every second time through list... + example: 2 + + print_config_parameter: description: Prints a Z-Wave node config parameter value to log. fields: diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 49f250c65fa9df..835b616987cbd2 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -297,10 +297,14 @@ def async_update_ha_state(self, force_refresh=False): def schedule_update_ha_state(self, force_refresh=False): """Schedule a update ha state change task. - That is only needed on executor to not block. + That avoid executor dead looks. """ self.hass.add_job(self.async_update_ha_state(force_refresh)) + def async_schedule_update_ha_state(self, force_refresh=False): + """Schedule a update ha state change task.""" + self.hass.async_add_job(self.async_update_ha_state(force_refresh)) + def remove(self) -> None: """Remove entity from HASS.""" run_coroutine_threadsafe( diff --git a/homeassistant/scripts/macos/launchd.plist b/homeassistant/scripts/macos/launchd.plist index b65cdac743982d..920f45a0c0e9e0 100644 --- a/homeassistant/scripts/macos/launchd.plist +++ b/homeassistant/scripts/macos/launchd.plist @@ -8,7 +8,9 @@ EnvironmentVariables PATH - /usr/local/bin/:/usr/bin:$PATH + /usr/local/bin/:/usr/bin:/usr/sbin:$PATH + LC_CTYPE + UTF-8 Program diff --git a/requirements_all.txt b/requirements_all.txt index 703bbd6b1848a8..114d0948f427d7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -21,7 +21,7 @@ astral==1.4 PyISY==1.0.8 # homeassistant.components.notify.html5 -PyJWT==1.5.2 +PyJWT==1.5.3 # homeassistant.components.sensor.mvglive PyMVGLive==1.1.4 @@ -368,6 +368,9 @@ libnacl==1.5.2 # homeassistant.components.dyson libpurecoollink==0.4.2 +# homeassistant.components.camera.foscam +libpyfoscam==1.0 + # homeassistant.components.device_tracker.mikrotik librouteros==1.0.2 @@ -413,7 +416,7 @@ miflora==0.1.16 miniupnpc==1.9 # homeassistant.components.sensor.mopar -motorparts==1.0.0 +motorparts==1.0.2 # homeassistant.components.tts mutagen==1.38 @@ -429,7 +432,7 @@ myusps==1.1.3 nad_receiver==0.0.6 # homeassistant.components.discovery -netdisco==1.1.0 +netdisco==1.2.0 # homeassistant.components.sensor.neurio_energy neurio==0.3.1 @@ -507,7 +510,7 @@ proliphix==0.4.1 prometheus_client==0.0.19 # homeassistant.components.sensor.systemmonitor -psutil==5.3.0 +psutil==5.3.1 # homeassistant.components.wink pubnubsub-handler==1.0.2 @@ -599,9 +602,6 @@ pyfido==1.0.1 # homeassistant.components.climate.flexit pyflexit==0.3 -# homeassistant.components.camera.foscam -pyfoscam==1.2 - # homeassistant.components.ifttt pyfttt==0.3 @@ -754,7 +754,7 @@ python-juicenet==0.0.5 # homeassistant.components.light.xiaomi_philipslight # homeassistant.components.vacuum.xiaomi -python-mirobo==0.1.3 +python-mirobo==0.2.0 # homeassistant.components.media_player.mpd python-mpd2==0.5.5 @@ -827,7 +827,7 @@ pyvizio==0.0.2 pyvlx==0.1.3 # homeassistant.components.notify.html5 -pywebpush==1.0.6 +pywebpush==1.1.0 # homeassistant.components.wemo pywemo==0.4.20 @@ -961,6 +961,9 @@ thingspeak==0.4.1 # homeassistant.components.light.tikteck tikteck==0.4 +# homeassistant.components.calendar.todoist +todoist-python==7.0.17 + # homeassistant.components.alarm_control_panel.totalconnect total_connect_client==0.11 @@ -978,7 +981,7 @@ uber_rides==0.5.2 upsmychoice==1.0.6 # homeassistant.components.camera.uvc -uvcclient==0.10.0 +uvcclient==0.10.1 # homeassistant.components.volvooncall volvooncall==0.3.3 @@ -999,6 +1002,9 @@ wakeonlan==0.2.2 # homeassistant.components.sensor.waqi waqiasync==1.0.0 +# homeassistant.components.cloud +warrant==0.2.0 + # homeassistant.components.media_player.gpmdp websocket-client==0.37.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7695f83497b84b..4e543a8eada03d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -21,7 +21,7 @@ freezegun>=0.3.8 # homeassistant.components.notify.html5 -PyJWT==1.5.2 +PyJWT==1.5.3 # homeassistant.components.media_player.sonos SoCo==0.12 @@ -111,7 +111,7 @@ python-forecastio==1.3.5 pyunifi==2.13 # homeassistant.components.notify.html5 -pywebpush==1.0.6 +pywebpush==1.1.0 # homeassistant.components.python_script restrictedpython==4.0a3 @@ -139,7 +139,10 @@ sqlalchemy==1.1.13 statsd==3.2.1 # homeassistant.components.camera.uvc -uvcclient==0.10.0 +uvcclient==0.10.1 + +# homeassistant.components.cloud +warrant==0.2.0 # 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 8a215cd2873ae9..99bcf80288bffb 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -33,44 +33,45 @@ ) TEST_REQUIREMENTS = ( - 'pydispatch', - 'influxdb', - 'nx584', - 'uvcclient', - 'somecomfort', 'aioautomatic', - 'SoCo', - 'libsoundtouch', - 'libpurecoollink', - 'rxv', + 'aiohttp_cors', 'apns2', - 'sqlalchemy', + 'dsmr_parser', + 'ephem', + 'evohomeclient', 'forecastio', - 'aiohttp_cors', - 'pilight', 'fuzzywuzzy', - 'rflink', - 'ring_doorbell', - 'sleepyq', - 'statsd', - 'pylitejet', - 'holidays', - 'evohomeclient', - 'pexpect', + 'gTTS-token', + 'ha-ffmpeg', 'hbmqtt', - 'paho', - 'dsmr_parser', + 'holidays', + 'influxdb', + 'libpurecoollink', + 'libsoundtouch', 'mficlient', + 'nx584', + 'paho', + 'pexpect', + 'pilight', 'pmsensor', - 'yahoo-finance', - 'ha-ffmpeg', - 'gTTS-token', - 'pywebpush', + 'prometheus_client', + 'pydispatch', 'PyJWT', - 'restrictedpython', + 'pylitejet', 'pyunifi', - 'prometheus_client', - 'ephem' + 'pywebpush', + 'restrictedpython', + 'rflink', + 'ring_doorbell', + 'rxv', + 'sleepyq', + 'SoCo', + 'somecomfort', + 'sqlalchemy', + 'statsd', + 'uvcclient', + 'warrant', + 'yahoo-finance', ) IGNORE_PACKAGES = ( diff --git a/tests/components/alarm_control_panel/test_manual.py b/tests/components/alarm_control_panel/test_manual.py index 063f3361148692..b5af01584d36cf 100644 --- a/tests/components/alarm_control_panel/test_manual.py +++ b/tests/components/alarm_control_panel/test_manual.py @@ -352,6 +352,137 @@ def test_trigger_with_pending(self): self.assertEqual(STATE_ALARM_DISARMED, self.hass.states.get(entity_id).state) + def test_armed_home_with_specific_pending(self): + """Test arm home method.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'pending_time': 10, + 'armed_home': { + 'pending_time': 2 + } + }})) + + 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=2) + 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_ARMED_HOME, + self.hass.states.get(entity_id).state) + + def test_armed_away_with_specific_pending(self): + """Test arm home method.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'pending_time': 10, + 'armed_away': { + 'pending_time': 2 + } + }})) + + entity_id = 'alarm_control_panel.test' + + alarm_control_panel.alarm_arm_away(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=2) + 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_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + def test_armed_night_with_specific_pending(self): + """Test arm home method.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'pending_time': 10, + 'armed_night': { + 'pending_time': 2 + } + }})) + + entity_id = 'alarm_control_panel.test' + + alarm_control_panel.alarm_arm_night(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=2) + 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_ARMED_NIGHT, + self.hass.states.get(entity_id).state) + + def test_trigger_with_specific_pending(self): + """Test arm home method.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'pending_time': 10, + 'triggered': { + 'pending_time': 2 + }, + 'trigger_time': 3, + 'disarm_after_trigger': False + }})) + + entity_id = 'alarm_control_panel.test' + + alarm_control_panel.alarm_trigger(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=2) + 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_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_disarm_after_trigger(self): """Test disarm after trigger.""" self.assertTrue(setup_component( diff --git a/tests/components/alexa/__init__.py b/tests/components/alexa/__init__.py new file mode 100644 index 00000000000000..88ecc63d200198 --- /dev/null +++ b/tests/components/alexa/__init__.py @@ -0,0 +1 @@ +"""Tests for the Alexa integration.""" diff --git a/tests/components/alexa/test_flash_briefings.py b/tests/components/alexa/test_flash_briefings.py new file mode 100644 index 00000000000000..d9f0c8e156ddf1 --- /dev/null +++ b/tests/components/alexa/test_flash_briefings.py @@ -0,0 +1,98 @@ +"""The tests for the Alexa component.""" +# pylint: disable=protected-access +import asyncio +import datetime + +import pytest + +from homeassistant.core import callback +from homeassistant.setup import async_setup_component +from homeassistant.components import alexa +from homeassistant.components.alexa import const + +SESSION_ID = "amzn1.echo-api.session.0000000-0000-0000-0000-00000000000" +APPLICATION_ID = "amzn1.echo-sdk-ams.app.000000-d0ed-0000-ad00-000000d00ebe" +REQUEST_ID = "amzn1.echo-api.request.0000000-0000-0000-0000-00000000000" + +# pylint: disable=invalid-name +calls = [] + +NPR_NEWS_MP3_URL = "https://pd.npr.org/anon.npr-mp3/npr/news/newscast.mp3" + + +@pytest.fixture +def alexa_client(loop, hass, test_client): + """Initialize a Home Assistant server for testing this module.""" + @callback + def mock_service(call): + calls.append(call) + + hass.services.async_register("test", "alexa", mock_service) + + assert loop.run_until_complete(async_setup_component(hass, alexa.DOMAIN, { + # Key is here to verify we allow other keys in config too + "homeassistant": {}, + "alexa": { + "flash_briefings": { + "weather": [ + {"title": "Weekly forecast", + "text": "This week it will be sunny."}, + {"title": "Current conditions", + "text": "Currently it is 80 degrees fahrenheit."} + ], + "news_audio": { + "title": "NPR", + "audio": NPR_NEWS_MP3_URL, + "display_url": "https://npr.org", + "uid": "uuid" + } + }, + } + })) + return loop.run_until_complete(test_client(hass.http.app)) + + +def _flash_briefing_req(client, briefing_id): + return client.get( + "/api/alexa/flash_briefings/{}".format(briefing_id)) + + +@asyncio.coroutine +def test_flash_briefing_invalid_id(alexa_client): + """Test an invalid Flash Briefing ID.""" + req = yield from _flash_briefing_req(alexa_client, 10000) + assert req.status == 404 + text = yield from req.text() + assert text == '' + + +@asyncio.coroutine +def test_flash_briefing_date_from_str(alexa_client): + """Test the response has a valid date parsed from string.""" + req = yield from _flash_briefing_req(alexa_client, "weather") + assert req.status == 200 + data = yield from req.json() + assert isinstance(datetime.datetime.strptime(data[0].get( + const.ATTR_UPDATE_DATE), const.DATE_FORMAT), datetime.datetime) + + +@asyncio.coroutine +def test_flash_briefing_valid(alexa_client): + """Test the response is valid.""" + data = [{ + "titleText": "NPR", + "redirectionURL": "https://npr.org", + "streamUrl": NPR_NEWS_MP3_URL, + "mainText": "", + "uid": "uuid", + "updateDate": '2016-10-10T19:51:42.0Z' + }] + + req = yield from _flash_briefing_req(alexa_client, "news_audio") + assert req.status == 200 + json = yield from req.json() + assert isinstance(datetime.datetime.strptime(json[0].get( + const.ATTR_UPDATE_DATE), const.DATE_FORMAT), datetime.datetime) + json[0].pop(const.ATTR_UPDATE_DATE) + data[0].pop(const.ATTR_UPDATE_DATE) + assert json == data diff --git a/tests/components/test_alexa.py b/tests/components/alexa/test_intent.py similarity index 87% rename from tests/components/test_alexa.py rename to tests/components/alexa/test_intent.py index 3789e7ab615ae1..565ebec64aa21f 100644 --- a/tests/components/test_alexa.py +++ b/tests/components/alexa/test_intent.py @@ -2,13 +2,13 @@ # pylint: disable=protected-access import asyncio import json -import datetime import pytest from homeassistant.core import callback from homeassistant.setup import async_setup_component from homeassistant.components import alexa +from homeassistant.components.alexa import intent SESSION_ID = "amzn1.echo-api.session.0000000-0000-0000-0000-00000000000" APPLICATION_ID = "amzn1.echo-sdk-ams.app.000000-d0ed-0000-ad00-000000d00ebe" @@ -32,22 +32,6 @@ def mock_service(call): assert loop.run_until_complete(async_setup_component(hass, alexa.DOMAIN, { # Key is here to verify we allow other keys in config too "homeassistant": {}, - "alexa": { - "flash_briefings": { - "weather": [ - {"title": "Weekly forecast", - "text": "This week it will be sunny."}, - {"title": "Current conditions", - "text": "Currently it is 80 degrees fahrenheit."} - ], - "news_audio": { - "title": "NPR", - "audio": NPR_NEWS_MP3_URL, - "display_url": "https://npr.org", - "uid": "uuid" - } - }, - } })) assert loop.run_until_complete(async_setup_component( hass, 'intent_script', { @@ -113,15 +97,10 @@ def mock_service(call): def _intent_req(client, data={}): - return client.post(alexa.INTENTS_API_ENDPOINT, data=json.dumps(data), + return client.post(intent.INTENTS_API_ENDPOINT, data=json.dumps(data), headers={'content-type': 'application/json'}) -def _flash_briefing_req(client, briefing_id): - return client.get( - "/api/alexa/flash_briefings/{}".format(briefing_id)) - - @asyncio.coroutine def test_intent_launch_request(alexa_client): """Test the launch of a request.""" @@ -467,44 +446,3 @@ def test_intent_from_built_in_intent_library(alexa_client): text = data.get("response", {}).get("outputSpeech", {}).get("text") assert text == "Playing the shins." - - -@asyncio.coroutine -def test_flash_briefing_invalid_id(alexa_client): - """Test an invalid Flash Briefing ID.""" - req = yield from _flash_briefing_req(alexa_client, 10000) - assert req.status == 404 - text = yield from req.text() - assert text == '' - - -@asyncio.coroutine -def test_flash_briefing_date_from_str(alexa_client): - """Test the response has a valid date parsed from string.""" - req = yield from _flash_briefing_req(alexa_client, "weather") - assert req.status == 200 - data = yield from req.json() - assert isinstance(datetime.datetime.strptime(data[0].get( - alexa.ATTR_UPDATE_DATE), alexa.DATE_FORMAT), datetime.datetime) - - -@asyncio.coroutine -def test_flash_briefing_valid(alexa_client): - """Test the response is valid.""" - data = [{ - "titleText": "NPR", - "redirectionURL": "https://npr.org", - "streamUrl": NPR_NEWS_MP3_URL, - "mainText": "", - "uid": "uuid", - "updateDate": '2016-10-10T19:51:42.0Z' - }] - - req = yield from _flash_briefing_req(alexa_client, "news_audio") - assert req.status == 200 - json = yield from req.json() - assert isinstance(datetime.datetime.strptime(json[0].get( - alexa.ATTR_UPDATE_DATE), alexa.DATE_FORMAT), datetime.datetime) - json[0].pop(alexa.ATTR_UPDATE_DATE) - data[0].pop(alexa.ATTR_UPDATE_DATE) - assert json == data diff --git a/tests/components/binary_sensor/test_bayesian.py b/tests/components/binary_sensor/test_bayesian.py index f86047f3a3df47..3b403c3702f088 100644 --- a/tests/components/binary_sensor/test_bayesian.py +++ b/tests/components/binary_sensor/test_bayesian.py @@ -73,8 +73,7 @@ def test_sensor_numeric_state(self): 'prob_false': 0.1, 'prob_true': 0.9 }], state.attributes.get('observations')) - self.assertAlmostEqual(0.7714285714285715, - state.attributes.get('probability')) + self.assertAlmostEqual(0.77, state.attributes.get('probability')) assert state.state == 'on' @@ -141,7 +140,7 @@ def test_sensor_state(self): 'prob_true': 0.8, 'prob_false': 0.4 }], state.attributes.get('observations')) - self.assertAlmostEqual(0.33333333, state.attributes.get('probability')) + self.assertAlmostEqual(0.33, state.attributes.get('probability')) assert state.state == 'on' @@ -155,6 +154,71 @@ def test_sensor_state(self): assert state.state == 'off' + def test_multiple_observations(self): + """Test sensor with multiple observations of same entity.""" + config = { + 'binary_sensor': { + 'name': + 'Test_Binary', + 'platform': + 'bayesian', + 'observations': [{ + 'platform': 'state', + 'entity_id': 'sensor.test_monitored', + 'to_state': 'blue', + 'prob_given_true': 0.8, + 'prob_given_false': 0.4 + }, { + 'platform': 'state', + 'entity_id': 'sensor.test_monitored', + 'to_state': 'red', + 'prob_given_true': 0.2, + 'prob_given_false': 0.4 + }], + 'prior': + 0.2, + 'probability_threshold': + 0.32, + } + } + + assert setup_component(self.hass, 'binary_sensor', config) + + self.hass.states.set('sensor.test_monitored', 'off') + + state = self.hass.states.get('binary_sensor.test_binary') + + self.assertEqual([], state.attributes.get('observations')) + self.assertEqual(0.2, state.attributes.get('probability')) + + assert state.state == 'off' + + self.hass.states.set('sensor.test_monitored', 'blue') + self.hass.block_till_done() + self.hass.states.set('sensor.test_monitored', 'off') + self.hass.block_till_done() + self.hass.states.set('sensor.test_monitored', 'blue') + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_binary') + self.assertEqual([{ + 'prob_true': 0.8, + 'prob_false': 0.4 + }], state.attributes.get('observations')) + self.assertAlmostEqual(0.33, state.attributes.get('probability')) + + assert state.state == 'on' + + self.hass.states.set('sensor.test_monitored', 'blue') + self.hass.block_till_done() + self.hass.states.set('sensor.test_monitored', 'red') + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_binary') + self.assertAlmostEqual(0.11, state.attributes.get('probability')) + + assert state.state == 'off' + def test_probability_updates(self): """Test probability update function.""" prob_true = [0.3, 0.6, 0.8] diff --git a/tests/components/cloud/test_auth_api.py b/tests/components/cloud/test_auth_api.py new file mode 100644 index 00000000000000..652829d2f32396 --- /dev/null +++ b/tests/components/cloud/test_auth_api.py @@ -0,0 +1,271 @@ +"""Tests for the tools to communicate with the cloud.""" +from unittest.mock import MagicMock, patch + +from botocore.exceptions import ClientError +import pytest + +from homeassistant.components.cloud import DOMAIN, auth_api + + +MOCK_AUTH = { + "id_token": "fake_id_token", + "access_token": "fake_access_token", + "refresh_token": "fake_refresh_token", +} + + +@pytest.fixture +def cloud_hass(hass): + """Fixture to return a hass instance with cloud mode set.""" + hass.data[DOMAIN] = {'mode': 'development'} + return hass + + +@pytest.fixture +def mock_write(): + """Mock reading authentication.""" + with patch.object(auth_api, '_write_info') as mock: + yield mock + + +@pytest.fixture +def mock_read(): + """Mock writing authentication.""" + with patch.object(auth_api, '_read_info') as mock: + yield mock + + +@pytest.fixture +def mock_cognito(): + """Mock warrant.""" + with patch('homeassistant.components.cloud.auth_api._cognito') as mock_cog: + yield mock_cog() + + +@pytest.fixture +def mock_auth(): + """Mock warrant.""" + with patch('homeassistant.components.cloud.auth_api.Auth') as mock_auth: + yield mock_auth() + + +def aws_error(code, message='Unknown', operation_name='fake_operation_name'): + """Generate AWS error response.""" + response = { + 'Error': { + 'Code': code, + 'Message': message + } + } + return ClientError(response, operation_name) + + +def test_load_auth_with_no_stored_auth(cloud_hass, mock_read): + """Test loading authentication with no stored auth.""" + mock_read.return_value = None + auth = auth_api.load_auth(cloud_hass) + assert auth.cognito is None + + +def test_load_auth_with_invalid_auth(cloud_hass, mock_read, mock_cognito): + """Test calling load_auth when auth is no longer valid.""" + mock_cognito.get_user.side_effect = aws_error('SomeError') + auth = auth_api.load_auth(cloud_hass) + + assert auth.cognito is None + + +def test_load_auth_with_valid_auth(cloud_hass, mock_read, mock_cognito): + """Test calling load_auth when valid auth.""" + auth = auth_api.load_auth(cloud_hass) + + assert auth.cognito is not None + + +def test_auth_properties(): + """Test Auth class properties.""" + auth = auth_api.Auth(None, None) + assert not auth.is_logged_in + auth.account = {} + assert auth.is_logged_in + + +def test_auth_validate_auth_verification_fails(mock_cognito): + """Test validate authentication with verify request failing.""" + mock_cognito.get_user.side_effect = aws_error('UserNotFoundException') + + auth = auth_api.Auth(None, mock_cognito) + assert auth.validate_auth() is False + + +def test_auth_validate_auth_token_refresh_needed_fails(mock_cognito): + """Test validate authentication with refresh needed which gets 401.""" + mock_cognito.get_user.side_effect = aws_error('NotAuthorizedException') + mock_cognito.renew_access_token.side_effect = \ + aws_error('NotAuthorizedException') + + auth = auth_api.Auth(None, mock_cognito) + assert auth.validate_auth() is False + + +def test_auth_validate_auth_token_refresh_needed_succeeds(mock_write, + mock_cognito): + """Test validate authentication with refresh.""" + mock_cognito.get_user.side_effect = [ + aws_error('NotAuthorizedException'), + MagicMock(email='hello@home-assistant.io') + ] + + auth = auth_api.Auth(None, mock_cognito) + assert auth.validate_auth() is True + assert len(mock_write.mock_calls) == 1 + + +def test_auth_login_invalid_auth(mock_cognito, mock_write): + """Test trying to login with invalid credentials.""" + mock_cognito.authenticate.side_effect = aws_error('NotAuthorizedException') + auth = auth_api.Auth(None, None) + with pytest.raises(auth_api.Unauthenticated): + auth.login('user', 'pass') + + assert not auth.is_logged_in + assert len(mock_cognito.get_user.mock_calls) == 0 + assert len(mock_write.mock_calls) == 0 + + +def test_auth_login_user_not_found(mock_cognito, mock_write): + """Test trying to login with invalid credentials.""" + mock_cognito.authenticate.side_effect = aws_error('UserNotFoundException') + auth = auth_api.Auth(None, None) + with pytest.raises(auth_api.UserNotFound): + auth.login('user', 'pass') + + assert not auth.is_logged_in + assert len(mock_cognito.get_user.mock_calls) == 0 + assert len(mock_write.mock_calls) == 0 + + +def test_auth_login_user_not_confirmed(mock_cognito, mock_write): + """Test trying to login without confirming account.""" + mock_cognito.authenticate.side_effect = \ + aws_error('UserNotConfirmedException') + auth = auth_api.Auth(None, None) + with pytest.raises(auth_api.UserNotConfirmed): + auth.login('user', 'pass') + + assert not auth.is_logged_in + assert len(mock_cognito.get_user.mock_calls) == 0 + assert len(mock_write.mock_calls) == 0 + + +def test_auth_login(cloud_hass, mock_cognito, mock_write): + """Test trying to login without confirming account.""" + mock_cognito.get_user.return_value = \ + MagicMock(email='hello@home-assistant.io') + auth = auth_api.Auth(cloud_hass, None) + auth.login('user', 'pass') + assert auth.is_logged_in + assert len(mock_cognito.authenticate.mock_calls) == 1 + assert len(mock_write.mock_calls) == 1 + result_hass, result_auth = mock_write.mock_calls[0][1] + assert result_hass is cloud_hass + assert result_auth is auth + + +def test_auth_renew_access_token(mock_write, mock_cognito): + """Test renewing an access token.""" + auth = auth_api.Auth(None, mock_cognito) + assert auth.renew_access_token() + assert len(mock_write.mock_calls) == 1 + + +def test_auth_renew_access_token_fails(mock_write, mock_cognito): + """Test failing to renew an access token.""" + mock_cognito.renew_access_token.side_effect = aws_error('SomeError') + auth = auth_api.Auth(None, mock_cognito) + assert not auth.renew_access_token() + assert len(mock_write.mock_calls) == 0 + + +def test_auth_logout(mock_write, mock_cognito): + """Test renewing an access token.""" + auth = auth_api.Auth(None, mock_cognito) + auth.account = MagicMock() + auth.logout() + assert auth.account is None + assert len(mock_write.mock_calls) == 1 + + +def test_auth_logout_fails(mock_write, mock_cognito): + """Test error while logging out.""" + mock_cognito.logout.side_effect = aws_error('SomeError') + auth = auth_api.Auth(None, mock_cognito) + auth.account = MagicMock() + with pytest.raises(auth_api.CloudError): + auth.logout() + assert auth.account is not None + assert len(mock_write.mock_calls) == 0 + + +def test_register(mock_cognito): + """Test registering an account.""" + auth_api.register(None, 'email@home-assistant.io', 'password') + assert len(mock_cognito.register.mock_calls) == 1 + result_email, result_password = mock_cognito.register.mock_calls[0][1] + assert result_email == 'email@home-assistant.io' + assert result_password == 'password' + + +def test_register_fails(mock_cognito): + """Test registering an account.""" + mock_cognito.register.side_effect = aws_error('SomeError') + with pytest.raises(auth_api.CloudError): + auth_api.register(None, 'email@home-assistant.io', 'password') + + +def test_confirm_register(mock_cognito): + """Test confirming a registration of an account.""" + auth_api.confirm_register(None, '123456', 'email@home-assistant.io') + assert len(mock_cognito.confirm_sign_up.mock_calls) == 1 + result_code, result_email = mock_cognito.confirm_sign_up.mock_calls[0][1] + assert result_email == 'email@home-assistant.io' + assert result_code == '123456' + + +def test_confirm_register_fails(mock_cognito): + """Test an error during confirmation of an account.""" + mock_cognito.confirm_sign_up.side_effect = aws_error('SomeError') + with pytest.raises(auth_api.CloudError): + auth_api.confirm_register(None, '123456', 'email@home-assistant.io') + + +def test_forgot_password(mock_cognito): + """Test starting forgot password flow.""" + auth_api.forgot_password(None, 'email@home-assistant.io') + assert len(mock_cognito.initiate_forgot_password.mock_calls) == 1 + + +def test_forgot_password_fails(mock_cognito): + """Test failure when starting forgot password flow.""" + mock_cognito.initiate_forgot_password.side_effect = aws_error('SomeError') + with pytest.raises(auth_api.CloudError): + auth_api.forgot_password(None, 'email@home-assistant.io') + + +def test_confirm_forgot_password(mock_cognito): + """Test confirming forgot password.""" + auth_api.confirm_forgot_password( + None, '123456', 'email@home-assistant.io', 'new password') + assert len(mock_cognito.confirm_forgot_password.mock_calls) == 1 + result_code, result_password = \ + mock_cognito.confirm_forgot_password.mock_calls[0][1] + assert result_code == '123456' + assert result_password == 'new password' + + +def test_confirm_forgot_password_fails(mock_cognito): + """Test failure when confirming forgot password.""" + mock_cognito.confirm_forgot_password.side_effect = aws_error('SomeError') + with pytest.raises(auth_api.CloudError): + auth_api.confirm_forgot_password( + None, '123456', 'email@home-assistant.io', 'new password') diff --git a/tests/components/cloud/test_cloud_api.py b/tests/components/cloud/test_cloud_api.py deleted file mode 100644 index 11c396daf05b1d..00000000000000 --- a/tests/components/cloud/test_cloud_api.py +++ /dev/null @@ -1,352 +0,0 @@ -"""Tests for the tools to communicate with the cloud.""" -import asyncio -from datetime import timedelta -from unittest.mock import patch -from urllib.parse import urljoin - -import aiohttp -import pytest - -from homeassistant.components.cloud import DOMAIN, cloud_api, const -import homeassistant.util.dt as dt_util - -from tests.common import mock_coro - - -MOCK_AUTH = { - "access_token": "jvCHxpTu2nfORLBRgQY78bIAoK4RPa", - "expires_at": "2017-08-29T05:33:28.266048+00:00", - "expires_in": 86400, - "refresh_token": "C4wR1mgb03cs69EeiFgGOBC8mMQC5Q", - "scope": "", - "token_type": "Bearer" -} - - -def url(path): - """Create a url.""" - return urljoin(const.SERVERS['development']['host'], path) - - -@pytest.fixture -def cloud_hass(hass): - """Fixture to return a hass instance with cloud mode set.""" - hass.data[DOMAIN] = {'mode': 'development'} - return hass - - -@pytest.fixture -def mock_write(): - """Mock reading authentication.""" - with patch.object(cloud_api, '_write_auth') as mock: - yield mock - - -@pytest.fixture -def mock_read(): - """Mock writing authentication.""" - with patch.object(cloud_api, '_read_auth') as mock: - yield mock - - -@asyncio.coroutine -def test_async_login_invalid_auth(cloud_hass, aioclient_mock, mock_write): - """Test trying to login with invalid credentials.""" - aioclient_mock.post(url('o/token/'), status=401) - with pytest.raises(cloud_api.Unauthenticated): - yield from cloud_api.async_login(cloud_hass, 'user', 'pass') - - assert len(mock_write.mock_calls) == 0 - - -@asyncio.coroutine -def test_async_login_cloud_error(cloud_hass, aioclient_mock, mock_write): - """Test exception in cloud while logging in.""" - aioclient_mock.post(url('o/token/'), status=500) - with pytest.raises(cloud_api.UnknownError): - yield from cloud_api.async_login(cloud_hass, 'user', 'pass') - - assert len(mock_write.mock_calls) == 0 - - -@asyncio.coroutine -def test_async_login_client_error(cloud_hass, aioclient_mock, mock_write): - """Test client error while logging in.""" - aioclient_mock.post(url('o/token/'), exc=aiohttp.ClientError) - with pytest.raises(cloud_api.UnknownError): - yield from cloud_api.async_login(cloud_hass, 'user', 'pass') - - assert len(mock_write.mock_calls) == 0 - - -@asyncio.coroutine -def test_async_login(cloud_hass, aioclient_mock, mock_write): - """Test logging in.""" - aioclient_mock.post(url('o/token/'), json={ - 'expires_in': 10 - }) - now = dt_util.utcnow() - with patch('homeassistant.components.cloud.cloud_api.utcnow', - return_value=now): - yield from cloud_api.async_login(cloud_hass, 'user', 'pass') - - assert len(mock_write.mock_calls) == 1 - result_hass, result_data = mock_write.mock_calls[0][1] - assert result_hass is cloud_hass - assert result_data == { - 'expires_in': 10, - 'expires_at': (now + timedelta(seconds=10)).isoformat() - } - - -@asyncio.coroutine -def test_load_auth_with_no_stored_auth(cloud_hass, mock_read): - """Test loading authentication with no stored auth.""" - mock_read.return_value = None - - result = yield from cloud_api.async_load_auth(cloud_hass) - - assert result is None - - -@asyncio.coroutine -def test_load_auth_timeout_during_verification(cloud_hass, mock_read): - """Test loading authentication with timeout during verification.""" - mock_read.return_value = MOCK_AUTH - - with patch.object(cloud_api.Cloud, 'async_refresh_account_info', - side_effect=asyncio.TimeoutError): - result = yield from cloud_api.async_load_auth(cloud_hass) - - assert result is None - - -@asyncio.coroutine -def test_load_auth_verification_failed_500(cloud_hass, mock_read, - aioclient_mock): - """Test loading authentication with verify request getting 500.""" - mock_read.return_value = MOCK_AUTH - aioclient_mock.get(url('account.json'), status=500) - - result = yield from cloud_api.async_load_auth(cloud_hass) - - assert result is None - - -@asyncio.coroutine -def test_load_auth_token_refresh_needed_401(cloud_hass, mock_read, - aioclient_mock): - """Test loading authentication with refresh needed which gets 401.""" - mock_read.return_value = MOCK_AUTH - aioclient_mock.get(url('account.json'), status=403) - aioclient_mock.post(url('o/token/'), status=401) - - result = yield from cloud_api.async_load_auth(cloud_hass) - - assert result is None - - -@asyncio.coroutine -def test_load_auth_token_refresh_needed_500(cloud_hass, mock_read, - aioclient_mock): - """Test loading authentication with refresh needed which gets 500.""" - mock_read.return_value = MOCK_AUTH - aioclient_mock.get(url('account.json'), status=403) - aioclient_mock.post(url('o/token/'), status=500) - - result = yield from cloud_api.async_load_auth(cloud_hass) - - assert result is None - - -@asyncio.coroutine -def test_load_auth_token_refresh_needed_timeout(cloud_hass, mock_read, - aioclient_mock): - """Test loading authentication with refresh timing out.""" - mock_read.return_value = MOCK_AUTH - aioclient_mock.get(url('account.json'), status=403) - aioclient_mock.post(url('o/token/'), exc=asyncio.TimeoutError) - - result = yield from cloud_api.async_load_auth(cloud_hass) - - assert result is None - - -@asyncio.coroutine -def test_load_auth_token_refresh_needed_succeeds(cloud_hass, mock_read, - aioclient_mock): - """Test loading authentication with refresh timing out.""" - mock_read.return_value = MOCK_AUTH - aioclient_mock.get(url('account.json'), status=403) - - with patch.object(cloud_api.Cloud, 'async_refresh_access_token', - return_value=mock_coro(True)) as mock_refresh: - result = yield from cloud_api.async_load_auth(cloud_hass) - - assert result is None - assert len(mock_refresh.mock_calls) == 1 - - -@asyncio.coroutine -def test_load_auth_token(cloud_hass, mock_read, aioclient_mock): - """Test loading authentication with refresh timing out.""" - mock_read.return_value = MOCK_AUTH - aioclient_mock.get(url('account.json'), json={ - 'first_name': 'Paulus', - 'last_name': 'Schoutsen' - }) - - result = yield from cloud_api.async_load_auth(cloud_hass) - - assert result is not None - assert result.account == { - 'first_name': 'Paulus', - 'last_name': 'Schoutsen' - } - assert result.auth == MOCK_AUTH - - -def test_cloud_properties(): - """Test Cloud class properties.""" - cloud = cloud_api.Cloud(None, MOCK_AUTH) - assert cloud.access_token == MOCK_AUTH['access_token'] - assert cloud.refresh_token == MOCK_AUTH['refresh_token'] - - -@asyncio.coroutine -def test_cloud_refresh_account_info(cloud_hass, aioclient_mock): - """Test refreshing account info.""" - aioclient_mock.get(url('account.json'), json={ - 'first_name': 'Paulus', - 'last_name': 'Schoutsen' - }) - cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) - assert cloud.account is None - result = yield from cloud.async_refresh_account_info() - assert result - assert cloud.account == { - 'first_name': 'Paulus', - 'last_name': 'Schoutsen' - } - - -@asyncio.coroutine -def test_cloud_refresh_account_info_500(cloud_hass, aioclient_mock): - """Test refreshing account info and getting 500.""" - aioclient_mock.get(url('account.json'), status=500) - cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) - assert cloud.account is None - result = yield from cloud.async_refresh_account_info() - assert not result - assert cloud.account is None - - -@asyncio.coroutine -def test_cloud_refresh_token(cloud_hass, aioclient_mock, mock_write): - """Test refreshing access token.""" - aioclient_mock.post(url('o/token/'), json={ - 'access_token': 'refreshed', - 'expires_in': 10 - }) - now = dt_util.utcnow() - cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) - with patch('homeassistant.components.cloud.cloud_api.utcnow', - return_value=now): - result = yield from cloud.async_refresh_access_token() - assert result - assert cloud.auth == { - 'access_token': 'refreshed', - 'expires_in': 10, - 'expires_at': (now + timedelta(seconds=10)).isoformat() - } - assert len(mock_write.mock_calls) == 1 - write_hass, write_data = mock_write.mock_calls[0][1] - assert write_hass is cloud_hass - assert write_data == cloud.auth - - -@asyncio.coroutine -def test_cloud_refresh_token_unknown_error(cloud_hass, aioclient_mock, - mock_write): - """Test refreshing access token.""" - aioclient_mock.post(url('o/token/'), status=500) - cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) - result = yield from cloud.async_refresh_access_token() - assert not result - assert cloud.auth == MOCK_AUTH - assert len(mock_write.mock_calls) == 0 - - -@asyncio.coroutine -def test_cloud_revoke_token(cloud_hass, aioclient_mock, mock_write): - """Test revoking access token.""" - aioclient_mock.post(url('o/revoke_token/')) - cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) - yield from cloud.async_revoke_access_token() - assert cloud.auth is None - assert len(mock_write.mock_calls) == 1 - write_hass, write_data = mock_write.mock_calls[0][1] - assert write_hass is cloud_hass - assert write_data is None - - -@asyncio.coroutine -def test_cloud_revoke_token_invalid_client_creds(cloud_hass, aioclient_mock, - mock_write): - """Test revoking access token with invalid client credentials.""" - aioclient_mock.post(url('o/revoke_token/'), status=401) - cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) - with pytest.raises(cloud_api.UnknownError): - yield from cloud.async_revoke_access_token() - assert cloud.auth is not None - assert len(mock_write.mock_calls) == 0 - - -@asyncio.coroutine -def test_cloud_revoke_token_request_error(cloud_hass, aioclient_mock, - mock_write): - """Test revoking access token with invalid client credentials.""" - aioclient_mock.post(url('o/revoke_token/'), exc=aiohttp.ClientError) - cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) - with pytest.raises(cloud_api.UnknownError): - yield from cloud.async_revoke_access_token() - assert cloud.auth is not None - assert len(mock_write.mock_calls) == 0 - - -@asyncio.coroutine -def test_cloud_request(cloud_hass, aioclient_mock): - """Test making request to the cloud.""" - aioclient_mock.post(url('some_endpoint'), json={'hello': 'world'}) - cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) - request = yield from cloud.async_request('post', 'some_endpoint') - assert request.status == 200 - data = yield from request.json() - assert data == {'hello': 'world'} - - -@asyncio.coroutine -def test_cloud_request_requiring_refresh_fail(cloud_hass, aioclient_mock): - """Test making request to the cloud.""" - aioclient_mock.post(url('some_endpoint'), status=403) - cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) - with patch.object(cloud_api.Cloud, 'async_refresh_access_token', - return_value=mock_coro(False)) as mock_refresh: - request = yield from cloud.async_request('post', 'some_endpoint') - assert request.status == 403 - assert len(mock_refresh.mock_calls) == 1 - - -@asyncio.coroutine -def test_cloud_request_requiring_refresh_success(cloud_hass, aioclient_mock): - """Test making request to the cloud.""" - aioclient_mock.post(url('some_endpoint'), status=403) - cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) - with patch.object(cloud_api.Cloud, 'async_refresh_access_token', - return_value=mock_coro(True)) as mock_refresh, \ - patch.object(cloud_api.Cloud, 'async_refresh_account_info', - return_value=mock_coro()) as mock_account_info: - request = yield from cloud.async_request('post', 'some_endpoint') - assert request.status == 403 - assert len(mock_refresh.mock_calls) == 1 - assert len(mock_account_info.mock_calls) == 1 diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 99e73461bc153e..fc9b3cce864419 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -5,9 +5,7 @@ import pytest from homeassistant.bootstrap import async_setup_component -from homeassistant.components.cloud import DOMAIN, cloud_api - -from tests.common import mock_coro +from homeassistant.components.cloud import DOMAIN, auth_api @pytest.fixture @@ -21,6 +19,20 @@ def cloud_client(hass, test_client): return hass.loop.run_until_complete(test_client(hass.http.app)) +@pytest.fixture +def mock_auth(cloud_client, hass): + """Fixture to mock authentication.""" + auth = hass.data[DOMAIN]['auth'] = MagicMock() + return auth + + +@pytest.fixture +def mock_cognito(): + """Mock warrant.""" + with patch('homeassistant.components.cloud.auth_api._cognito') as mock_cog: + yield mock_cog() + + @asyncio.coroutine def test_account_view_no_account(cloud_client): """Test fetching account if no account available.""" @@ -29,129 +41,300 @@ def test_account_view_no_account(cloud_client): @asyncio.coroutine -def test_account_view(hass, cloud_client): +def test_account_view(mock_auth, cloud_client): """Test fetching account if no account available.""" - cloud = MagicMock(account={'test': 'account'}) - hass.data[DOMAIN]['cloud'] = cloud + mock_auth.account = MagicMock(email='hello@home-assistant.io') req = yield from cloud_client.get('/api/cloud/account') assert req.status == 200 result = yield from req.json() - assert result == {'test': 'account'} + assert result == {'email': 'hello@home-assistant.io'} @asyncio.coroutine -def test_login_view(hass, cloud_client): +def test_login_view(mock_auth, cloud_client): """Test logging in.""" - cloud = MagicMock(account={'test': 'account'}) - cloud.async_refresh_account_info.return_value = mock_coro(None) - - with patch.object(cloud_api, 'async_login', - MagicMock(return_value=mock_coro(cloud))): - req = yield from cloud_client.post('/api/cloud/login', json={ - 'username': 'my_username', - 'password': 'my_password' - }) + mock_auth.account = MagicMock(email='hello@home-assistant.io') + req = yield from cloud_client.post('/api/cloud/login', json={ + 'email': 'my_username', + 'password': 'my_password' + }) assert req.status == 200 - result = yield from req.json() - assert result == {'test': 'account'} - assert hass.data[DOMAIN]['cloud'] is cloud + assert result == {'email': 'hello@home-assistant.io'} + assert len(mock_auth.login.mock_calls) == 1 + result_user, result_pass = mock_auth.login.mock_calls[0][1] + assert result_user == 'my_username' + assert result_pass == 'my_password' @asyncio.coroutine -def test_login_view_invalid_json(hass, cloud_client): +def test_login_view_invalid_json(mock_auth, cloud_client): """Try logging in with invalid JSON.""" req = yield from cloud_client.post('/api/cloud/login', data='Not JSON') assert req.status == 400 - assert 'cloud' not in hass.data[DOMAIN] + assert len(mock_auth.mock_calls) == 0 @asyncio.coroutine -def test_login_view_invalid_schema(hass, cloud_client): +def test_login_view_invalid_schema(mock_auth, cloud_client): """Try logging in with invalid schema.""" req = yield from cloud_client.post('/api/cloud/login', json={ 'invalid': 'schema' }) assert req.status == 400 - assert 'cloud' not in hass.data[DOMAIN] + assert len(mock_auth.mock_calls) == 0 @asyncio.coroutine -def test_login_view_request_timeout(hass, cloud_client): +def test_login_view_request_timeout(mock_auth, cloud_client): """Test request timeout while trying to log in.""" - with patch.object(cloud_api, 'async_login', - MagicMock(side_effect=asyncio.TimeoutError)): - req = yield from cloud_client.post('/api/cloud/login', json={ - 'username': 'my_username', - 'password': 'my_password' - }) + mock_auth.login.side_effect = asyncio.TimeoutError + req = yield from cloud_client.post('/api/cloud/login', json={ + 'email': 'my_username', + 'password': 'my_password' + }) assert req.status == 502 - assert 'cloud' not in hass.data[DOMAIN] @asyncio.coroutine -def test_login_view_invalid_credentials(hass, cloud_client): +def test_login_view_invalid_credentials(mock_auth, cloud_client): """Test logging in with invalid credentials.""" - with patch.object(cloud_api, 'async_login', - MagicMock(side_effect=cloud_api.Unauthenticated)): - req = yield from cloud_client.post('/api/cloud/login', json={ - 'username': 'my_username', - 'password': 'my_password' - }) + mock_auth.login.side_effect = auth_api.Unauthenticated + req = yield from cloud_client.post('/api/cloud/login', json={ + 'email': 'my_username', + 'password': 'my_password' + }) assert req.status == 401 - assert 'cloud' not in hass.data[DOMAIN] @asyncio.coroutine -def test_login_view_unknown_error(hass, cloud_client): +def test_login_view_unknown_error(mock_auth, cloud_client): """Test unknown error while logging in.""" - with patch.object(cloud_api, 'async_login', - MagicMock(side_effect=cloud_api.UnknownError)): - req = yield from cloud_client.post('/api/cloud/login', json={ - 'username': 'my_username', - 'password': 'my_password' - }) + mock_auth.login.side_effect = auth_api.UnknownError + req = yield from cloud_client.post('/api/cloud/login', json={ + 'email': 'my_username', + 'password': 'my_password' + }) - assert req.status == 500 - assert 'cloud' not in hass.data[DOMAIN] + assert req.status == 502 @asyncio.coroutine -def test_logout_view(hass, cloud_client): +def test_logout_view(mock_auth, cloud_client): """Test logging out.""" - cloud = MagicMock() - cloud.async_revoke_access_token.return_value = mock_coro(None) - hass.data[DOMAIN]['cloud'] = cloud - req = yield from cloud_client.post('/api/cloud/logout') assert req.status == 200 data = yield from req.json() - assert data == {'result': 'ok'} - assert 'cloud' not in hass.data[DOMAIN] + assert data == {'message': 'ok'} + assert len(mock_auth.logout.mock_calls) == 1 @asyncio.coroutine -def test_logout_view_request_timeout(hass, cloud_client): +def test_logout_view_request_timeout(mock_auth, cloud_client): """Test timeout while logging out.""" - cloud = MagicMock() - cloud.async_revoke_access_token.side_effect = asyncio.TimeoutError - hass.data[DOMAIN]['cloud'] = cloud + mock_auth.logout.side_effect = asyncio.TimeoutError + req = yield from cloud_client.post('/api/cloud/logout') + assert req.status == 502 + +@asyncio.coroutine +def test_logout_view_unknown_error(mock_auth, cloud_client): + """Test unknown error while loggin out.""" + mock_auth.logout.side_effect = auth_api.UnknownError req = yield from cloud_client.post('/api/cloud/logout') assert req.status == 502 - assert 'cloud' in hass.data[DOMAIN] @asyncio.coroutine -def test_logout_view_unknown_error(hass, cloud_client): +def test_register_view(mock_cognito, cloud_client): + """Test logging out.""" + req = yield from cloud_client.post('/api/cloud/register', json={ + 'email': 'hello@bla.com', + 'password': 'falcon42' + }) + assert req.status == 200 + assert len(mock_cognito.register.mock_calls) == 1 + result_email, result_pass = mock_cognito.register.mock_calls[0][1] + assert result_email == 'hello@bla.com' + assert result_pass == 'falcon42' + + +@asyncio.coroutine +def test_register_view_bad_data(mock_cognito, cloud_client): + """Test logging out.""" + req = yield from cloud_client.post('/api/cloud/register', json={ + 'email': 'hello@bla.com', + 'not_password': 'falcon' + }) + assert req.status == 400 + assert len(mock_cognito.logout.mock_calls) == 0 + + +@asyncio.coroutine +def test_register_view_request_timeout(mock_cognito, cloud_client): + """Test timeout while logging out.""" + mock_cognito.register.side_effect = asyncio.TimeoutError + req = yield from cloud_client.post('/api/cloud/register', json={ + 'email': 'hello@bla.com', + 'password': 'falcon42' + }) + assert req.status == 502 + + +@asyncio.coroutine +def test_register_view_unknown_error(mock_cognito, cloud_client): """Test unknown error while loggin out.""" - cloud = MagicMock() - cloud.async_revoke_access_token.side_effect = cloud_api.UnknownError - hass.data[DOMAIN]['cloud'] = cloud + mock_cognito.register.side_effect = auth_api.UnknownError + req = yield from cloud_client.post('/api/cloud/register', json={ + 'email': 'hello@bla.com', + 'password': 'falcon42' + }) + assert req.status == 502 - req = yield from cloud_client.post('/api/cloud/logout') + +@asyncio.coroutine +def test_confirm_register_view(mock_cognito, cloud_client): + """Test logging out.""" + req = yield from cloud_client.post('/api/cloud/confirm_register', json={ + 'email': 'hello@bla.com', + 'confirmation_code': '123456' + }) + assert req.status == 200 + assert len(mock_cognito.confirm_sign_up.mock_calls) == 1 + result_code, result_email = mock_cognito.confirm_sign_up.mock_calls[0][1] + assert result_email == 'hello@bla.com' + assert result_code == '123456' + + +@asyncio.coroutine +def test_confirm_register_view_bad_data(mock_cognito, cloud_client): + """Test logging out.""" + req = yield from cloud_client.post('/api/cloud/confirm_register', json={ + 'email': 'hello@bla.com', + 'not_confirmation_code': '123456' + }) + assert req.status == 400 + assert len(mock_cognito.confirm_sign_up.mock_calls) == 0 + + +@asyncio.coroutine +def test_confirm_register_view_request_timeout(mock_cognito, cloud_client): + """Test timeout while logging out.""" + mock_cognito.confirm_sign_up.side_effect = asyncio.TimeoutError + req = yield from cloud_client.post('/api/cloud/confirm_register', json={ + 'email': 'hello@bla.com', + 'confirmation_code': '123456' + }) + assert req.status == 502 + + +@asyncio.coroutine +def test_confirm_register_view_unknown_error(mock_cognito, cloud_client): + """Test unknown error while loggin out.""" + mock_cognito.confirm_sign_up.side_effect = auth_api.UnknownError + req = yield from cloud_client.post('/api/cloud/confirm_register', json={ + 'email': 'hello@bla.com', + 'confirmation_code': '123456' + }) + assert req.status == 502 + + +@asyncio.coroutine +def test_forgot_password_view(mock_cognito, cloud_client): + """Test logging out.""" + req = yield from cloud_client.post('/api/cloud/forgot_password', json={ + 'email': 'hello@bla.com', + }) + assert req.status == 200 + assert len(mock_cognito.initiate_forgot_password.mock_calls) == 1 + + +@asyncio.coroutine +def test_forgot_password_view_bad_data(mock_cognito, cloud_client): + """Test logging out.""" + req = yield from cloud_client.post('/api/cloud/forgot_password', json={ + 'not_email': 'hello@bla.com', + }) + assert req.status == 400 + assert len(mock_cognito.initiate_forgot_password.mock_calls) == 0 + + +@asyncio.coroutine +def test_forgot_password_view_request_timeout(mock_cognito, cloud_client): + """Test timeout while logging out.""" + mock_cognito.initiate_forgot_password.side_effect = asyncio.TimeoutError + req = yield from cloud_client.post('/api/cloud/forgot_password', json={ + 'email': 'hello@bla.com', + }) + assert req.status == 502 + + +@asyncio.coroutine +def test_forgot_password_view_unknown_error(mock_cognito, cloud_client): + """Test unknown error while loggin out.""" + mock_cognito.initiate_forgot_password.side_effect = auth_api.UnknownError + req = yield from cloud_client.post('/api/cloud/forgot_password', json={ + 'email': 'hello@bla.com', + }) + assert req.status == 502 + + +@asyncio.coroutine +def test_confirm_forgot_password_view(mock_cognito, cloud_client): + """Test logging out.""" + req = yield from cloud_client.post( + '/api/cloud/confirm_forgot_password', json={ + 'email': 'hello@bla.com', + 'confirmation_code': '123456', + 'new_password': 'hello2', + }) + assert req.status == 200 + assert len(mock_cognito.confirm_forgot_password.mock_calls) == 1 + result_code, result_new_password = \ + mock_cognito.confirm_forgot_password.mock_calls[0][1] + assert result_code == '123456' + assert result_new_password == 'hello2' + + +@asyncio.coroutine +def test_confirm_forgot_password_view_bad_data(mock_cognito, cloud_client): + """Test logging out.""" + req = yield from cloud_client.post( + '/api/cloud/confirm_forgot_password', json={ + 'email': 'hello@bla.com', + 'not_confirmation_code': '123456', + 'new_password': 'hello2', + }) + assert req.status == 400 + assert len(mock_cognito.confirm_forgot_password.mock_calls) == 0 + + +@asyncio.coroutine +def test_confirm_forgot_password_view_request_timeout(mock_cognito, + cloud_client): + """Test timeout while logging out.""" + mock_cognito.confirm_forgot_password.side_effect = asyncio.TimeoutError + req = yield from cloud_client.post( + '/api/cloud/confirm_forgot_password', json={ + 'email': 'hello@bla.com', + 'confirmation_code': '123456', + 'new_password': 'hello2', + }) + assert req.status == 502 + + +@asyncio.coroutine +def test_confirm_forgot_password_view_unknown_error(mock_cognito, + cloud_client): + """Test unknown error while loggin out.""" + mock_cognito.confirm_forgot_password.side_effect = auth_api.UnknownError + req = yield from cloud_client.post( + '/api/cloud/confirm_forgot_password', json={ + 'email': 'hello@bla.com', + 'confirmation_code': '123456', + 'new_password': 'hello2', + }) assert req.status == 502 - assert 'cloud' in hass.data[DOMAIN] diff --git a/tests/components/config/test_zwave.py b/tests/components/config/test_zwave.py index ecf4d6ecb2978b..fc359dc7ff7241 100644 --- a/tests/components/config/test_zwave.py +++ b/tests/components/config/test_zwave.py @@ -143,7 +143,7 @@ def test_get_values(hass, test_client): node = MockNode(node_id=1) value = MockValue(value_id=123456, node=node, label='Test Label', - instance=1, index=2) + instance=1, index=2, poll_intensity=4) values = MockEntityValues(primary=value) node2 = MockNode(node_id=2) value2 = MockValue(value_id=234567, node=node2, label='Test Label 2') @@ -162,6 +162,7 @@ def test_get_values(hass, test_client): 'label': 'Test Label', 'instance': 1, 'index': 2, + 'poll_intensity': 4, } } diff --git a/tests/components/light/test_template.py b/tests/components/light/test_template.py index 6564d66299baeb..0e741cc7ee11f7 100644 --- a/tests/components/light/test_template.py +++ b/tests/components/light/test_template.py @@ -590,6 +590,44 @@ def test_level_template(self): assert state.attributes.get('brightness') == '42' + def test_friendly_name(self): + """Test the accessibility of the friendly_name attribute.""" + with assert_setup_component(1, 'light'): + assert setup.setup_component(self.hass, 'light', { + 'light': { + 'platform': 'template', + 'lights': { + 'test_template_light': { + 'friendly_name': 'Template light', + 'value_template': "{{ 1 == 1 }}", + 'turn_on': { + 'service': 'light.turn_on', + 'entity_id': 'light.test_state' + }, + 'turn_off': { + 'service': 'light.turn_off', + 'entity_id': 'light.test_state' + }, + 'set_level': { + 'service': 'light.turn_on', + 'data_template': { + 'entity_id': 'light.test_state', + 'brightness': '{{brightness}}' + } + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + state = self.hass.states.get('light.test_template_light') + assert state is not None + + assert state.attributes.get('friendly_name') == 'Template light' + @asyncio.coroutine def test_restore_state(hass): diff --git a/tests/components/switch/test_mqtt.py b/tests/components/switch/test_mqtt.py index cc97fe1c9c365f..21ab1dd31f2aec 100644 --- a/tests/components/switch/test_mqtt.py +++ b/tests/components/switch/test_mqtt.py @@ -9,7 +9,7 @@ mock_mqtt_component, fire_mqtt_message, get_test_home_assistant) -class TestSensorMQTT(unittest.TestCase): +class TestSwitchMQTT(unittest.TestCase): """Test the MQTT switch.""" def setUp(self): # pylint: disable=invalid-name diff --git a/tests/components/test_mqtt_statestream.py b/tests/components/test_mqtt_statestream.py new file mode 100644 index 00000000000000..73e2dbd1ac4be9 --- /dev/null +++ b/tests/components/test_mqtt_statestream.py @@ -0,0 +1,65 @@ +"""The tests for the MQTT statestream component.""" +from unittest.mock import patch + +from homeassistant.setup import setup_component +import homeassistant.components.mqtt_statestream as statestream +from homeassistant.core import State + +from tests.common import ( + get_test_home_assistant, + mock_mqtt_component, + mock_state_change_event +) + + +class TestMqttStateStream(object): + """Test the MQTT statestream module.""" + + def setup_method(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.mock_mqtt = mock_mqtt_component(self.hass) + + def teardown_method(self): + """Stop everything that was started.""" + self.hass.stop() + + def add_statestream(self, base_topic=None): + """Add a mqtt_statestream component.""" + config = {} + if base_topic: + config['base_topic'] = base_topic + return setup_component(self.hass, statestream.DOMAIN, { + statestream.DOMAIN: config}) + + def test_fails_with_no_base(self): + """Setup should fail if no base_topic is set.""" + assert self.add_statestream() is False + + def test_setup_succeeds(self): + """"Test the success of the setup with a valid base_topic.""" + assert self.add_statestream(base_topic='pub') + + @patch('homeassistant.components.mqtt.async_publish') + @patch('homeassistant.core.dt_util.utcnow') + def test_state_changed_event_sends_message(self, mock_utcnow, mock_pub): + """"Test the sending of a new message if event changed.""" + e_id = 'fake.entity' + base_topic = 'pub' + + # Add the statestream component for publishing state updates + assert self.add_statestream(base_topic=base_topic) + self.hass.block_till_done() + + # Reset the mock because it will have already gotten calls for the + # mqtt_statestream state change on initialization, etc. + mock_pub.reset_mock() + + # Set a state of an entity + mock_state_change_event(self.hass, State(e_id, 'on')) + self.hass.block_till_done() + + # Make sure 'on' was published to pub/fake/entity + mock_pub.assert_called_with(self.hass, 'pub/fake/entity', 'on', 1, + True) + assert mock_pub.called diff --git a/tests/components/test_python_script.py b/tests/components/test_python_script.py index 62c1b67eba994f..3ff32cc312a77b 100644 --- a/tests/components/test_python_script.py +++ b/tests/components/test_python_script.py @@ -180,3 +180,26 @@ def test_iterating(hass): assert hass.states.is_state('hello.1', 'world') assert hass.states.is_state('hello.2', 'world') + + +@asyncio.coroutine +def test_unpacking_sequence(hass, caplog): + """Test compile error logs error.""" + caplog.set_level(logging.ERROR) + source = """ +a,b = (1,2) +ab_list = [(a,b) for a,b in [(1, 2), (3, 4)]] +hass.states.set('hello.a', a) +hass.states.set('hello.b', b) +hass.states.set('hello.ab_list', '{}'.format(ab_list)) +""" + + hass.async_add_job(execute, hass, 'test.py', source, {}) + yield from hass.async_block_till_done() + + assert hass.states.is_state('hello.a', '1') + assert hass.states.is_state('hello.b', '2') + assert hass.states.is_state('hello.ab_list', '[(1, 2), (3, 4)]') + + # No errors logged = good + assert caplog.text == '' diff --git a/tests/components/zwave/test_init.py b/tests/components/zwave/test_init.py index 2fa4dd0b92964d..1e759949a4681e 100644 --- a/tests/components/zwave/test_init.py +++ b/tests/components/zwave/test_init.py @@ -576,7 +576,6 @@ def test_entity_existing_values(self, discovery, get_platform): assert args[3] == {const.DISCOVERY_DEVICE: id(values)} assert args[4] == self.zwave_config assert not self.primary.enable_poll.called - assert self.primary.disable_poll.called @patch.object(zwave, 'get_platform') @patch.object(zwave, 'discovery') @@ -742,7 +741,6 @@ def test_config_polling_intensity(self, discovery, get_platform): assert self.primary.enable_poll.called assert len(self.primary.enable_poll.mock_calls) == 1 assert self.primary.enable_poll.mock_calls[0][1][0] == 123 - assert not self.primary.disable_poll.called class TestZwave(unittest.TestCase): @@ -887,6 +885,85 @@ def test_rename_value(self): assert value.label == "New Label" + def test_set_poll_intensity_enable(self): + """Test zwave set_poll_intensity service, succsessful set.""" + node = MockNode(node_id=14) + value = MockValue(index=12, value_id=123456, poll_intensity=0) + node.values = {123456: value} + self.zwave_network.nodes = {11: node} + + assert value.poll_intensity == 0 + self.hass.services.call('zwave', 'set_poll_intensity', { + const.ATTR_NODE_ID: 11, + const.ATTR_VALUE_ID: 123456, + const.ATTR_POLL_INTENSITY: 4, + }) + self.hass.block_till_done() + + enable_poll = value.enable_poll + assert value.enable_poll.called + assert len(enable_poll.mock_calls) == 2 + assert enable_poll.mock_calls[0][1][0] == 4 + + def test_set_poll_intensity_enable_failed(self): + """Test zwave set_poll_intensity service, failed set.""" + node = MockNode(node_id=14) + value = MockValue(index=12, value_id=123456, poll_intensity=0) + value.enable_poll.return_value = False + node.values = {123456: value} + self.zwave_network.nodes = {11: node} + + assert value.poll_intensity == 0 + self.hass.services.call('zwave', 'set_poll_intensity', { + const.ATTR_NODE_ID: 11, + const.ATTR_VALUE_ID: 123456, + const.ATTR_POLL_INTENSITY: 4, + }) + self.hass.block_till_done() + + enable_poll = value.enable_poll + assert value.enable_poll.called + assert len(enable_poll.mock_calls) == 1 + + def test_set_poll_intensity_disable(self): + """Test zwave set_poll_intensity service, successful disable.""" + node = MockNode(node_id=14) + value = MockValue(index=12, value_id=123456, poll_intensity=4) + node.values = {123456: value} + self.zwave_network.nodes = {11: node} + + assert value.poll_intensity == 4 + self.hass.services.call('zwave', 'set_poll_intensity', { + const.ATTR_NODE_ID: 11, + const.ATTR_VALUE_ID: 123456, + const.ATTR_POLL_INTENSITY: 0, + }) + self.hass.block_till_done() + + disable_poll = value.disable_poll + assert value.disable_poll.called + assert len(disable_poll.mock_calls) == 2 + + def test_set_poll_intensity_disable_failed(self): + """Test zwave set_poll_intensity service, failed disable.""" + node = MockNode(node_id=14) + value = MockValue(index=12, value_id=123456, poll_intensity=4) + value.disable_poll.return_value = False + node.values = {123456: value} + self.zwave_network.nodes = {11: node} + + assert value.poll_intensity == 4 + self.hass.services.call('zwave', 'set_poll_intensity', { + const.ATTR_NODE_ID: 11, + const.ATTR_VALUE_ID: 123456, + const.ATTR_POLL_INTENSITY: 0, + }) + self.hass.block_till_done() + + disable_poll = value.disable_poll + assert value.disable_poll.called + assert len(disable_poll.mock_calls) == 1 + def test_remove_failed_node(self): """Test zwave remove_failed_node service.""" self.hass.services.call('zwave', 'remove_failed_node', { diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 644c88948746e0..cf73e066072593 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -191,3 +191,25 @@ def async_update(): assert mock_call().cancel.called assert update_call + + +@asyncio.coroutine +def test_async_schedule_update_ha_state(hass): + """Warn we log when entity update takes a long time and trow exception.""" + update_call = False + + @asyncio.coroutine + def async_update(): + """Mock async update.""" + nonlocal update_call + update_call = True + + mock_entity = entity.Entity() + mock_entity.hass = hass + mock_entity.entity_id = 'comp_test.test_entity' + mock_entity.async_update = async_update + + mock_entity.async_schedule_update_ha_state(True) + yield from hass.async_block_till_done() + + assert update_call is True