diff --git a/.coveragerc b/.coveragerc index 4b19519038f..bd99e3ac2e2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -29,6 +29,9 @@ omit = homeassistant/components/arduino.py homeassistant/components/*/arduino.py + homeassistant/components/bmw_connected_drive.py + homeassistant/components/*/bmw_connected_drive.py + homeassistant/components/android_ip_webcam.py homeassistant/components/*/android_ip_webcam.py @@ -38,6 +41,9 @@ omit = homeassistant/components/asterisk_mbox.py homeassistant/components/*/asterisk_mbox.py + homeassistant/components/august.py + homeassistant/components/*/august.py + homeassistant/components/axis.py homeassistant/components/*/axis.py @@ -205,6 +211,9 @@ omit = homeassistant/components/skybell.py homeassistant/components/*/skybell.py + homeassistant/components/smappee.py + homeassistant/components/*/smappee.py + homeassistant/components/tado.py homeassistant/components/*/tado.py @@ -462,6 +471,7 @@ omit = homeassistant/components/media_player/vizio.py homeassistant/components/media_player/vlc.py homeassistant/components/media_player/volumio.py + homeassistant/components/media_player/xiaomi_tv.py homeassistant/components/media_player/yamaha.py homeassistant/components/media_player/yamaha_musiccast.py homeassistant/components/media_player/ziggo_mediabox_xl.py @@ -551,8 +561,10 @@ omit = homeassistant/components/sensor/etherscan.py homeassistant/components/sensor/fastdotcom.py homeassistant/components/sensor/fedex.py + homeassistant/components/sensor/filesize.py homeassistant/components/sensor/fitbit.py homeassistant/components/sensor/fixer.py + homeassistant/components/sensor/folder.py homeassistant/components/sensor/fritzbox_callmonitor.py homeassistant/components/sensor/fritzbox_netmonitor.py homeassistant/components/sensor/gearbest.py @@ -617,6 +629,7 @@ omit = homeassistant/components/sensor/sochain.py homeassistant/components/sensor/sonarr.py homeassistant/components/sensor/speedtest.py + homeassistant/components/sensor/spotcrime.py homeassistant/components/sensor/steam_online.py homeassistant/components/sensor/supervisord.py homeassistant/components/sensor/swiss_hydrological_data.py diff --git a/.gitattributes b/.gitattributes index 214efef6e4d..caff2fc5c1f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,10 @@ # Ensure Docker script files uses LF to support Docker for Windows. -setup_docker_prereqs eol=lf -/virtualization/Docker/scripts/* eol=lf \ No newline at end of file +# Ensure "git config --global core.autocrlf input" before you clone +* text eol=lf +*.py whitespace=error + +*.ico binary +*.jpg binary +*.png binary +*.zip binary +*.mp3 binary diff --git a/CODEOWNERS b/CODEOWNERS old mode 100644 new mode 100755 index 6e088a84e5d..a5b5cfcb32c --- a/CODEOWNERS +++ b/CODEOWNERS @@ -43,6 +43,7 @@ homeassistant/components/hassio.py @home-assistant/hassio # Individual components homeassistant/components/alarm_control_panel/egardia.py @jeroenterheerdt +homeassistant/components/bmw_connected_drive.py @ChristianKuehnel homeassistant/components/camera/yi.py @bachya homeassistant/components/climate/ephember.py @ttroy50 homeassistant/components/climate/eq3btsmart.py @rytilahti @@ -54,7 +55,10 @@ homeassistant/components/history_graph.py @andrey-git homeassistant/components/light/tplink.py @rytilahti homeassistant/components/light/yeelight.py @rytilahti homeassistant/components/media_player/kodi.py @armills +homeassistant/components/media_player/mediaroom.py @dgomes homeassistant/components/media_player/monoprice.py @etsinko +homeassistant/components/media_player/sonos.py @amelchio +homeassistant/components/media_player/xiaomi_tv.py @fattdev homeassistant/components/media_player/yamaha_musiccast.py @jalmeroth homeassistant/components/plant.py @ChristianKuehnel homeassistant/components/sensor/airvisual.py @bachya @@ -63,6 +67,7 @@ homeassistant/components/sensor/irish_rail_transport.py @ttroy50 homeassistant/components/sensor/miflora.py @danielhiversen @ChristianKuehnel homeassistant/components/sensor/pollen.py @bachya homeassistant/components/sensor/sytadin.py @gautric +homeassistant/components/sensor/sql.py @dgomes homeassistant/components/sensor/tibber.py @danielhiversen homeassistant/components/sensor/waqi.py @andrey-git homeassistant/components/switch/rainmachine.py @bachya @@ -70,9 +75,11 @@ homeassistant/components/switch/tplink.py @rytilahti homeassistant/components/xiaomi_aqara.py @danielhiversen @syssi homeassistant/components/*/axis.py @kane610 +homeassistant/components/*/bmw_connected_drive.py @ChristianKuehnel homeassistant/components/*/broadlink.py @danielhiversen homeassistant/components/hive.py @Rendili @KJonline homeassistant/components/*/hive.py @Rendili @KJonline +homeassistant/components/homekit/* @cdce8p homeassistant/components/*/deconz.py @kane610 homeassistant/components/*/rfxtrx.py @danielhiversen homeassistant/components/velux.py @Julius2342 diff --git a/docs/source/conf.py b/docs/source/conf.py index 595c15717eb..b5428ede8fa 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -22,10 +22,23 @@ import os import inspect from homeassistant.const import __version__, __short_version__ -from setup import ( - PROJECT_NAME, PROJECT_LONG_DESCRIPTION, PROJECT_COPYRIGHT, PROJECT_AUTHOR, - PROJECT_GITHUB_USERNAME, PROJECT_GITHUB_REPOSITORY, GITHUB_PATH, - GITHUB_URL) + +PROJECT_NAME = 'Home Assistant' +PROJECT_PACKAGE_NAME = 'homeassistant' +PROJECT_AUTHOR = 'The Home Assistant Authors' +PROJECT_COPYRIGHT = ' 2013-2018, {}'.format(PROJECT_AUTHOR) +PROJECT_LONG_DESCRIPTION = ('Home Assistant is an open-source ' + 'home automation platform running on Python 3. ' + 'Track and control all devices at home and ' + 'automate control. ' + 'Installation in less than a minute.') +PROJECT_GITHUB_USERNAME = 'home-assistant' +PROJECT_GITHUB_REPOSITORY = 'home-assistant' + +GITHUB_PATH = '{}/{}'.format( + PROJECT_GITHUB_USERNAME, PROJECT_GITHUB_REPOSITORY) +GITHUB_URL = 'https://github.com/{}'.format(GITHUB_PATH) + sys.path.insert(0, os.path.abspath('_ext')) sys.path.insert(0, os.path.abspath('../homeassistant')) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 64ad88f8c8b..4971cbccc9c 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -12,7 +12,8 @@ from typing import Any, Optional, Dict import voluptuous as vol from homeassistant import ( - core, config as conf_util, loader, components as core_components) + core, config as conf_util, config_entries, loader, + components as core_components) from homeassistant.components import persistent_notification from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE from homeassistant.setup import async_setup_component @@ -35,13 +36,13 @@ FIRST_INIT_COMPONENT = set(( def from_config_dict(config: Dict[str, Any], - hass: Optional[core.HomeAssistant]=None, - config_dir: Optional[str]=None, - enable_log: bool=True, - verbose: bool=False, - skip_pip: bool=False, - log_rotate_days: Any=None, - log_file: Any=None) \ + hass: Optional[core.HomeAssistant] = None, + config_dir: Optional[str] = None, + enable_log: bool = True, + verbose: bool = False, + skip_pip: bool = False, + log_rotate_days: Any = None, + log_file: Any = None) \ -> Optional[core.HomeAssistant]: """Try to configure Home Assistant from a configuration dictionary. @@ -68,12 +69,12 @@ def from_config_dict(config: Dict[str, Any], @asyncio.coroutine def async_from_config_dict(config: Dict[str, Any], hass: core.HomeAssistant, - config_dir: Optional[str]=None, - enable_log: bool=True, - verbose: bool=False, - skip_pip: bool=False, - log_rotate_days: Any=None, - log_file: Any=None) \ + config_dir: Optional[str] = None, + enable_log: bool = True, + verbose: bool = False, + skip_pip: bool = False, + log_rotate_days: Any = None, + log_file: Any = None) \ -> Optional[core.HomeAssistant]: """Try to configure Home Assistant from a configuration dictionary. @@ -123,9 +124,13 @@ def async_from_config_dict(config: Dict[str, Any], new_config[key] = value or {} config = new_config + hass.config_entries = config_entries.ConfigEntries(hass, config) + yield from hass.config_entries.async_load() + # Filter out the repeating and common config section [homeassistant] components = set(key.split(' ')[0] for key in config.keys() if key != core.DOMAIN) + components.update(hass.config_entries.async_domains()) # setup components # pylint: disable=not-an-iterable @@ -163,11 +168,11 @@ def async_from_config_dict(config: Dict[str, Any], 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_file: Any=None): + hass: Optional[core.HomeAssistant] = None, + verbose: bool = False, + skip_pip: bool = True, + 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, @@ -188,10 +193,10 @@ def from_config_file(config_path: str, @asyncio.coroutine def async_from_config_file(config_path: str, hass: core.HomeAssistant, - verbose: bool=False, - skip_pip: bool=True, - log_rotate_days: Any=None, - log_file: Any=None): + verbose: bool = False, + skip_pip: bool = True, + 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. @@ -219,7 +224,7 @@ def async_from_config_file(config_path: str, @core.callback -def async_enable_logging(hass: core.HomeAssistant, verbose: bool=False, +def async_enable_logging(hass: core.HomeAssistant, verbose: bool = False, log_rotate_days=None, log_file=None) -> None: """Set up the logging. diff --git a/homeassistant/components/__init__.py b/homeassistant/components/__init__.py index a1c6811afe7..6b306adad5b 100644 --- a/homeassistant/components/__init__.py +++ b/homeassistant/components/__init__.py @@ -15,6 +15,7 @@ import homeassistant.core as ha import homeassistant.config as conf_util from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.service import extract_entity_ids +from homeassistant.helpers import intent from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE, SERVICE_HOMEASSISTANT_STOP, SERVICE_HOMEASSISTANT_RESTART, @@ -154,6 +155,12 @@ def async_setup(hass, config): ha.DOMAIN, SERVICE_TURN_ON, async_handle_turn_service) hass.services.async_register( ha.DOMAIN, SERVICE_TOGGLE, async_handle_turn_service) + hass.helpers.intent.async_register(intent.ServiceIntentHandler( + intent.INTENT_TURN_ON, ha.DOMAIN, SERVICE_TURN_ON, "Turned on {}")) + hass.helpers.intent.async_register(intent.ServiceIntentHandler( + intent.INTENT_TURN_OFF, ha.DOMAIN, SERVICE_TURN_OFF, "Turned off {}")) + hass.helpers.intent.async_register(intent.ServiceIntentHandler( + intent.INTENT_TOGGLE, ha.DOMAIN, SERVICE_TOGGLE, "Toggled {}")) @asyncio.coroutine def async_handle_core_service(call): diff --git a/homeassistant/components/abode.py b/homeassistant/components/abode.py index cbfee2ae215..fde21a265b0 100644 --- a/homeassistant/components/abode.py +++ b/homeassistant/components/abode.py @@ -7,6 +7,7 @@ https://home-assistant.io/components/abode/ import asyncio import logging from functools import partial +from requests.exceptions import HTTPError, ConnectTimeout import voluptuous as vol @@ -17,7 +18,6 @@ from homeassistant.const import ( from homeassistant.helpers import config_validation as cv from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -from requests.exceptions import HTTPError, ConnectTimeout REQUIREMENTS = ['abodepy==0.12.2'] diff --git a/homeassistant/components/alarm_control_panel/canary.py b/homeassistant/components/alarm_control_panel/canary.py index fb5c4c37e8d..2e0e9994e10 100644 --- a/homeassistant/components/alarm_control_panel/canary.py +++ b/homeassistant/components/alarm_control_panel/canary.py @@ -59,8 +59,7 @@ class CanaryAlarm(AlarmControlPanel): return STATE_ALARM_ARMED_HOME elif mode.name == LOCATION_MODE_NIGHT: return STATE_ALARM_ARMED_NIGHT - else: - return None + return None @property def device_state_attributes(self): diff --git a/homeassistant/components/alarm_control_panel/manual.py b/homeassistant/components/alarm_control_panel/manual.py index 35b255d4b57..5beb5261607 100644 --- a/homeassistant/components/alarm_control_panel/manual.py +++ b/homeassistant/components/alarm_control_panel/manual.py @@ -172,9 +172,8 @@ class ManualAlarm(alarm.AlarmControlPanel): trigger_time) < dt_util.utcnow(): if self._disarm_after_trigger: return STATE_ALARM_DISARMED - else: - self._state = self._previous_state - return self._state + self._state = self._previous_state + return self._state if self._state in SUPPORTED_PENDING_STATES and \ self._within_pending_time(self._state): @@ -187,8 +186,7 @@ class ManualAlarm(alarm.AlarmControlPanel): """Get the current state.""" if self.state == STATE_ALARM_PENDING: return self._previous_state - else: - return self._state + return self._state def _pending_time(self, state): """Get the pending time.""" diff --git a/homeassistant/components/alarm_control_panel/manual_mqtt.py b/homeassistant/components/alarm_control_panel/manual_mqtt.py index ef12cbe365f..4b08ad67292 100644 --- a/homeassistant/components/alarm_control_panel/manual_mqtt.py +++ b/homeassistant/components/alarm_control_panel/manual_mqtt.py @@ -208,9 +208,8 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): trigger_time) < dt_util.utcnow(): if self._disarm_after_trigger: return STATE_ALARM_DISARMED - else: - self._state = self._previous_state - return self._state + self._state = self._previous_state + return self._state if self._state in SUPPORTED_PENDING_STATES and \ self._within_pending_time(self._state): @@ -223,8 +222,7 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): """Get the current state.""" if self.state == STATE_ALARM_PENDING: return self._previous_state - else: - return self._state + return self._state def _pending_time(self, state): """Get the pending time.""" diff --git a/homeassistant/components/alarm_control_panel/services.yaml b/homeassistant/components/alarm_control_panel/services.yaml index bfd38c902d0..72784c8178c 100644 --- a/homeassistant/components/alarm_control_panel/services.yaml +++ b/homeassistant/components/alarm_control_panel/services.yaml @@ -1,71 +1,71 @@ -# Describes the format for available alarm control panel services - -alarm_disarm: - description: Send the alarm the command for disarm. - fields: - entity_id: - description: Name of alarm control panel to disarm. - example: 'alarm_control_panel.downstairs' - code: - description: An optional code to disarm the alarm control panel with. - example: 1234 - -alarm_arm_home: - description: Send the alarm the command for arm home. - fields: - entity_id: - description: Name of alarm control panel to arm home. - example: 'alarm_control_panel.downstairs' - code: - description: An optional code to arm home the alarm control panel with. - example: 1234 - -alarm_arm_away: - description: Send the alarm the command for arm away. - fields: - entity_id: - description: Name of alarm control panel to arm away. - example: 'alarm_control_panel.downstairs' - code: - description: An optional code to arm away the alarm control panel with. - example: 1234 - -alarm_arm_night: - description: Send the alarm the command for arm night. - fields: - entity_id: - description: Name of alarm control panel to arm night. - example: 'alarm_control_panel.downstairs' - code: - description: An optional code to arm night the alarm control panel with. - example: 1234 - -alarm_trigger: - description: Send the alarm the command for trigger. - fields: - entity_id: - description: Name of alarm control panel to trigger. - example: 'alarm_control_panel.downstairs' - code: - description: An optional code to trigger the alarm control panel with. - example: 1234 - -envisalink_alarm_keypress: - description: Send custom keypresses to the alarm. - fields: - entity_id: - description: Name of the alarm control panel to trigger. - example: 'alarm_control_panel.downstairs' - keypress: - description: 'String to send to the alarm panel (1-6 characters).' - example: '*71' - -alarmdecoder_alarm_toggle_chime: - description: Send the alarm the toggle chime command. - fields: - entity_id: - description: Name of the alarm control panel to trigger. - example: 'alarm_control_panel.downstairs' - code: - description: A required code to toggle the alarm control panel chime with. - example: 1234 +# Describes the format for available alarm control panel services + +alarm_disarm: + description: Send the alarm the command for disarm. + fields: + entity_id: + description: Name of alarm control panel to disarm. + example: 'alarm_control_panel.downstairs' + code: + description: An optional code to disarm the alarm control panel with. + example: 1234 + +alarm_arm_home: + description: Send the alarm the command for arm home. + fields: + entity_id: + description: Name of alarm control panel to arm home. + example: 'alarm_control_panel.downstairs' + code: + description: An optional code to arm home the alarm control panel with. + example: 1234 + +alarm_arm_away: + description: Send the alarm the command for arm away. + fields: + entity_id: + description: Name of alarm control panel to arm away. + example: 'alarm_control_panel.downstairs' + code: + description: An optional code to arm away the alarm control panel with. + example: 1234 + +alarm_arm_night: + description: Send the alarm the command for arm night. + fields: + entity_id: + description: Name of alarm control panel to arm night. + example: 'alarm_control_panel.downstairs' + code: + description: An optional code to arm night the alarm control panel with. + example: 1234 + +alarm_trigger: + description: Send the alarm the command for trigger. + fields: + entity_id: + description: Name of alarm control panel to trigger. + example: 'alarm_control_panel.downstairs' + code: + description: An optional code to trigger the alarm control panel with. + example: 1234 + +envisalink_alarm_keypress: + description: Send custom keypresses to the alarm. + fields: + entity_id: + description: Name of the alarm control panel to trigger. + example: 'alarm_control_panel.downstairs' + keypress: + description: 'String to send to the alarm panel (1-6 characters).' + example: '*71' + +alarmdecoder_alarm_toggle_chime: + description: Send the alarm the toggle chime command. + fields: + entity_id: + description: Name of the alarm control panel to trigger. + example: 'alarm_control_panel.downstairs' + code: + description: A required code to toggle the alarm control panel chime with. + example: 1234 diff --git a/homeassistant/components/alert.py b/homeassistant/components/alert.py index eb941e22877..9d47e4bd322 100644 --- a/homeassistant/components/alert.py +++ b/homeassistant/components/alert.py @@ -34,7 +34,7 @@ DEFAULT_SKIP_FIRST = False ALERT_SCHEMA = vol.Schema({ vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_DONE_MESSAGE, default=None): cv.string, + vol.Optional(CONF_DONE_MESSAGE): cv.string, vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Required(CONF_STATE, default=STATE_ON): cv.string, vol.Required(CONF_REPEAT): vol.All(cv.ensure_list, [vol.Coerce(float)]), @@ -121,7 +121,7 @@ def async_setup(hass, config): # Setup alerts for entity_id, alert in alerts.items(): entity = Alert(hass, entity_id, - alert[CONF_NAME], alert[CONF_DONE_MESSAGE], + alert[CONF_NAME], alert.get(CONF_DONE_MESSAGE), alert[CONF_ENTITY_ID], alert[CONF_STATE], alert[CONF_REPEAT], alert[CONF_SKIP_FIRST], alert[CONF_NOTIFIERS], alert[CONF_CAN_ACK]) diff --git a/homeassistant/components/alexa/__init__.py b/homeassistant/components/alexa/__init__.py index b683f5cfc7c..d120270650f 100644 --- a/homeassistant/components/alexa/__init__.py +++ b/homeassistant/components/alexa/__init__.py @@ -31,10 +31,7 @@ ALEXA_ENTITY_SCHEMA = vol.Schema({ }) SMART_HOME_SCHEMA = vol.Schema({ - vol.Optional( - CONF_FILTER, - default=lambda: entityfilter.generate_filter([], [], [], []) - ): entityfilter.FILTER_SCHEMA, + vol.Optional(CONF_FILTER, default={}): entityfilter.FILTER_SCHEMA, vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: ALEXA_ENTITY_SCHEMA} }) diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index a4f0225d22d..0d325534266 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -391,6 +391,7 @@ class _AlexaTemperatureSensor(_AlexaInterface): @ENTITY_ADAPTERS.register(alert.DOMAIN) @ENTITY_ADAPTERS.register(automation.DOMAIN) +@ENTITY_ADAPTERS.register(group.DOMAIN) @ENTITY_ADAPTERS.register(input_boolean.DOMAIN) class _GenericCapabilities(_AlexaEntity): """A generic, on/off device. @@ -521,16 +522,6 @@ class _ScriptCapabilities(_AlexaEntity): supports_deactivation=can_cancel)] -@ENTITY_ADAPTERS.register(group.DOMAIN) -class _GroupCapabilities(_AlexaEntity): - def default_display_categories(self): - return [_DisplayCategory.SCENE_TRIGGER] - - def interfaces(self): - return [_AlexaSceneController(self.entity, - supports_deactivation=True)] - - @ENTITY_ADAPTERS.register(sensor.DOMAIN) class _SensorCapabilities(_AlexaEntity): def default_display_categories(self): @@ -773,6 +764,8 @@ def extract_entity(funct): def async_api_turn_on(hass, config, request, entity): """Process a turn on request.""" domain = entity.domain + if entity.domain == group.DOMAIN: + domain = ha.DOMAIN service = SERVICE_TURN_ON if entity.domain == cover.DOMAIN: @@ -928,10 +921,7 @@ def async_api_increase_color_temp(hass, config, request, entity): @asyncio.coroutine def async_api_activate(hass, config, request, entity): """Process an activate request.""" - if entity.domain == group.DOMAIN: - domain = ha.DOMAIN - else: - domain = entity.domain + domain = entity.domain yield from hass.services.async_call(domain, SERVICE_TURN_ON, { ATTR_ENTITY_ID: entity.entity_id @@ -955,10 +945,7 @@ def async_api_activate(hass, config, request, entity): @asyncio.coroutine def async_api_deactivate(hass, config, request, entity): """Process a deactivate request.""" - if entity.domain == group.DOMAIN: - domain = ha.DOMAIN - else: - domain = entity.domain + domain = entity.domain yield from hass.services.async_call(domain, SERVICE_TURN_OFF, { ATTR_ENTITY_ID: entity.entity_id @@ -1178,20 +1165,24 @@ def async_api_adjust_volume(hass, config, request, entity): @asyncio.coroutine def async_api_adjust_volume_step(hass, config, request, entity): """Process an adjust volume step request.""" - volume_step = round(float(request[API_PAYLOAD]['volumeSteps'] / 100), 2) - - current_level = entity.attributes.get(media_player.ATTR_MEDIA_VOLUME_LEVEL) - - volume = current_level + volume_step + # media_player volume up/down service does not support specifying steps + # each component handles it differently e.g. via config. + # For now we use the volumeSteps returned to figure out if we + # should step up/down + volume_step = request[API_PAYLOAD]['volumeSteps'] data = { ATTR_ENTITY_ID: entity.entity_id, - media_player.ATTR_MEDIA_VOLUME_LEVEL: volume, } - yield from hass.services.async_call( - entity.domain, media_player.SERVICE_VOLUME_SET, - data, blocking=False) + if volume_step > 0: + yield from hass.services.async_call( + entity.domain, media_player.SERVICE_VOLUME_UP, + data, blocking=False) + elif volume_step < 0: + yield from hass.services.async_call( + entity.domain, media_player.SERVICE_VOLUME_DOWN, + data, blocking=False) return api_message(request) diff --git a/homeassistant/components/amcrest.py b/homeassistant/components/amcrest.py index 9205846462f..b91f1fae565 100644 --- a/homeassistant/components/amcrest.py +++ b/homeassistant/components/amcrest.py @@ -79,7 +79,7 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_FFMPEG_ARGUMENTS): cv.string, vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): cv.time_period, - vol.Optional(CONF_SENSORS, default=None): + vol.Optional(CONF_SENSORS): vol.All(cv.ensure_list, [vol.In(SENSORS)]), })]) }, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/components/android_ip_webcam.py b/homeassistant/components/android_ip_webcam.py index 5fbd5a764e9..13fa64438d3 100644 --- a/homeassistant/components/android_ip_webcam.py +++ b/homeassistant/components/android_ip_webcam.py @@ -140,11 +140,11 @@ CONFIG_SCHEMA = vol.Schema({ cv.time_period, vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string, vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string, - vol.Optional(CONF_SWITCHES, default=None): + vol.Optional(CONF_SWITCHES): vol.All(cv.ensure_list, [vol.In(SWITCHES)]), - vol.Optional(CONF_SENSORS, default=None): + vol.Optional(CONF_SENSORS): vol.All(cv.ensure_list, [vol.In(SENSORS)]), - vol.Optional(CONF_MOTION_SENSOR, default=None): cv.boolean, + vol.Optional(CONF_MOTION_SENSOR): cv.boolean, })]) }, extra=vol.ALLOW_EXTRA) @@ -165,9 +165,9 @@ def async_setup(hass, config): password = cam_config.get(CONF_PASSWORD) name = cam_config[CONF_NAME] interval = cam_config[CONF_SCAN_INTERVAL] - switches = cam_config[CONF_SWITCHES] - sensors = cam_config[CONF_SENSORS] - motion = cam_config[CONF_MOTION_SENSOR] + switches = cam_config.get(CONF_SWITCHES) + sensors = cam_config.get(CONF_SENSORS) + motion = cam_config.get(CONF_MOTION_SENSOR) # Init ip webcam cam = PyDroidIPCam( diff --git a/homeassistant/components/apple_tv.py b/homeassistant/components/apple_tv.py index 230b0ea8a1b..a9bd5c9c8bc 100644 --- a/homeassistant/components/apple_tv.py +++ b/homeassistant/components/apple_tv.py @@ -60,7 +60,7 @@ CONFIG_SCHEMA = 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_CREDENTIALS, default=None): cv.string, + vol.Optional(CONF_CREDENTIALS): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_START_OFF, default=False): cv.boolean, })]) diff --git a/homeassistant/components/august.py b/homeassistant/components/august.py new file mode 100644 index 00000000000..c12e18ef09c --- /dev/null +++ b/homeassistant/components/august.py @@ -0,0 +1,257 @@ +""" +Support for August devices. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/august/ +""" + +import logging +from datetime import timedelta + +import voluptuous as vol +from requests import RequestException + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_PASSWORD, CONF_USERNAME, CONF_TIMEOUT) +from homeassistant.helpers import discovery +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +_CONFIGURING = {} + +REQUIREMENTS = ['py-august==0.3.0'] + +DEFAULT_TIMEOUT = 10 +ACTIVITY_FETCH_LIMIT = 10 +ACTIVITY_INITIAL_FETCH_LIMIT = 20 + +CONF_LOGIN_METHOD = 'login_method' +CONF_INSTALL_ID = 'install_id' + +NOTIFICATION_ID = 'august_notification' +NOTIFICATION_TITLE = "August Setup" + +AUGUST_CONFIG_FILE = '.august.conf' + +DATA_AUGUST = 'august' +DOMAIN = 'august' +DEFAULT_ENTITY_NAMESPACE = 'august' +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5) +DEFAULT_SCAN_INTERVAL = timedelta(seconds=5) +LOGIN_METHODS = ['phone', 'email'] + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_LOGIN_METHOD): vol.In(LOGIN_METHODS), + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_INSTALL_ID): cv.string, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + }) +}, extra=vol.ALLOW_EXTRA) + +AUGUST_COMPONENTS = [ + 'camera', 'binary_sensor', 'lock' +] + + +def request_configuration(hass, config, api, authenticator): + """Request configuration steps from the user.""" + configurator = hass.components.configurator + + def august_configuration_callback(data): + """Run when the configuration callback is called.""" + from august.authenticator import ValidationResult + + result = authenticator.validate_verification_code( + data.get('verification_code')) + + if result == ValidationResult.INVALID_VERIFICATION_CODE: + configurator.notify_errors(_CONFIGURING[DOMAIN], + "Invalid verification code") + elif result == ValidationResult.VALIDATED: + setup_august(hass, config, api, authenticator) + + if DOMAIN not in _CONFIGURING: + authenticator.send_verification_code() + + conf = config[DOMAIN] + username = conf.get(CONF_USERNAME) + login_method = conf.get(CONF_LOGIN_METHOD) + + _CONFIGURING[DOMAIN] = configurator.request_config( + NOTIFICATION_TITLE, + august_configuration_callback, + description="Please check your {} ({}) and enter the verification " + "code below".format(login_method, username), + submit_caption='Verify', + fields=[{ + 'id': 'verification_code', + 'name': "Verification code", + 'type': 'string'}] + ) + + +def setup_august(hass, config, api, authenticator): + """Set up the August component.""" + from august.authenticator import AuthenticationState + + authentication = None + try: + authentication = authenticator.authenticate() + except RequestException as ex: + _LOGGER.error("Unable to connect to August service: %s", str(ex)) + + hass.components.persistent_notification.create( + "Error: {}
" + "You will need to restart hass after fixing." + "".format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + + state = authentication.state + + if state == AuthenticationState.AUTHENTICATED: + if DOMAIN in _CONFIGURING: + hass.components.configurator.request_done(_CONFIGURING.pop(DOMAIN)) + + hass.data[DATA_AUGUST] = AugustData(api, authentication.access_token) + + for component in AUGUST_COMPONENTS: + discovery.load_platform(hass, component, DOMAIN, {}, config) + + return True + elif state == AuthenticationState.BAD_PASSWORD: + return False + elif state == AuthenticationState.REQUIRES_VALIDATION: + request_configuration(hass, config, api, authenticator) + return True + + return False + + +def setup(hass, config): + """Set up the August component.""" + from august.api import Api + from august.authenticator import Authenticator + + conf = config[DOMAIN] + api = Api(timeout=conf.get(CONF_TIMEOUT)) + + authenticator = Authenticator( + api, + conf.get(CONF_LOGIN_METHOD), + conf.get(CONF_USERNAME), + conf.get(CONF_PASSWORD), + install_id=conf.get(CONF_INSTALL_ID), + access_token_cache_file=hass.config.path(AUGUST_CONFIG_FILE)) + + return setup_august(hass, config, api, authenticator) + + +class AugustData: + """August data object.""" + + def __init__(self, api, access_token): + """Init August data object.""" + self._api = api + self._access_token = access_token + self._doorbells = self._api.get_doorbells(self._access_token) or [] + self._locks = self._api.get_locks(self._access_token) or [] + self._house_ids = [d.house_id for d in self._doorbells + self._locks] + + self._doorbell_detail_by_id = {} + self._lock_status_by_id = {} + self._lock_detail_by_id = {} + self._activities_by_id = {} + + @property + def house_ids(self): + """Return a list of house_ids.""" + return self._house_ids + + @property + def doorbells(self): + """Return a list of doorbells.""" + return self._doorbells + + @property + def locks(self): + """Return a list of locks.""" + return self._locks + + def get_device_activities(self, device_id, *activity_types): + """Return a list of activities.""" + self._update_device_activities() + + activities = self._activities_by_id.get(device_id, []) + if activity_types: + return [a for a in activities if a.activity_type in activity_types] + return activities + + def get_latest_device_activity(self, device_id, *activity_types): + """Return latest activity.""" + activities = self.get_device_activities(device_id, *activity_types) + return next(iter(activities or []), None) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def _update_device_activities(self, limit=ACTIVITY_FETCH_LIMIT): + """Update data object with latest from August API.""" + for house_id in self.house_ids: + activities = self._api.get_house_activities(self._access_token, + house_id, + limit=limit) + + device_ids = {a.device_id for a in activities} + for device_id in device_ids: + self._activities_by_id[device_id] = [a for a in activities if + a.device_id == device_id] + + def get_doorbell_detail(self, doorbell_id): + """Return doorbell detail.""" + self._update_doorbells() + return self._doorbell_detail_by_id.get(doorbell_id) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def _update_doorbells(self): + detail_by_id = {} + + for doorbell in self._doorbells: + detail_by_id[doorbell.device_id] = self._api.get_doorbell_detail( + self._access_token, doorbell.device_id) + + self._doorbell_detail_by_id = detail_by_id + + def get_lock_status(self, lock_id): + """Return lock status.""" + self._update_locks() + return self._lock_status_by_id.get(lock_id) + + def get_lock_detail(self, lock_id): + """Return lock detail.""" + self._update_locks() + return self._lock_detail_by_id.get(lock_id) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def _update_locks(self): + status_by_id = {} + detail_by_id = {} + + for lock in self._locks: + status_by_id[lock.device_id] = self._api.get_lock_status( + self._access_token, lock.device_id) + detail_by_id[lock.device_id] = self._api.get_lock_detail( + self._access_token, lock.device_id) + + self._lock_status_by_id = status_by_id + self._lock_detail_by_id = detail_by_id + + def lock(self, device_id): + """Lock the device.""" + return self._api.lock(self._access_token, device_id) + + def unlock(self, device_id): + """Unlock the device.""" + return self._api.unlock(self._access_token, device_id) diff --git a/homeassistant/components/binary_sensor/august.py b/homeassistant/components/binary_sensor/august.py new file mode 100644 index 00000000000..8df50a1bfb6 --- /dev/null +++ b/homeassistant/components/binary_sensor/august.py @@ -0,0 +1,97 @@ +""" +Support for August binary sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.august/ +""" +from datetime import timedelta, datetime + +from homeassistant.components.august import DATA_AUGUST +from homeassistant.components.binary_sensor import (BinarySensorDevice) + +DEPENDENCIES = ['august'] + +SCAN_INTERVAL = timedelta(seconds=5) + + +def _retrieve_online_state(data, doorbell): + """Get the latest state of the sensor.""" + detail = data.get_doorbell_detail(doorbell.device_id) + return detail.is_online + + +def _retrieve_motion_state(data, doorbell): + from august.activity import ActivityType + return _activity_time_based_state(data, doorbell, + [ActivityType.DOORBELL_MOTION, + ActivityType.DOORBELL_DING]) + + +def _retrieve_ding_state(data, doorbell): + from august.activity import ActivityType + return _activity_time_based_state(data, doorbell, + [ActivityType.DOORBELL_DING]) + + +def _activity_time_based_state(data, doorbell, activity_types): + """Get the latest state of the sensor.""" + latest = data.get_latest_device_activity(doorbell.device_id, + *activity_types) + + if latest is not None: + start = latest.activity_start_time + end = latest.activity_end_time + timedelta(seconds=30) + return start <= datetime.now() <= end + return None + + +# Sensor types: Name, device_class, state_provider +SENSOR_TYPES = { + 'doorbell_ding': ['Ding', 'occupancy', _retrieve_ding_state], + 'doorbell_motion': ['Motion', 'motion', _retrieve_motion_state], + 'doorbell_online': ['Online', 'connectivity', _retrieve_online_state], +} + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the August binary sensors.""" + data = hass.data[DATA_AUGUST] + devices = [] + + for doorbell in data.doorbells: + for sensor_type in SENSOR_TYPES: + devices.append(AugustBinarySensor(data, sensor_type, doorbell)) + + add_devices(devices, True) + + +class AugustBinarySensor(BinarySensorDevice): + """Representation of an August binary sensor.""" + + def __init__(self, data, sensor_type, doorbell): + """Initialize the sensor.""" + self._data = data + self._sensor_type = sensor_type + self._doorbell = doorbell + self._state = None + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self._state + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return SENSOR_TYPES[self._sensor_type][1] + + @property + def name(self): + """Return the name of the binary sensor.""" + return "{} {}".format(self._doorbell.device_name, + SENSOR_TYPES[self._sensor_type][0]) + + def update(self): + """Get the latest state of the sensor.""" + state_provider = SENSOR_TYPES[self._sensor_type][2] + self._state = state_provider(self._data, self._doorbell) diff --git a/homeassistant/components/binary_sensor/bloomsky.py b/homeassistant/components/binary_sensor/bloomsky.py index 1d0849b255e..53f148fe97f 100644 --- a/homeassistant/components/binary_sensor/bloomsky.py +++ b/homeassistant/components/binary_sensor/bloomsky.py @@ -24,7 +24,7 @@ SENSOR_TYPES = { } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_TYPES): + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), }) diff --git a/homeassistant/components/binary_sensor/deconz.py b/homeassistant/components/binary_sensor/deconz.py index 0d7c3e086bb..8fea7891c3d 100644 --- a/homeassistant/components/binary_sensor/deconz.py +++ b/homeassistant/components/binary_sensor/deconz.py @@ -7,7 +7,8 @@ https://home-assistant.io/components/binary_sensor.deconz/ import asyncio from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.deconz import DOMAIN as DECONZ_DATA +from homeassistant.components.deconz import ( + DOMAIN as DATA_DECONZ, DATA_DECONZ_ID) from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.core import callback @@ -21,7 +22,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): return from pydeconz.sensor import DECONZ_BINARY_SENSOR - sensors = hass.data[DECONZ_DATA].sensors + sensors = hass.data[DATA_DECONZ].sensors entities = [] for key in sorted(sensors.keys(), key=int): @@ -42,6 +43,7 @@ class DeconzBinarySensor(BinarySensorDevice): def async_added_to_hass(self): """Subscribe sensors events.""" self._sensor.register_async_callback(self.async_update_callback) + self.hass.data[DATA_DECONZ_ID][self.entity_id] = self._sensor.deconz_id @callback def async_update_callback(self, reason): diff --git a/homeassistant/components/binary_sensor/envisalink.py b/homeassistant/components/binary_sensor/envisalink.py index 7d35c0c9e94..0aadcc247ea 100644 --- a/homeassistant/components/binary_sensor/envisalink.py +++ b/homeassistant/components/binary_sensor/envisalink.py @@ -50,7 +50,7 @@ class EnvisalinkBinarySensor(EnvisalinkDevice, BinarySensorDevice): self._zone_type = zone_type self._zone_number = zone_number - _LOGGER.debug('Setting up zone: ' + zone_name) + _LOGGER.debug('Setting up zone: %s', zone_name) super().__init__(zone_name, info, controller) @asyncio.coroutine diff --git a/homeassistant/components/binary_sensor/hikvision.py b/homeassistant/components/binary_sensor/hikvision.py index ec64bdf07b8..36ec8b7b61a 100644 --- a/homeassistant/components/binary_sensor/hikvision.py +++ b/homeassistant/components/binary_sensor/hikvision.py @@ -56,7 +56,7 @@ CUSTOMIZE_SCHEMA = vol.Schema({ }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_NAME, default=None): cv.string, + vol.Optional(CONF_NAME): cv.string, vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_SSL, default=False): cv.boolean, diff --git a/homeassistant/components/binary_sensor/ihc.py b/homeassistant/components/binary_sensor/ihc.py index 04f8c0d00dd..96efa6e6c19 100644 --- a/homeassistant/components/binary_sensor/ihc.py +++ b/homeassistant/components/binary_sensor/ihc.py @@ -25,7 +25,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.All({ vol.Required(CONF_ID): cv.positive_int, vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_TYPE, default=None): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_TYPE): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_INVERTING, default=False): cv.boolean, }, validate_name) ]) @@ -43,7 +43,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): product_cfg = device['product_cfg'] product = device['product'] sensor = IHCBinarySensor(ihc_controller, name, ihc_id, info, - product_cfg[CONF_TYPE], + product_cfg.get(CONF_TYPE), product_cfg[CONF_INVERTING], product) devices.append(sensor) @@ -52,7 +52,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): for sensor_cfg in binary_sensors: ihc_id = sensor_cfg[CONF_ID] name = sensor_cfg[CONF_NAME] - sensor_type = sensor_cfg[CONF_TYPE] + sensor_type = sensor_cfg.get(CONF_TYPE) inverting = sensor_cfg[CONF_INVERTING] sensor = IHCBinarySensor(ihc_controller, name, ihc_id, info, sensor_type, inverting) @@ -70,7 +70,7 @@ class IHCBinarySensor(IHCDevice, BinarySensorDevice): def __init__(self, ihc_controller, name, ihc_id: int, info: bool, sensor_type: str, inverting: bool, - product: Element=None) -> None: + product: Element = None) -> None: """Initialize the IHC binary sensor.""" super().__init__(ihc_controller, name, ihc_id, info, product) self._state = None diff --git a/homeassistant/components/binary_sensor/knx.py b/homeassistant/components/binary_sensor/knx.py index c01654a3663..2b33d6850d6 100644 --- a/homeassistant/components/binary_sensor/knx.py +++ b/homeassistant/components/binary_sensor/knx.py @@ -35,7 +35,7 @@ DEPENDENCIES = ['knx'] AUTOMATION_SCHEMA = vol.Schema({ vol.Optional(CONF_HOOK, default=CONF_DEFAULT_HOOK): cv.string, vol.Optional(CONF_COUNTER, default=CONF_DEFAULT_COUNTER): cv.port, - vol.Required(CONF_ACTION, default=None): cv.SCRIPT_SCHEMA + vol.Required(CONF_ACTION): cv.SCRIPT_SCHEMA }) AUTOMATIONS_SCHEMA = vol.All( @@ -49,16 +49,13 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_DEVICE_CLASS): cv.string, vol.Optional(CONF_SIGNIFICANT_BIT, default=CONF_DEFAULT_SIGNIFICANT_BIT): cv.positive_int, - vol.Optional(CONF_AUTOMATION, default=None): AUTOMATIONS_SCHEMA, + vol.Optional(CONF_AUTOMATION): AUTOMATIONS_SCHEMA, }) @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up binary sensor(s) for KNX platform.""" - if DATA_KNX not in hass.data or not hass.data[DATA_KNX].initialized: - return - if discovery_info is not None: async_add_devices_discovery(hass, discovery_info, async_add_devices) else: diff --git a/homeassistant/components/binary_sensor/netatmo.py b/homeassistant/components/binary_sensor/netatmo.py index dd7e0ee8d50..7997e4e60db 100644 --- a/homeassistant/components/binary_sensor/netatmo.py +++ b/homeassistant/components/binary_sensor/netatmo.py @@ -50,10 +50,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_CAMERAS, default=[]): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_HOME): cv.string, - vol.Optional(CONF_PRESENCE_SENSORS, default=PRESENCE_SENSOR_TYPES): + vol.Optional(CONF_PRESENCE_SENSORS, default=list(PRESENCE_SENSOR_TYPES)): vol.All(cv.ensure_list, [vol.In(PRESENCE_SENSOR_TYPES)]), vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, - vol.Optional(CONF_WELCOME_SENSORS, default=WELCOME_SENSOR_TYPES): + vol.Optional(CONF_WELCOME_SENSORS, default=list(WELCOME_SENSOR_TYPES)): vol.All(cv.ensure_list, [vol.In(WELCOME_SENSOR_TYPES)]), }) diff --git a/homeassistant/components/binary_sensor/octoprint.py b/homeassistant/components/binary_sensor/octoprint.py index 129b5250431..265fcec66fa 100644 --- a/homeassistant/components/binary_sensor/octoprint.py +++ b/homeassistant/components/binary_sensor/octoprint.py @@ -27,7 +27,7 @@ SENSOR_TYPES = { } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_TYPES): + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) diff --git a/homeassistant/components/binary_sensor/rfxtrx.py b/homeassistant/components/binary_sensor/rfxtrx.py index 2cc0aee2c7b..aedfc3364db 100644 --- a/homeassistant/components/binary_sensor/rfxtrx.py +++ b/homeassistant/components/binary_sensor/rfxtrx.py @@ -28,15 +28,15 @@ DEPENDENCIES = ['rfxtrx'] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_DEVICES, default={}): { cv.string: vol.Schema({ - vol.Optional(CONF_NAME, default=None): cv.string, - vol.Optional(CONF_DEVICE_CLASS, default=None): + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean, - vol.Optional(CONF_OFF_DELAY, default=None): + vol.Optional(CONF_OFF_DELAY): vol.Any(cv.time_period, cv.positive_timedelta), - vol.Optional(CONF_DATA_BITS, default=None): cv.positive_int, - vol.Optional(CONF_COMMAND_ON, default=None): cv.byte, - vol.Optional(CONF_COMMAND_OFF, default=None): cv.byte + vol.Optional(CONF_DATA_BITS): cv.positive_int, + vol.Optional(CONF_COMMAND_ON): cv.byte, + vol.Optional(CONF_COMMAND_OFF): cv.byte }) }, vol.Optional(CONF_AUTOMATIC_ADD, default=False): cv.boolean, @@ -48,7 +48,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): import RFXtrx as rfxtrxmod sensors = [] - for packet_id, entity in config['devices'].items(): + for packet_id, entity in config[CONF_DEVICES].items(): event = rfxtrx.get_rfx_object(packet_id) device_id = slugify(event.device.id_string.lower()) @@ -64,10 +64,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): entity[ATTR_NAME], entity[CONF_DEVICE_CLASS]) device = RfxtrxBinarySensor( - event, entity[ATTR_NAME], entity[CONF_DEVICE_CLASS], - entity[CONF_FIRE_EVENT], entity[CONF_OFF_DELAY], - entity[CONF_DATA_BITS], entity[CONF_COMMAND_ON], - entity[CONF_COMMAND_OFF]) + event, entity.get(CONF_NAME), entity.get(CONF_DEVICE_CLASS), + entity[CONF_FIRE_EVENT], entity.get(CONF_OFF_DELAY), + entity.get(CONF_DATA_BITS), entity.get(CONF_COMMAND_ON), + entity.get(CONF_COMMAND_OFF)) device.hass = hass sensors.append(device) rfxtrx.RFX_DEVICES[device_id] = device diff --git a/homeassistant/components/binary_sensor/rpi_pfio.py b/homeassistant/components/binary_sensor/rpi_pfio.py index 7acbadf873a..1abfa25c82b 100644 --- a/homeassistant/components/binary_sensor/rpi_pfio.py +++ b/homeassistant/components/binary_sensor/rpi_pfio.py @@ -26,7 +26,7 @@ DEFAULT_SETTLE_TIME = 20 DEPENDENCIES = ['rpi_pfio'] PORT_SCHEMA = vol.Schema({ - vol.Optional(CONF_NAME, default=None): cv.string, + vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_SETTLE_TIME, default=DEFAULT_SETTLE_TIME): cv.positive_int, vol.Optional(CONF_INVERT_LOGIC, default=DEFAULT_INVERT_LOGIC): cv.boolean, @@ -44,7 +44,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): binary_sensors = [] ports = config.get(CONF_PORTS) for port, port_entity in ports.items(): - name = port_entity[CONF_NAME] + name = port_entity.get(CONF_NAME) settle_time = port_entity[CONF_SETTLE_TIME] / 1000 invert_logic = port_entity[CONF_INVERT_LOGIC] diff --git a/homeassistant/components/binary_sensor/workday.py b/homeassistant/components/binary_sensor/workday.py index af814cfd464..58599d3d3de 100644 --- a/homeassistant/components/binary_sensor/workday.py +++ b/homeassistant/components/binary_sensor/workday.py @@ -47,7 +47,7 @@ DEFAULT_OFFSET = 0 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_COUNTRY): vol.In(ALL_COUNTRIES), - vol.Optional(CONF_PROVINCE, default=None): cv.string, + vol.Optional(CONF_PROVINCE): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_OFFSET, default=DEFAULT_OFFSET): vol.Coerce(int), vol.Optional(CONF_WORKDAYS, default=DEFAULT_WORKDAYS): diff --git a/homeassistant/components/bmw_connected_drive.py b/homeassistant/components/bmw_connected_drive.py new file mode 100644 index 00000000000..98c25df79f6 --- /dev/null +++ b/homeassistant/components/bmw_connected_drive.py @@ -0,0 +1,105 @@ +""" +Reads vehicle status from BMW connected drive portal. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/bmw_connected_drive/ +""" +import logging +import datetime + +import voluptuous as vol +from homeassistant.helpers import discovery +from homeassistant.helpers.event import track_utc_time_change + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_USERNAME, CONF_PASSWORD +) + +REQUIREMENTS = ['bimmer_connected==0.3.0'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'bmw_connected_drive' +CONF_VALUES = 'values' +CONF_COUNTRY = 'country' + +ACCOUNT_SCHEMA = vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_COUNTRY): cv.string, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: { + cv.string: ACCOUNT_SCHEMA + }, +}, extra=vol.ALLOW_EXTRA) + + +BMW_COMPONENTS = ['device_tracker', 'sensor'] +UPDATE_INTERVAL = 5 # in minutes + + +def setup(hass, config): + """Set up the BMW connected drive components.""" + accounts = [] + for name, account_config in config[DOMAIN].items(): + username = account_config[CONF_USERNAME] + password = account_config[CONF_PASSWORD] + country = account_config[CONF_COUNTRY] + _LOGGER.debug('Adding new account %s', name) + bimmer = BMWConnectedDriveAccount(username, password, country, name) + accounts.append(bimmer) + + # update every UPDATE_INTERVAL minutes, starting now + # this should even out the load on the servers + + now = datetime.datetime.now() + track_utc_time_change( + hass, bimmer.update, + minute=range(now.minute % UPDATE_INTERVAL, 60, UPDATE_INTERVAL), + second=now.second) + + hass.data[DOMAIN] = accounts + + for account in accounts: + account.update() + + for component in BMW_COMPONENTS: + discovery.load_platform(hass, component, DOMAIN, {}, config) + + return True + + +class BMWConnectedDriveAccount(object): + """Representation of a BMW vehicle.""" + + def __init__(self, username: str, password: str, country: str, + name: str) -> None: + """Constructor.""" + from bimmer_connected.account import ConnectedDriveAccount + + self.account = ConnectedDriveAccount(username, password, country) + self.name = name + self._update_listeners = [] + + def update(self, *_): + """Update the state of all vehicles. + + Notify all listeners about the update. + """ + _LOGGER.debug('Updating vehicle state for account %s, ' + 'notifying %d listeners', + self.name, len(self._update_listeners)) + try: + self.account.update_vehicle_states() + for listener in self._update_listeners: + listener() + except IOError as exception: + _LOGGER.error('Error updating the vehicle state.') + _LOGGER.exception(exception) + + def add_update_listener(self, listener): + """Add a listener for update notifications.""" + self._update_listeners.append(listener) diff --git a/homeassistant/components/calendar/caldav.py b/homeassistant/components/calendar/caldav.py index ba798ce7902..d70e7ff8946 100644 --- a/homeassistant/components/calendar/caldav.py +++ b/homeassistant/components/calendar/caldav.py @@ -166,7 +166,7 @@ class WebDavCalendarData(object): self.event = { "summary": vevent.summary.value, "start": self.get_hass_date(vevent.dtstart.value), - "end": self.get_hass_date(vevent.dtend.value), + "end": self.get_hass_date(self.get_end_date(vevent)), "location": self.get_attr_value(vevent, "location"), "description": self.get_attr_value(vevent, "description") } @@ -194,7 +194,7 @@ class WebDavCalendarData(object): @staticmethod def is_over(vevent): """Return if the event is over.""" - return dt.now() > WebDavCalendarData.to_datetime(vevent.dtend.value) + return dt.now() > WebDavCalendarData.get_end_date(vevent) @staticmethod def get_hass_date(obj): @@ -217,3 +217,17 @@ class WebDavCalendarData(object): if hasattr(obj, attribute): return getattr(obj, attribute).value return None + + @staticmethod + def get_end_date(obj): + """Return the end datetime as determined by dtend or duration.""" + if hasattr(obj, "dtend"): + enddate = obj.dtend.value + + elif hasattr(obj, "duration"): + enddate = obj.dtstart.value + obj.duration.value + + else: + enddate = obj.dtstart.value + timedelta(days=1) + + return WebDavCalendarData.to_datetime(enddate) diff --git a/homeassistant/components/calendar/todoist.py b/homeassistant/components/calendar/todoist.py index f1c80612f3b..c5ae1dd3c11 100644 --- a/homeassistant/components/calendar/todoist.py +++ b/homeassistant/components/calendar/todoist.py @@ -498,7 +498,7 @@ class TodoistProjectData(object): # Organize the best tasks (so users can see all the tasks # they have, organized) - while len(project_tasks) > 0: + while project_tasks: best_task = self.select_best_task(project_tasks) _LOGGER.debug("Found Todoist Task: %s", best_task[SUMMARY]) project_tasks.remove(best_task) diff --git a/homeassistant/components/camera/august.py b/homeassistant/components/camera/august.py new file mode 100644 index 00000000000..d3bc080bfc6 --- /dev/null +++ b/homeassistant/components/camera/august.py @@ -0,0 +1,76 @@ +""" +Support for August camera. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.august/ +""" +from datetime import timedelta + +import requests + +from homeassistant.components.august import DATA_AUGUST, DEFAULT_TIMEOUT +from homeassistant.components.camera import Camera + +DEPENDENCIES = ['august'] + +SCAN_INTERVAL = timedelta(seconds=5) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up August cameras.""" + data = hass.data[DATA_AUGUST] + devices = [] + + for doorbell in data.doorbells: + devices.append(AugustCamera(data, doorbell, DEFAULT_TIMEOUT)) + + add_devices(devices, True) + + +class AugustCamera(Camera): + """An implementation of a Canary security camera.""" + + def __init__(self, data, doorbell, timeout): + """Initialize a Canary security camera.""" + super().__init__() + self._data = data + self._doorbell = doorbell + self._timeout = timeout + self._image_url = None + self._image_content = None + + @property + def name(self): + """Return the name of this device.""" + return self._doorbell.device_name + + @property + def is_recording(self): + """Return true if the device is recording.""" + return self._doorbell.has_subscription + + @property + def motion_detection_enabled(self): + """Return the camera motion detection status.""" + return True + + @property + def brand(self): + """Return the camera brand.""" + return 'August' + + @property + def model(self): + """Return the camera model.""" + return 'Doorbell' + + def camera_image(self): + """Return bytes of camera image.""" + latest = self._data.get_doorbell_detail(self._doorbell.device_id) + + if self._image_url is not latest.image_url: + self._image_url = latest.image_url + self._image_content = requests.get(self._image_url, + timeout=self._timeout).content + + return self._image_content diff --git a/homeassistant/components/camera/doorbird.py b/homeassistant/components/camera/doorbird.py index 2ca962a8450..034ddc2fabb 100644 --- a/homeassistant/components/camera/doorbird.py +++ b/homeassistant/components/camera/doorbird.py @@ -18,8 +18,10 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession DEPENDENCIES = ['doorbird'] _CAMERA_LAST_VISITOR = "DoorBird Last Ring" +_CAMERA_LAST_MOTION = "DoorBird Last Motion" _CAMERA_LIVE = "DoorBird Live" _LAST_VISITOR_INTERVAL = datetime.timedelta(minutes=1) +_LAST_MOTION_INTERVAL = datetime.timedelta(minutes=1) _LIVE_INTERVAL = datetime.timedelta(seconds=1) _LOGGER = logging.getLogger(__name__) _TIMEOUT = 10 # seconds @@ -34,6 +36,9 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): DoorBirdCamera( device.history_image_url(1, 'doorbell'), _CAMERA_LAST_VISITOR, _LAST_VISITOR_INTERVAL), + DoorBirdCamera( + device.history_image_url(1, 'motionsensor'), _CAMERA_LAST_MOTION, + _LAST_MOTION_INTERVAL), ]) diff --git a/homeassistant/components/camera/mjpeg.py b/homeassistant/components/camera/mjpeg.py index 6168eb81939..35d30104f6e 100644 --- a/homeassistant/components/camera/mjpeg.py +++ b/homeassistant/components/camera/mjpeg.py @@ -119,6 +119,8 @@ class MjpegCamera(Camera): else: req = requests.get(self._mjpeg_url, stream=True, timeout=10) + # https://github.com/PyCQA/pylint/issues/1437 + # pylint: disable=no-member with closing(req) as response: return extract_image_from_mjpeg(response.iter_content(102400)) diff --git a/homeassistant/components/camera/onvif.py b/homeassistant/components/camera/onvif.py index 65f291bf41d..1340c52459d 100644 --- a/homeassistant/components/camera/onvif.py +++ b/homeassistant/components/camera/onvif.py @@ -6,18 +6,19 @@ https://home-assistant.io/components/camera.onvif/ """ import asyncio import logging -import os import voluptuous as vol from homeassistant.const import ( - CONF_NAME, CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_PORT) -from homeassistant.components.camera import Camera, PLATFORM_SCHEMA + CONF_NAME, CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_PORT, + ATTR_ENTITY_ID) +from homeassistant.components.camera import Camera, PLATFORM_SCHEMA, DOMAIN from homeassistant.components.ffmpeg import ( DATA_FFMPEG, CONF_EXTRA_ARGUMENTS) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.aiohttp_client import ( async_aiohttp_proxy_stream) +from homeassistant.helpers.service import extract_entity_ids _LOGGER = logging.getLogger(__name__) @@ -33,6 +34,22 @@ DEFAULT_USERNAME = 'admin' DEFAULT_PASSWORD = '888888' DEFAULT_ARGUMENTS = '-q:v 2' +ATTR_PAN = "pan" +ATTR_TILT = "tilt" +ATTR_ZOOM = "zoom" + +DIR_UP = "UP" +DIR_DOWN = "DOWN" +DIR_LEFT = "LEFT" +DIR_RIGHT = "RIGHT" +ZOOM_OUT = "ZOOM_OUT" +ZOOM_IN = "ZOOM_IN" + +SERVICE_PTZ = "onvif_ptz" + +ONVIF_DATA = "onvif" +ENTITIES = "entities" + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, @@ -42,36 +59,98 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_EXTRA_ARGUMENTS, default=DEFAULT_ARGUMENTS): cv.string, }) +SERVICE_PTZ_SCHEMA = vol.Schema({ + ATTR_ENTITY_ID: cv.entity_ids, + ATTR_PAN: vol.In([DIR_LEFT, DIR_RIGHT]), + ATTR_TILT: vol.In([DIR_UP, DIR_DOWN]), + ATTR_ZOOM: vol.In([ZOOM_OUT, ZOOM_IN]) +}) + @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up a ONVIF camera.""" if not hass.data[DATA_FFMPEG].async_run_test(config.get(CONF_HOST)): return - async_add_devices([ONVIFCamera(hass, config)]) + + def handle_ptz(service): + """Handle PTZ service call.""" + pan = service.data.get(ATTR_PAN, None) + tilt = service.data.get(ATTR_TILT, None) + zoom = service.data.get(ATTR_ZOOM, None) + all_cameras = hass.data[ONVIF_DATA][ENTITIES] + entity_ids = extract_entity_ids(hass, service) + target_cameras = [] + if not entity_ids: + target_cameras = all_cameras + else: + target_cameras = [camera for camera in all_cameras + if camera.entity_id in entity_ids] + for camera in target_cameras: + camera.perform_ptz(pan, tilt, zoom) + + hass.services.async_register(DOMAIN, SERVICE_PTZ, handle_ptz, + schema=SERVICE_PTZ_SCHEMA) + async_add_devices([ONVIFHassCamera(hass, config)]) -class ONVIFCamera(Camera): +class ONVIFHassCamera(Camera): """An implementation of an ONVIF camera.""" def __init__(self, hass, config): """Initialize a ONVIF camera.""" - from onvif import ONVIFService - import onvif + from onvif import ONVIFCamera, exceptions super().__init__() self._name = config.get(CONF_NAME) self._ffmpeg_arguments = config.get(CONF_EXTRA_ARGUMENTS) - media = ONVIFService( - 'http://{}:{}/onvif/device_service'.format( - config.get(CONF_HOST), config.get(CONF_PORT)), - config.get(CONF_USERNAME), - config.get(CONF_PASSWORD), - '{}/wsdl/media.wsdl'.format(os.path.dirname(onvif.__file__)) - ) - self._input = media.GetStreamUri().Uri - _LOGGER.debug("ONVIF Camera Using the following URL for %s: %s", - self._name, self._input) + self._input = None + camera = None + try: + _LOGGER.debug("Connecting with ONVIF Camera: %s on port %s", + config.get(CONF_HOST), config.get(CONF_PORT)) + camera = ONVIFCamera( + config.get(CONF_HOST), config.get(CONF_PORT), + config.get(CONF_USERNAME), config.get(CONF_PASSWORD) + ) + media_service = camera.create_media_service() + stream_uri = media_service.GetStreamUri( + {'StreamSetup': {'Stream': 'RTP-Unicast', 'Transport': 'RTSP'}} + ) + self._input = stream_uri.Uri.replace( + 'rtsp://', 'rtsp://{}:{}@'.format( + config.get(CONF_USERNAME), + config.get(CONF_PASSWORD)), 1) + _LOGGER.debug( + "ONVIF Camera Using the following URL for %s: %s", + self._name, self._input) + except Exception as err: + _LOGGER.error("Unable to communicate with ONVIF Camera: %s", err) + raise + try: + self._ptz = camera.create_ptz_service() + except exceptions.ONVIFError as err: + self._ptz = None + _LOGGER.warning("Unable to setup PTZ for ONVIF Camera: %s", err) + + def perform_ptz(self, pan, tilt, zoom): + """Perform a PTZ action on the camera.""" + if self._ptz: + pan_val = 1 if pan == DIR_RIGHT else -1 if pan == DIR_LEFT else 0 + tilt_val = 1 if tilt == DIR_UP else -1 if tilt == DIR_DOWN else 0 + zoom_val = 1 if zoom == ZOOM_IN else -1 if zoom == ZOOM_OUT else 0 + req = {"Velocity": { + "PanTilt": {"_x": pan_val, "_y": tilt_val}, + "Zoom": {"_x": zoom_val}}} + self._ptz.ContinuousMove(req) + + @asyncio.coroutine + def async_added_to_hass(self): + """Callback when entity is added to hass.""" + if ONVIF_DATA not in self.hass.data: + self.hass.data[ONVIF_DATA] = {} + self.hass.data[ONVIF_DATA][ENTITIES] = [] + self.hass.data[ONVIF_DATA][ENTITIES].append(self) @asyncio.coroutine def async_camera_image(self): diff --git a/homeassistant/components/camera/rpi_camera.py b/homeassistant/components/camera/rpi_camera.py index f37e7778414..f1f110d7c6a 100644 --- a/homeassistant/components/camera/rpi_camera.py +++ b/homeassistant/components/camera/rpi_camera.py @@ -8,6 +8,7 @@ import os import subprocess import logging import shutil +from tempfile import NamedTemporaryFile import voluptuous as vol @@ -36,7 +37,7 @@ DEFAULT_TIMELAPSE = 1000 DEFAULT_VERTICAL_FLIP = 0 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_FILE_PATH): cv.string, + vol.Optional(CONF_FILE_PATH): cv.isfile, vol.Optional(CONF_HORIZONTAL_FLIP, default=DEFAULT_HORIZONTAL_FLIP): vol.All(vol.Coerce(int), vol.Range(min=0, max=1)), vol.Optional(CONF_IMAGE_HEIGHT, default=DEFAULT_IMAGE_HEIGHT): @@ -77,25 +78,32 @@ def setup_platform(hass, config, add_devices, discovery_info=None): CONF_TIMELAPSE: config.get(CONF_TIMELAPSE), CONF_HORIZONTAL_FLIP: config.get(CONF_HORIZONTAL_FLIP), CONF_VERTICAL_FLIP: config.get(CONF_VERTICAL_FLIP), - CONF_FILE_PATH: config.get(CONF_FILE_PATH, - os.path.join(os.path.dirname(__file__), - 'image.jpg')) + CONF_FILE_PATH: config.get(CONF_FILE_PATH) } ) hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, kill_raspistill) - try: - # Try to create an empty file (or open existing) to ensure we have - # proper permissions. - open(setup_config[CONF_FILE_PATH], 'a').close() + file_path = setup_config[CONF_FILE_PATH] - add_devices([RaspberryCamera(setup_config)]) - except PermissionError: - _LOGGER.error("File path is not writable") - return False - except FileNotFoundError: - _LOGGER.error("Could not create output file (missing directory?)") + def delete_temp_file(*args): + """Delete the temporary file to prevent saving multiple temp images. + + Only used when no path is defined + """ + os.remove(file_path) + + # If no file path is defined, use a temporary file + if file_path is None: + temp_file = NamedTemporaryFile(suffix='.jpg', delete=False) + temp_file.close() + file_path = temp_file.name + setup_config[CONF_FILE_PATH] = file_path + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, delete_temp_file) + + # Check whether the file path has been whitelisted + elif not hass.config.is_allowed_path(file_path): + _LOGGER.error("'%s' is not a whitelisted directory", file_path) return False diff --git a/homeassistant/components/camera/services.yaml b/homeassistant/components/camera/services.yaml index 926af582cc7..b548f3d1ada 100644 --- a/homeassistant/components/camera/services.yaml +++ b/homeassistant/components/camera/services.yaml @@ -23,3 +23,20 @@ snapshot: filename: description: Template of a Filename. Variable is entity_id. example: '/tmp/snapshot_{{ entity_id }}' + +onvif_ptz: + description: Pan/Tilt/Zoom service for ONVIF camera. + fields: + entity_id: + description: Name(s) of entities to pan, tilt or zoom. + example: 'camera.living_room_camera' + pan: + description: "Direction of pan. Allowed values: LEFT, RIGHT." + example: 'LEFT' + tilt: + description: "Direction of tilt. Allowed values: DOWN, UP." + example: 'DOWN' + zoom: + description: "Zoom. Allowed values: ZOOM_IN, ZOOM_OUT" + example: "ZOOM_IN" + diff --git a/homeassistant/components/camera/uvc.py b/homeassistant/components/camera/uvc.py index f7dc4cfd973..20dceb8a1c5 100644 --- a/homeassistant/components/camera/uvc.py +++ b/homeassistant/components/camera/uvc.py @@ -188,7 +188,7 @@ class UnifiVideoCamera(Camera): self._nvr.set_recordmode(self._uuid, set_mode) self._motion_status = mode except NvrError as err: - _LOGGER.error("Unable to set recordmode to " + set_mode) + _LOGGER.error("Unable to set recordmode to %s", set_mode) _LOGGER.debug(err) def enable_motion_detection(self): diff --git a/homeassistant/components/camera/xeoma.py b/homeassistant/components/camera/xeoma.py index b4bcad0064d..5836a9c94dc 100644 --- a/homeassistant/components/camera/xeoma.py +++ b/homeassistant/components/camera/xeoma.py @@ -33,7 +33,7 @@ CAMERAS_SCHEMA = vol.Schema({ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_CAMERAS, default={}): + vol.Optional(CONF_CAMERAS): vol.Schema(vol.All(cv.ensure_list, [CAMERAS_SCHEMA])), vol.Optional(CONF_NEW_VERSION, default=True): cv.boolean, vol.Optional(CONF_PASSWORD): cv.string, @@ -42,7 +42,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine -# pylint: disable=unused-argument def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Discover and setup Xeoma Cameras.""" from pyxeoma.xeoma import Xeoma, XeomaError @@ -68,7 +67,9 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): for image_name, username, pw in discovered_image_names ] - for cam in config[CONF_CAMERAS]: + for cam in config.get(CONF_CAMERAS, []): + # https://github.com/PyCQA/pylint/issues/1830 + # pylint: disable=stop-iteration-return camera = next( (dc for dc in discovered_cameras if dc[CONF_IMAGE_NAME] == cam[CONF_IMAGE_NAME]), None) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index ce656eb96e8..e1a5f71af83 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -669,16 +669,16 @@ class ClimateDevice(Entity): """ return self.hass.async_add_job(self.set_humidity, humidity) - def set_fan_mode(self, fan): + def set_fan_mode(self, fan_mode): """Set new target fan mode.""" raise NotImplementedError() - def async_set_fan_mode(self, fan): + def async_set_fan_mode(self, fan_mode): """Set new target fan mode. This method must be run in the event loop and returns a coroutine. """ - return self.hass.async_add_job(self.set_fan_mode, fan) + return self.hass.async_add_job(self.set_fan_mode, fan_mode) def set_operation_mode(self, operation_mode): """Set new target operation mode.""" diff --git a/homeassistant/components/climate/daikin.py b/homeassistant/components/climate/daikin.py index 0ed4ebe8942..2c49b25a39d 100644 --- a/homeassistant/components/climate/daikin.py +++ b/homeassistant/components/climate/daikin.py @@ -28,7 +28,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_NAME, default=None): cv.string, + vol.Optional(CONF_NAME): cv.string, }) HA_STATE_TO_DAIKIN = { @@ -236,9 +236,9 @@ class DaikinClimate(ClimateDevice): """Return the fan setting.""" return self.get(ATTR_FAN_MODE) - def set_fan_mode(self, fan): + def set_fan_mode(self, fan_mode): """Set fan mode.""" - self.set({ATTR_FAN_MODE: fan}) + self.set({ATTR_FAN_MODE: fan_mode}) @property def fan_list(self): diff --git a/homeassistant/components/climate/demo.py b/homeassistant/components/climate/demo.py index 102155babea..44491b8cd21 100644 --- a/homeassistant/components/climate/demo.py +++ b/homeassistant/components/climate/demo.py @@ -195,9 +195,9 @@ class DemoClimate(ClimateDevice): self._current_swing_mode = swing_mode self.schedule_update_ha_state() - def set_fan_mode(self, fan): + def set_fan_mode(self, fan_mode): """Set new target temperature.""" - self._current_fan_mode = fan + self._current_fan_mode = fan_mode self.schedule_update_ha_state() def set_operation_mode(self, operation_mode): @@ -225,9 +225,9 @@ class DemoClimate(ClimateDevice): self._away = False self.schedule_update_ha_state() - def set_hold_mode(self, hold): - """Update hold mode on.""" - self._hold = hold + def set_hold_mode(self, hold_mode): + """Update hold_mode on.""" + self._hold = hold_mode self.schedule_update_ha_state() def turn_aux_heat_on(self): diff --git a/homeassistant/components/climate/ephember.py b/homeassistant/components/climate/ephember.py index e1f1ab7d448..419237b4645 100644 --- a/homeassistant/components/climate/ephember.py +++ b/homeassistant/components/climate/ephember.py @@ -98,8 +98,7 @@ class EphEmberThermostat(ClimateDevice): """Return current operation ie. heat, cool, idle.""" if self._zone['isCurrentlyActive']: return STATE_HEAT - else: - return STATE_IDLE + return STATE_IDLE @property def is_aux_heat_on(self): diff --git a/homeassistant/components/climate/eq3btsmart.py b/homeassistant/components/climate/eq3btsmart.py index 9c712c632e6..5c0a3530006 100644 --- a/homeassistant/components/climate/eq3btsmart.py +++ b/homeassistant/components/climate/eq3btsmart.py @@ -15,7 +15,7 @@ from homeassistant.const import ( CONF_MAC, CONF_DEVICES, TEMP_CELSIUS, ATTR_TEMPERATURE, PRECISION_HALVES) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-eq3bt==0.1.8'] +REQUIREMENTS = ['python-eq3bt==0.1.9'] _LOGGER = logging.getLogger(__name__) @@ -53,7 +53,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): add_devices(devices) -# pylint: disable=import-error +# pylint: disable=import-error, no-name-in-module class EQ3BTSmartThermostat(ClimateDevice): """Representation of an eQ-3 Bluetooth Smart thermostat.""" @@ -75,6 +75,8 @@ class EQ3BTSmartThermostat(ClimateDevice): self._name = _name self._thermostat = eq3.Thermostat(_mac) + self._target_temperature = None + self._target_mode = None @property def supported_features(self): @@ -116,6 +118,7 @@ class EQ3BTSmartThermostat(ClimateDevice): temperature = kwargs.get(ATTR_TEMPERATURE) if temperature is None: return + self._target_temperature = temperature self._thermostat.target_temperature = temperature @property @@ -132,6 +135,7 @@ class EQ3BTSmartThermostat(ClimateDevice): def set_operation_mode(self, operation_mode): """Set operation mode.""" + self._target_mode = operation_mode self._thermostat.mode = self.reverse_modes[operation_mode] def turn_away_mode_off(self): @@ -177,3 +181,15 @@ class EQ3BTSmartThermostat(ClimateDevice): self._thermostat.update() except BTLEException as ex: _LOGGER.warning("Updating the state failed: %s", ex) + + if (self._target_temperature and + self._thermostat.target_temperature + != self._target_temperature): + self.set_temperature(temperature=self._target_temperature) + else: + self._target_temperature = None + if (self._target_mode and + self.modes[self._thermostat.mode] != self._target_mode): + self.set_operation_mode(operation_mode=self._target_mode) + else: + self._target_mode = None diff --git a/homeassistant/components/climate/flexit.py b/homeassistant/components/climate/flexit.py index 98c03217509..565e913319f 100644 --- a/homeassistant/components/climate/flexit.py +++ b/homeassistant/components/climate/flexit.py @@ -152,6 +152,6 @@ class Flexit(ClimateDevice): self._target_temperature = kwargs.get(ATTR_TEMPERATURE) self.unit.set_temp(self._target_temperature) - def set_fan_mode(self, fan): + def set_fan_mode(self, fan_mode): """Set new fan mode.""" - self.unit.set_fan_speed(self._fan_list.index(fan)) + self.unit.set_fan_speed(self._fan_list.index(fan_mode)) diff --git a/homeassistant/components/climate/generic_thermostat.py b/homeassistant/components/climate/generic_thermostat.py index c66e611c8e9..b97dc221298 100644 --- a/homeassistant/components/climate/generic_thermostat.py +++ b/homeassistant/components/climate/generic_thermostat.py @@ -190,11 +190,9 @@ class GenericThermostat(ClimateDevice): """Return the current state.""" if self._is_device_active: return self.current_operation - else: - if self._enabled: - return STATE_IDLE - else: - return STATE_OFF + if self._enabled: + return STATE_IDLE + return STATE_OFF @property def should_poll(self): diff --git a/homeassistant/components/climate/knx.py b/homeassistant/components/climate/knx.py index a78c277fa33..1bbc5b789fb 100644 --- a/homeassistant/components/climate/knx.py +++ b/homeassistant/components/climate/knx.py @@ -48,9 +48,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ default=DEFAULT_SETPOINT_SHIFT_STEP): vol.All( float, vol.Range(min=0, max=2)), vol.Optional(CONF_SETPOINT_SHIFT_MAX, default=DEFAULT_SETPOINT_SHIFT_MAX): - vol.All(int, vol.Range(min=-32, max=0)), - vol.Optional(CONF_SETPOINT_SHIFT_MIN, default=DEFAULT_SETPOINT_SHIFT_MIN): vol.All(int, vol.Range(min=0, max=32)), + vol.Optional(CONF_SETPOINT_SHIFT_MIN, default=DEFAULT_SETPOINT_SHIFT_MIN): + vol.All(int, vol.Range(min=-32, max=0)), vol.Optional(CONF_OPERATION_MODE_ADDRESS): cv.string, vol.Optional(CONF_OPERATION_MODE_STATE_ADDRESS): cv.string, vol.Optional(CONF_CONTROLLER_STATUS_ADDRESS): cv.string, @@ -64,9 +64,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up climate(s) for KNX platform.""" - if DATA_KNX not in hass.data or not hass.data[DATA_KNX].initialized: - return - if discovery_info is not None: async_add_devices_discovery(hass, discovery_info, async_add_devices) else: diff --git a/homeassistant/components/climate/melissa.py b/homeassistant/components/climate/melissa.py index 2b3b3bfbab1..9c005b62dcc 100644 --- a/homeassistant/components/climate/melissa.py +++ b/homeassistant/components/climate/melissa.py @@ -26,7 +26,7 @@ SUPPORT_FLAGS = (SUPPORT_FAN_MODE | SUPPORT_OPERATION_MODE | SUPPORT_ON_OFF | SUPPORT_TARGET_TEMPERATURE) OP_MODES = [ - STATE_AUTO, STATE_COOL, STATE_DRY, STATE_FAN_ONLY, STATE_HEAT + STATE_COOL, STATE_DRY, STATE_FAN_ONLY, STATE_HEAT ] FAN_MODES = [ @@ -42,8 +42,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): all_devices = [] for device in devices: - all_devices.append(MelissaClimate( - api, device['serial_number'], device)) + if device['type'] == 'melissa': + all_devices.append(MelissaClimate( + api, device['serial_number'], device)) add_devices(all_devices) @@ -146,10 +147,10 @@ class MelissaClimate(ClimateDevice): temp = kwargs.get(ATTR_TEMPERATURE) self.send({self._api.TEMP: temp}) - def set_fan_mode(self, fan): + def set_fan_mode(self, fan_mode): """Set fan mode.""" - fan_mode = self.hass_fan_to_melissa(fan) - self.send({self._api.FAN: fan_mode}) + melissa_fan_mode = self.hass_fan_to_melissa(fan_mode) + self.send({self._api.FAN: melissa_fan_mode}) def set_operation_mode(self, operation_mode): """Set operation mode.""" @@ -174,8 +175,7 @@ class MelissaClimate(ClimateDevice): if not self._api.send(self._serial_number, self._cur_settings): self._cur_settings = old_value return False - else: - return True + return True def update(self): """Get latest data from Melissa.""" @@ -196,14 +196,11 @@ class MelissaClimate(ClimateDevice): return STATE_OFF elif state == self._api.STATE_IDLE: return STATE_IDLE - else: - return None + return None def melissa_op_to_hass(self, mode): """Translate Melissa modes to hass states.""" - if mode == self._api.MODE_AUTO: - return STATE_AUTO - elif mode == self._api.MODE_HEAT: + if mode == self._api.MODE_HEAT: return STATE_HEAT elif mode == self._api.MODE_COOL: return STATE_COOL @@ -211,10 +208,9 @@ class MelissaClimate(ClimateDevice): return STATE_DRY elif mode == self._api.MODE_FAN: return STATE_FAN_ONLY - else: - _LOGGER.warning( - "Operation mode %s could not be mapped to hass", mode) - return None + _LOGGER.warning( + "Operation mode %s could not be mapped to hass", mode) + return None def melissa_fan_to_hass(self, fan): """Translate Melissa fan modes to hass modes.""" @@ -226,15 +222,12 @@ class MelissaClimate(ClimateDevice): return SPEED_MEDIUM elif fan == self._api.FAN_HIGH: return SPEED_HIGH - else: - _LOGGER.warning("Fan mode %s could not be mapped to hass", fan) - return None + _LOGGER.warning("Fan mode %s could not be mapped to hass", fan) + return None def hass_mode_to_melissa(self, mode): """Translate hass states to melissa modes.""" - if mode == STATE_AUTO: - return self._api.MODE_AUTO - elif mode == STATE_HEAT: + if mode == STATE_HEAT: return self._api.MODE_HEAT elif mode == STATE_COOL: return self._api.MODE_COOL diff --git a/homeassistant/components/climate/mqtt.py b/homeassistant/components/climate/mqtt.py index 5929cec3b05..1d98a5733f7 100644 --- a/homeassistant/components/climate/mqtt.py +++ b/homeassistant/components/climate/mqtt.py @@ -482,15 +482,15 @@ class MqttClimate(MqttAvailability, ClimateDevice): self.async_schedule_update_ha_state() @asyncio.coroutine - def async_set_fan_mode(self, fan): + def async_set_fan_mode(self, fan_mode): """Set new target temperature.""" if self._send_if_off or self._current_operation != STATE_OFF: mqtt.async_publish( self.hass, self._topic[CONF_FAN_MODE_COMMAND_TOPIC], - fan, self._qos, self._retain) + fan_mode, self._qos, self._retain) if self._topic[CONF_FAN_MODE_STATE_TOPIC] is None: - self._current_fan_mode = fan + self._current_fan_mode = fan_mode self.async_schedule_update_ha_state() @asyncio.coroutine @@ -552,15 +552,15 @@ class MqttClimate(MqttAvailability, ClimateDevice): self.async_schedule_update_ha_state() @asyncio.coroutine - def async_set_hold_mode(self, hold): + def async_set_hold_mode(self, hold_mode): """Update hold mode on.""" if self._topic[CONF_HOLD_COMMAND_TOPIC] is not None: mqtt.async_publish(self.hass, self._topic[CONF_HOLD_COMMAND_TOPIC], - hold, self._qos, self._retain) + hold_mode, self._qos, self._retain) if self._topic[CONF_HOLD_STATE_TOPIC] is None: - self._hold = hold + self._hold = hold_mode self.async_schedule_update_ha_state() @asyncio.coroutine diff --git a/homeassistant/components/climate/mysensors.py b/homeassistant/components/climate/mysensors.py index 5553db70f0d..b526d8b066c 100644 --- a/homeassistant/components/climate/mysensors.py +++ b/homeassistant/components/climate/mysensors.py @@ -143,14 +143,14 @@ class MySensorsHVAC(mysensors.MySensorsEntity, ClimateDevice): self._values[value_type] = value self.schedule_update_ha_state() - def set_fan_mode(self, fan): + def set_fan_mode(self, fan_mode): """Set new target temperature.""" set_req = self.gateway.const.SetReq self.gateway.set_child_value( - self.node_id, self.child_id, set_req.V_HVAC_SPEED, fan) + self.node_id, self.child_id, set_req.V_HVAC_SPEED, fan_mode) if self.gateway.optimistic: # Optimistically assume that device has changed state - self._values[set_req.V_HVAC_SPEED] = fan + self._values[set_req.V_HVAC_SPEED] = fan_mode self.schedule_update_ha_state() def set_operation_mode(self, operation_mode): diff --git a/homeassistant/components/climate/nest.py b/homeassistant/components/climate/nest.py index d8d7d6c901a..0427514a7b5 100644 --- a/homeassistant/components/climate/nest.py +++ b/homeassistant/components/climate/nest.py @@ -207,9 +207,9 @@ class NestThermostat(ClimateDevice): """List of available fan modes.""" return self._fan_list - def set_fan_mode(self, fan): + def set_fan_mode(self, fan_mode): """Turn fan on/off.""" - self.device.fan = fan.lower() + self.device.fan = fan_mode.lower() @property def min_temp(self): @@ -225,7 +225,7 @@ class NestThermostat(ClimateDevice): """Cache value from Python-nest.""" self._location = self.device.where self._name = self.device.name - self._humidity = self.device.humidity, + self._humidity = self.device.humidity self._temperature = self.device.temperature self._mode = self.device.mode self._target_temperature = self.device.target diff --git a/homeassistant/components/climate/nuheat.py b/homeassistant/components/climate/nuheat.py index f41812dbaae..39c66ff94f2 100644 --- a/homeassistant/components/climate/nuheat.py +++ b/homeassistant/components/climate/nuheat.py @@ -185,7 +185,7 @@ class NuHeatThermostat(ClimateDevice): self._thermostat.resume_schedule() self._force_update = True - def set_hold_mode(self, hold_mode, **kwargs): + def set_hold_mode(self, hold_mode): """Update the hold mode of the thermostat.""" if hold_mode == MODE_AUTO: schedule_mode = SCHEDULE_RUN diff --git a/homeassistant/components/climate/radiotherm.py b/homeassistant/components/climate/radiotherm.py index 2b31ca93d22..032d85637ef 100644 --- a/homeassistant/components/climate/radiotherm.py +++ b/homeassistant/components/climate/radiotherm.py @@ -183,17 +183,16 @@ class RadioThermostat(ClimateDevice): """List of available fan modes.""" if self._is_model_ct80: return CT80_FAN_OPERATION_LIST - else: - return CT30_FAN_OPERATION_LIST + return CT30_FAN_OPERATION_LIST @property def current_fan_mode(self): """Return whether the fan is on.""" return self._fmode - def set_fan_mode(self, fan): + def set_fan_mode(self, fan_mode): """Turn fan on/off.""" - code = FAN_MODE_TO_CODE.get(fan, None) + code = FAN_MODE_TO_CODE.get(fan_mode, None) if code is not None: self.device.fmode = code diff --git a/homeassistant/components/climate/sensibo.py b/homeassistant/components/climate/sensibo.py index 67113e7c48a..b49d379592f 100644 --- a/homeassistant/components/climate/sensibo.py +++ b/homeassistant/components/climate/sensibo.py @@ -240,13 +240,13 @@ class SensiboClimate(ClimateDevice): def min_temp(self): """Return the minimum temperature.""" return self._temperatures_list[0] \ - if len(self._temperatures_list) else super().min_temp() + if self._temperatures_list else super().min_temp() @property def max_temp(self): """Return the maximum temperature.""" return self._temperatures_list[-1] \ - if len(self._temperatures_list) else super().max_temp() + if self._temperatures_list else super().max_temp() @asyncio.coroutine def async_set_temperature(self, **kwargs): @@ -273,11 +273,11 @@ class SensiboClimate(ClimateDevice): self._id, 'targetTemperature', temperature, self._ac_states) @asyncio.coroutine - def async_set_fan_mode(self, fan): + def async_set_fan_mode(self, fan_mode): """Set new target fan mode.""" with async_timeout.timeout(TIMEOUT): yield from self._client.async_set_ac_state_property( - self._id, 'fanLevel', fan, self._ac_states) + self._id, 'fanLevel', fan_mode, self._ac_states) @asyncio.coroutine def async_set_operation_mode(self, operation_mode): diff --git a/homeassistant/components/climate/tado.py b/homeassistant/components/climate/tado.py index 868511c0ac4..437c8ec3371 100644 --- a/homeassistant/components/climate/tado.py +++ b/homeassistant/components/climate/tado.py @@ -213,6 +213,7 @@ class TadoClimate(ClimateDevice): self._target_temp = temperature self._control_heating() + # pylint: disable=arguments-differ def set_operation_mode(self, readable_operation_mode): """Set new operation mode.""" operation_mode = CONST_MODE_SMART_SCHEDULE diff --git a/homeassistant/components/climate/tesla.py b/homeassistant/components/climate/tesla.py index 459d9c666fd..225c13d975d 100644 --- a/homeassistant/components/climate/tesla.py +++ b/homeassistant/components/climate/tesla.py @@ -51,8 +51,7 @@ class TeslaThermostat(TeslaDevice, ClimateDevice): mode = self.tesla_device.is_hvac_enabled() if mode: return OPERATION_LIST[0] # On - else: - return OPERATION_LIST[1] # Off + return OPERATION_LIST[1] # Off @property def operation_list(self): diff --git a/homeassistant/components/climate/venstar.py b/homeassistant/components/climate/venstar.py index 6db1d53bc50..6e63cc4092b 100644 --- a/homeassistant/components/climate/venstar.py +++ b/homeassistant/components/climate/venstar.py @@ -111,8 +111,7 @@ class VenstarThermostat(ClimateDevice): """Return the unit of measurement, as defined by the API.""" if self._client.tempunits == self._client.TEMPUNITS_F: return TEMP_FAHRENHEIT - else: - return TEMP_CELSIUS + return TEMP_CELSIUS @property def fan_list(self): @@ -143,16 +142,14 @@ class VenstarThermostat(ClimateDevice): return STATE_COOL elif self._client.mode == self._client.MODE_AUTO: return STATE_AUTO - else: - return STATE_OFF + return STATE_OFF @property def current_fan_mode(self): """Return the fan setting.""" if self._client.fan == self._client.FAN_AUTO: return STATE_AUTO - else: - return STATE_ON + return STATE_ON @property def device_state_attributes(self): @@ -169,24 +166,21 @@ class VenstarThermostat(ClimateDevice): return self._client.heattemp elif self._client.mode == self._client.MODE_COOL: return self._client.cooltemp - else: - return None + return None @property def target_temperature_low(self): """Return the lower bound temp if auto mode is on.""" if self._client.mode == self._client.MODE_AUTO: return self._client.heattemp - else: - return None + return None @property def target_temperature_high(self): """Return the upper bound temp if auto mode is on.""" if self._client.mode == self._client.MODE_AUTO: return self._client.cooltemp - else: - return None + return None @property def target_humidity(self): @@ -245,9 +239,9 @@ class VenstarThermostat(ClimateDevice): if not success: _LOGGER.error("Failed to change the temperature") - def set_fan_mode(self, fan): + def set_fan_mode(self, fan_mode): """Set new target fan mode.""" - if fan == STATE_ON: + if fan_mode == STATE_ON: success = self._client.set_fan(self._client.FAN_ON) else: success = self._client.set_fan(self._client.FAN_AUTO) diff --git a/homeassistant/components/climate/vera.py b/homeassistant/components/climate/vera.py index c9d22e41d81..6fb6bc0ff48 100644 --- a/homeassistant/components/climate/vera.py +++ b/homeassistant/components/climate/vera.py @@ -85,13 +85,13 @@ class VeraThermostat(VeraDevice, ClimateDevice): """Return a list of available fan modes.""" return FAN_OPERATION_LIST - def set_fan_mode(self, mode): + def set_fan_mode(self, fan_mode): """Set new target temperature.""" - if mode == FAN_OPERATION_LIST[0]: + if fan_mode == FAN_OPERATION_LIST[0]: self.vera_device.fan_on() - elif mode == FAN_OPERATION_LIST[1]: + elif fan_mode == FAN_OPERATION_LIST[1]: self.vera_device.fan_auto() - elif mode == FAN_OPERATION_LIST[2]: + elif fan_mode == FAN_OPERATION_LIST[2]: return self.vera_device.fan_cycle() @property diff --git a/homeassistant/components/climate/wink.py b/homeassistant/components/climate/wink.py index 50374a32807..8c66567a4aa 100644 --- a/homeassistant/components/climate/wink.py +++ b/homeassistant/components/climate/wink.py @@ -324,9 +324,9 @@ class WinkThermostat(WinkDevice, ClimateDevice): return self.wink.fan_modes() return None - def set_fan_mode(self, fan): + def set_fan_mode(self, fan_mode): """Turn fan on/off.""" - self.wink.set_fan_mode(fan.lower()) + self.wink.set_fan_mode(fan_mode.lower()) def turn_aux_heat_on(self): """Turn auxiliary heater on.""" @@ -486,26 +486,25 @@ class WinkAC(WinkDevice, ClimateDevice): return SPEED_LOW elif speed <= 0.66: return SPEED_MEDIUM - else: - return SPEED_HIGH + return SPEED_HIGH @property def fan_list(self): """Return a list of available fan modes.""" return [SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] - def set_fan_mode(self, fan): + def set_fan_mode(self, fan_mode): """ Set fan speed. The official Wink app only supports 3 modes [low, medium, high] which are equal to [0.33, 0.66, 1.0] respectively. """ - if fan == SPEED_LOW: + if fan_mode == SPEED_LOW: speed = 0.33 - elif fan == SPEED_MEDIUM: + elif fan_mode == SPEED_MEDIUM: speed = 0.66 - elif fan == SPEED_HIGH: + elif fan_mode == SPEED_HIGH: speed = 1.0 self.wink.set_ac_fan_speed(speed) diff --git a/homeassistant/components/climate/zwave.py b/homeassistant/components/climate/zwave.py index acc3eda1194..1eec9c82f3c 100644 --- a/homeassistant/components/climate/zwave.py +++ b/homeassistant/components/climate/zwave.py @@ -198,10 +198,10 @@ class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice): self.values.primary.data = temperature - def set_fan_mode(self, fan): + def set_fan_mode(self, fan_mode): """Set new target fan mode.""" if self.values.fan_mode: - self.values.fan_mode.data = fan + self.values.fan_mode.data = fan_mode def set_operation_mode(self, operation_mode): """Set new target operation mode.""" diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index e17c9ee1b1e..3657b64b989 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -56,10 +56,7 @@ GOOGLE_ENTITY_SCHEMA = vol.Schema({ }) ASSISTANT_SCHEMA = vol.Schema({ - vol.Optional( - CONF_FILTER, - default=lambda: entityfilter.generate_filter([], [], [], []) - ): entityfilter.FILTER_SCHEMA, + vol.Optional(CONF_FILTER, default={}): entityfilter.FILTER_SCHEMA, }) ALEXA_SCHEMA = ASSISTANT_SCHEMA.extend({ @@ -222,7 +219,7 @@ class Cloud: # Fetching keyset can fail if internet is not up yet. if not success: - self.hass.helpers.async_call_later(5, self.async_start) + self.hass.helpers.event.async_call_later(5, self.async_start) return def load_config(): diff --git a/homeassistant/components/cloud/auth_api.py b/homeassistant/components/cloud/auth_api.py index e96f2a2d8a5..118a9857158 100644 --- a/homeassistant/components/cloud/auth_api.py +++ b/homeassistant/components/cloud/auth_api.py @@ -1,7 +1,4 @@ """Package to communicate with the authentication API.""" -import logging - -_LOGGER = logging.getLogger(__name__) class CloudError(Exception): @@ -31,6 +28,8 @@ class InvalidCode(CloudError): class PasswordChangeRequired(CloudError): """Raised when a password change is required.""" + # https://github.com/PyCQA/pylint/issues/1085 + # pylint: disable=useless-super-delegation def __init__(self, message='Password change required.'): """Initialize a password change required error.""" super().__init__(message) diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index af966e180eb..f7f327f2f2c 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -6,8 +6,9 @@ import logging import async_timeout import voluptuous as vol -from homeassistant.components.http import ( - HomeAssistantView, RequestDataValidator) +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http.data_validator import ( + RequestDataValidator) from . import auth_api from .const import DOMAIN, REQUEST_TIMEOUT diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py index 2d3ab025e43..3220fc372f7 100644 --- a/homeassistant/components/cloud/iot.py +++ b/homeassistant/components/cloud/iot.py @@ -44,20 +44,13 @@ class CloudIoT: @asyncio.coroutine def connect(self): """Connect to the IoT broker.""" + if self.state != STATE_DISCONNECTED: + raise RuntimeError('Connect called while not disconnected') + hass = self.cloud.hass - if self.cloud.subscription_expired: - # Try refreshing the token to see if it is still expired. - yield from hass.async_add_job(auth_api.check_token, self.cloud) - - if self.cloud.subscription_expired: - hass.components.persistent_notification.async_create( - MESSAGE_EXPIRATION, 'Subscription expired', - 'cloud_subscription_expired') - self.state = STATE_DISCONNECTED - return - - if self.state == STATE_CONNECTED: - raise RuntimeError('Already connected') + self.close_requested = False + self.state = STATE_CONNECTING + self.tries = 0 @asyncio.coroutine def _handle_hass_stop(event): @@ -66,17 +59,60 @@ class CloudIoT: remove_hass_stop_listener = None yield from self.disconnect() - self.state = STATE_CONNECTING - self.close_requested = False remove_hass_stop_listener = hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, _handle_hass_stop) + + while True: + try: + yield from self._handle_connection() + except Exception: # pylint: disable=broad-except + # Safety net. This should never hit. + # Still adding it here to make sure we can always reconnect + _LOGGER.exception("Unexpected error") + + if self.close_requested: + break + + self.state = STATE_CONNECTING + self.tries += 1 + + try: + # Sleep 0, 5, 10, 15 ... 30 seconds between retries + self.retry_task = hass.async_add_job(asyncio.sleep( + min(30, (self.tries - 1) * 5), loop=hass.loop)) + yield from self.retry_task + self.retry_task = None + except asyncio.CancelledError: + # Happens if disconnect called + break + + self.state = STATE_DISCONNECTED + if remove_hass_stop_listener is not None: + remove_hass_stop_listener() + + @asyncio.coroutine + def _handle_connection(self): + """Connect to the IoT broker.""" + hass = self.cloud.hass + + try: + yield from hass.async_add_job(auth_api.check_token, self.cloud) + except auth_api.CloudError as err: + _LOGGER.warning("Unable to connect: %s", err) + return + + if self.cloud.subscription_expired: + hass.components.persistent_notification.async_create( + MESSAGE_EXPIRATION, 'Subscription expired', + 'cloud_subscription_expired') + self.close_requested = True + return + session = async_get_clientsession(self.cloud.hass) client = None disconnect_warn = None try: - yield from hass.async_add_job(auth_api.check_token, self.cloud) - self.client = client = yield from session.ws_connect( self.cloud.relayer, heartbeat=55, headers={ hdrs.AUTHORIZATION: @@ -90,9 +126,11 @@ class CloudIoT: while not client.closed: msg = yield from client.receive() - if msg.type in (WSMsgType.ERROR, WSMsgType.CLOSED, - WSMsgType.CLOSING): - disconnect_warn = 'Connection cancelled.' + if msg.type in (WSMsgType.CLOSED, WSMsgType.CLOSING): + break + + elif msg.type == WSMsgType.ERROR: + disconnect_warn = 'Connection error' break elif msg.type != WSMsgType.TEXT: @@ -131,9 +169,6 @@ class CloudIoT: _LOGGER.debug("Publishing message: %s", response) yield from client.send_json(response) - except auth_api.CloudError: - _LOGGER.warning("Unable to connect: Unable to refresh token.") - except client_exceptions.WSServerHandshakeError as err: if err.code == 401: disconnect_warn = 'Invalid auth.' @@ -145,38 +180,11 @@ class CloudIoT: except client_exceptions.ClientError as err: _LOGGER.warning("Unable to connect: %s", err) - except Exception: # pylint: disable=broad-except - if not self.close_requested: - _LOGGER.exception("Unexpected error") - finally: - if disconnect_warn is not None: - _LOGGER.warning("Connection closed: %s", disconnect_warn) - - if remove_hass_stop_listener is not None: - remove_hass_stop_listener() - - if client is not None: - self.client = None - yield from client.close() - - if self.close_requested: - self.state = STATE_DISCONNECTED - + if disconnect_warn is None: + _LOGGER.info("Connection closed") else: - self.state = STATE_CONNECTING - self.tries += 1 - - try: - # Sleep 0, 5, 10, 15 ... up to 30 seconds between retries - self.retry_task = hass.async_add_job(asyncio.sleep( - min(30, (self.tries - 1) * 5), loop=hass.loop)) - yield from self.retry_task - self.retry_task = None - hass.async_add_job(self.connect()) - except asyncio.CancelledError: - # Happens if disconnect called - pass + _LOGGER.warning("Connection closed: %s", disconnect_warn) @asyncio.coroutine def disconnect(self): diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index c45e3561c47..39c35205619 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -14,15 +14,23 @@ from homeassistant.util.yaml import load_yaml, dump DOMAIN = 'config' DEPENDENCIES = ['http'] SECTIONS = ('core', 'customize', 'group', 'hassbian', 'automation', 'script') -ON_DEMAND = ('zwave') +ON_DEMAND = ('zwave',) +FEATURE_FLAGS = ('config_entries',) @asyncio.coroutine def async_setup(hass, config): """Set up the config component.""" + global SECTIONS + yield from hass.components.frontend.async_register_built_in_panel( 'config', 'config', 'mdi:settings') + # Temporary way of allowing people to opt-in for unreleased config sections + for key, value in config.get(DOMAIN, {}).items(): + if key in FEATURE_FLAGS and value: + SECTIONS += (key,) + @asyncio.coroutine def setup_panel(panel_name): """Set up a panel.""" @@ -151,7 +159,7 @@ class EditKeyBasedConfigView(BaseEditConfigView): def _get_value(self, hass, data, config_key): """Get value.""" - return data.get(config_key, {}) + return data.get(config_key) def _write_value(self, hass, data, config_key, new_value): """Set value.""" diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py new file mode 100644 index 00000000000..ebfa095372a --- /dev/null +++ b/homeassistant/components/config/config_entries.py @@ -0,0 +1,182 @@ +"""Http views to control the config manager.""" +import asyncio + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http.data_validator import RequestDataValidator + + +REQUIREMENTS = ['voluptuous-serialize==1'] + + +@asyncio.coroutine +def async_setup(hass): + """Enable the Home Assistant views.""" + hass.http.register_view(ConfigManagerEntryIndexView) + hass.http.register_view(ConfigManagerEntryResourceView) + hass.http.register_view(ConfigManagerFlowIndexView) + hass.http.register_view(ConfigManagerFlowResourceView) + hass.http.register_view(ConfigManagerAvailableFlowView) + return True + + +def _prepare_json(result): + """Convert result for JSON.""" + if result['type'] != config_entries.RESULT_TYPE_FORM: + return result + + import voluptuous_serialize + + data = result.copy() + + schema = data['data_schema'] + if schema is None: + data['data_schema'] = [] + else: + data['data_schema'] = voluptuous_serialize.convert(schema) + + return data + + +class ConfigManagerEntryIndexView(HomeAssistantView): + """View to get available config entries.""" + + url = '/api/config/config_entries/entry' + name = 'api:config:config_entries:entry' + + @asyncio.coroutine + def get(self, request): + """List flows in progress.""" + hass = request.app['hass'] + return self.json([{ + 'entry_id': entry.entry_id, + 'domain': entry.domain, + 'title': entry.title, + 'source': entry.source, + 'state': entry.state, + } for entry in hass.config_entries.async_entries()]) + + +class ConfigManagerEntryResourceView(HomeAssistantView): + """View to interact with a config entry.""" + + url = '/api/config/config_entries/entry/{entry_id}' + name = 'api:config:config_entries:entry:resource' + + @asyncio.coroutine + def delete(self, request, entry_id): + """Delete a config entry.""" + hass = request.app['hass'] + + try: + result = yield from hass.config_entries.async_remove(entry_id) + except config_entries.UnknownEntry: + return self.json_message('Invalid entry specified', 404) + + return self.json(result) + + +class ConfigManagerFlowIndexView(HomeAssistantView): + """View to create config flows.""" + + url = '/api/config/config_entries/flow' + name = 'api:config:config_entries:flow' + + @asyncio.coroutine + def get(self, request): + """List flows that are in progress but not started by a user. + + Example of a non-user initiated flow is a discovered Hue hub that + requires user interaction to finish setup. + """ + hass = request.app['hass'] + + return self.json([ + flow for flow in hass.config_entries.flow.async_progress() + if flow['source'] != config_entries.SOURCE_USER]) + + @asyncio.coroutine + @RequestDataValidator(vol.Schema({ + vol.Required('domain'): str, + })) + def post(self, request, data): + """Handle a POST request.""" + hass = request.app['hass'] + + try: + result = yield from hass.config_entries.flow.async_init( + data['domain']) + except config_entries.UnknownHandler: + return self.json_message('Invalid handler specified', 404) + except config_entries.UnknownStep: + return self.json_message('Handler does not support init', 400) + + result = _prepare_json(result) + + return self.json(result) + + +class ConfigManagerFlowResourceView(HomeAssistantView): + """View to interact with the flow manager.""" + + url = '/api/config/config_entries/flow/{flow_id}' + name = 'api:config:config_entries:flow:resource' + + @asyncio.coroutine + def get(self, request, flow_id): + """Get the current state of a flow.""" + hass = request.app['hass'] + + try: + result = yield from hass.config_entries.flow.async_configure( + flow_id) + except config_entries.UnknownFlow: + return self.json_message('Invalid flow specified', 404) + + result = _prepare_json(result) + + return self.json(result) + + @asyncio.coroutine + @RequestDataValidator(vol.Schema(dict), allow_empty=True) + def post(self, request, flow_id, data): + """Handle a POST request.""" + hass = request.app['hass'] + + try: + result = yield from hass.config_entries.flow.async_configure( + flow_id, data) + except config_entries.UnknownFlow: + return self.json_message('Invalid flow specified', 404) + except vol.Invalid: + return self.json_message('User input malformed', 400) + + result = _prepare_json(result) + + return self.json(result) + + @asyncio.coroutine + def delete(self, request, flow_id): + """Cancel a flow in progress.""" + hass = request.app['hass'] + + try: + hass.config_entries.async_abort(flow_id) + except config_entries.UnknownFlow: + return self.json_message('Invalid flow specified', 404) + + return self.json_message('Flow aborted') + + +class ConfigManagerAvailableFlowView(HomeAssistantView): + """View to query available flows.""" + + url = '/api/config/config_entries/flow_handlers' + name = 'api:config:config_entries:flow_handlers' + + @asyncio.coroutine + def get(self, request): + """List available flow handlers.""" + return self.json(config_entries.FLOWS) diff --git a/homeassistant/components/config_entry_example.py b/homeassistant/components/config_entry_example.py new file mode 100644 index 00000000000..2d5ea728ff3 --- /dev/null +++ b/homeassistant/components/config_entry_example.py @@ -0,0 +1,102 @@ +"""Example component to show how config entries work.""" + +import asyncio + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ATTR_FRIENDLY_NAME +from homeassistant.util import slugify + + +DOMAIN = 'config_entry_example' + + +@asyncio.coroutine +def async_setup(hass, config): + """Setup for our example component.""" + return True + + +@asyncio.coroutine +def async_setup_entry(hass, entry): + """Initialize an entry.""" + entity_id = '{}.{}'.format(DOMAIN, entry.data['object_id']) + hass.states.async_set(entity_id, 'loaded', { + ATTR_FRIENDLY_NAME: entry.data['name'] + }) + + # Indicate setup was successful. + return True + + +@asyncio.coroutine +def async_unload_entry(hass, entry): + """Unload an entry.""" + entity_id = '{}.{}'.format(DOMAIN, entry.data['object_id']) + hass.states.async_remove(entity_id) + + # Indicate unload was successful. + return True + + +@config_entries.HANDLERS.register(DOMAIN) +class ExampleConfigFlow(config_entries.ConfigFlowHandler): + """Handle an example configuration flow.""" + + VERSION = 1 + + def __init__(self): + """Initialize a Hue config handler.""" + self.object_id = None + + @asyncio.coroutine + def async_step_init(self, user_input=None): + """Start config flow.""" + errors = None + if user_input is not None: + object_id = user_input['object_id'] + + if object_id != '' and object_id == slugify(object_id): + self.object_id = user_input['object_id'] + return (yield from self.async_step_name()) + + errors = { + 'object_id': 'Invalid object id.' + } + + return self.async_show_form( + title='Pick object id', + step_id='init', + description="Please enter an object_id for the test entity.", + data_schema=vol.Schema({ + 'object_id': str + }), + errors=errors + ) + + @asyncio.coroutine + def async_step_name(self, user_input=None): + """Ask user to enter the name.""" + errors = None + if user_input is not None: + name = user_input['name'] + + if name != '': + return self.async_create_entry( + title=name, + data={ + 'name': name, + 'object_id': self.object_id, + } + ) + + return self.async_show_form( + title='Name of the entity', + step_id='name', + description="Please enter a name for the test entity.", + data_schema=vol.Schema({ + 'name': str + }), + errors=errors + ) diff --git a/homeassistant/components/conversation.py b/homeassistant/components/conversation.py index 5187b4782ef..9f325f3eb89 100644 --- a/homeassistant/components/conversation.py +++ b/homeassistant/components/conversation.py @@ -7,19 +7,17 @@ https://home-assistant.io/components/conversation/ import asyncio import logging import re -import warnings import voluptuous as vol from homeassistant import core from homeassistant.components import http -from homeassistant.const import ( - ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON) +from homeassistant.components.http.data_validator import ( + RequestDataValidator) from homeassistant.helpers import config_validation as cv from homeassistant.helpers import intent -from homeassistant.loader import bind_hass -REQUIREMENTS = ['fuzzywuzzy==0.16.0'] +from homeassistant.loader import bind_hass _LOGGER = logging.getLogger(__name__) @@ -28,9 +26,6 @@ ATTR_TEXT = 'text' DEPENDENCIES = ['http'] DOMAIN = 'conversation' -INTENT_TURN_OFF = 'HassTurnOff' -INTENT_TURN_ON = 'HassTurnOn' - REGEX_TURN_COMMAND = re.compile(r'turn (?P(?: |\w)+) (?P\w+)') REGEX_TYPE = type(re.compile('')) @@ -50,7 +45,7 @@ CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({ @core.callback @bind_hass def async_register(hass, intent_type, utterances): - """Register an intent. + """Register utterances and any custom intents. Registrations don't require conversations to be loaded. They will become active once the conversation component is loaded. @@ -75,8 +70,6 @@ def async_register(hass, intent_type, utterances): @asyncio.coroutine def async_setup(hass, config): """Register the process service.""" - warnings.filterwarnings('ignore', module='fuzzywuzzy') - config = config.get(DOMAIN, {}) intents = hass.data.get(DOMAIN) @@ -102,12 +95,12 @@ def async_setup(hass, config): hass.http.register_view(ConversationProcessView) - hass.helpers.intent.async_register(TurnOnIntent()) - hass.helpers.intent.async_register(TurnOffIntent()) - async_register(hass, INTENT_TURN_ON, + async_register(hass, intent.INTENT_TURN_ON, ['Turn {name} on', 'Turn on {name}']) - async_register(hass, INTENT_TURN_OFF, [ - 'Turn {name} off', 'Turn off {name}']) + async_register(hass, intent.INTENT_TURN_OFF, + ['Turn {name} off', 'Turn off {name}']) + async_register(hass, intent.INTENT_TOGGLE, + ['Toggle {name}', '{name} toggle']) return True @@ -151,86 +144,13 @@ def _process(hass, text): return response -@core.callback -def _match_entity(hass, name): - """Match a name to an entity.""" - from fuzzywuzzy import process as fuzzyExtract - entities = {state.entity_id: state.name for state - in hass.states.async_all()} - entity_id = fuzzyExtract.extractOne( - name, entities, score_cutoff=65)[2] - return hass.states.get(entity_id) if entity_id else None - - -class TurnOnIntent(intent.IntentHandler): - """Handle turning item on intents.""" - - intent_type = INTENT_TURN_ON - slot_schema = { - 'name': cv.string, - } - - @asyncio.coroutine - def async_handle(self, intent_obj): - """Handle turn on intent.""" - hass = intent_obj.hass - slots = self.async_validate_slots(intent_obj.slots) - name = slots['name']['value'] - entity = _match_entity(hass, name) - - if not entity: - _LOGGER.error("Could not find entity id for %s", name) - return None - - yield from hass.services.async_call( - core.DOMAIN, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: entity.entity_id, - }, blocking=True) - - response = intent_obj.create_response() - response.async_set_speech( - 'Turned on {}'.format(entity.name)) - return response - - -class TurnOffIntent(intent.IntentHandler): - """Handle turning item off intents.""" - - intent_type = INTENT_TURN_OFF - slot_schema = { - 'name': cv.string, - } - - @asyncio.coroutine - def async_handle(self, intent_obj): - """Handle turn off intent.""" - hass = intent_obj.hass - slots = self.async_validate_slots(intent_obj.slots) - name = slots['name']['value'] - entity = _match_entity(hass, name) - - if not entity: - _LOGGER.error("Could not find entity id for %s", name) - return None - - yield from hass.services.async_call( - core.DOMAIN, SERVICE_TURN_OFF, { - ATTR_ENTITY_ID: entity.entity_id, - }, blocking=True) - - response = intent_obj.create_response() - response.async_set_speech( - 'Turned off {}'.format(entity.name)) - return response - - class ConversationProcessView(http.HomeAssistantView): """View to retrieve shopping list content.""" url = '/api/conversation/process' name = "api:conversation:process" - @http.RequestDataValidator(vol.Schema({ + @RequestDataValidator(vol.Schema({ vol.Required('text'): str, })) @asyncio.coroutine diff --git a/homeassistant/components/cover/demo.py b/homeassistant/components/cover/demo.py index 827b50c8af9..70e681f1120 100644 --- a/homeassistant/components/cover/demo.py +++ b/homeassistant/components/cover/demo.py @@ -5,7 +5,8 @@ For more details about this platform, please refer to the documentation https://home-assistant.io/components/demo/ """ from homeassistant.components.cover import ( - CoverDevice, SUPPORT_OPEN, SUPPORT_CLOSE) + CoverDevice, SUPPORT_OPEN, SUPPORT_CLOSE, ATTR_POSITION, + ATTR_TILT_POSITION) from homeassistant.helpers.event import track_utc_time_change @@ -137,8 +138,9 @@ class DemoCover(CoverDevice): self._listen_cover_tilt() self._requested_closing_tilt = False - def set_cover_position(self, position, **kwargs): + def set_cover_position(self, **kwargs): """Move the cover to a specific position.""" + position = kwargs.get(ATTR_POSITION) self._set_position = round(position, -1) if self._position == position: return @@ -146,8 +148,9 @@ class DemoCover(CoverDevice): self._listen_cover() self._requested_closing = position < self._position - def set_cover_tilt_position(self, tilt_position, **kwargs): + def set_cover_tilt_position(self, **kwargs): """Move the cover til to a specific position.""" + tilt_position = kwargs.get(ATTR_TILT_POSITION) self._set_tilt_position = round(tilt_position, -1) if self._tilt_position == tilt_position: return diff --git a/homeassistant/components/cover/garadget.py b/homeassistant/components/cover/garadget.py index 22f5fd889a2..c19aa69c8f0 100644 --- a/homeassistant/components/cover/garadget.py +++ b/homeassistant/components/cover/garadget.py @@ -201,21 +201,21 @@ class GaradgetCover(CoverDevice): """Check the state of the service during an operation.""" self.schedule_update_ha_state(True) - def close_cover(self): + def close_cover(self, **kwargs): """Close the cover.""" if self._state not in ['close', 'closing']: ret = self._put_command('setState', 'close') self._start_watcher('close') return ret.get('return_value') == 1 - def open_cover(self): + def open_cover(self, **kwargs): """Open the cover.""" if self._state not in ['open', 'opening']: ret = self._put_command('setState', 'open') self._start_watcher('open') return ret.get('return_value') == 1 - def stop_cover(self): + def stop_cover(self, **kwargs): """Stop the door where it is.""" if self._state not in ['stopped']: ret = self._put_command('setState', 'stop') diff --git a/homeassistant/components/cover/isy994.py b/homeassistant/components/cover/isy994.py index 7d77b1bc3be..82ca60e84e6 100644 --- a/homeassistant/components/cover/isy994.py +++ b/homeassistant/components/cover/isy994.py @@ -42,10 +42,6 @@ def setup_platform(hass, config: ConfigType, class ISYCoverDevice(ISYDevice, CoverDevice): """Representation of an ISY994 cover device.""" - def __init__(self, node: object) -> None: - """Initialize the ISY994 cover device.""" - super().__init__(node) - @property def current_cover_position(self) -> int: """Return the current cover position.""" @@ -61,8 +57,7 @@ class ISYCoverDevice(ISYDevice, CoverDevice): """Get the state of the ISY994 cover device.""" if self.is_unknown(): return None - else: - return VALUE_TO_STATE.get(self.value, STATE_OPEN) + return VALUE_TO_STATE.get(self.value, STATE_OPEN) def open_cover(self, **kwargs) -> None: """Send the open cover command to the ISY994 cover device.""" diff --git a/homeassistant/components/cover/knx.py b/homeassistant/components/cover/knx.py index a6cd1263a73..730a2b29a2e 100644 --- a/homeassistant/components/cover/knx.py +++ b/homeassistant/components/cover/knx.py @@ -53,9 +53,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up cover(s) for KNX platform.""" - if DATA_KNX not in hass.data or not hass.data[DATA_KNX].initialized: - return - if discovery_info is not None: async_add_devices_discovery(hass, discovery_info, async_add_devices) else: diff --git a/homeassistant/components/cover/mqtt.py b/homeassistant/components/cover/mqtt.py index e55072dbc73..0f31d3a9fe0 100644 --- a/homeassistant/components/cover/mqtt.py +++ b/homeassistant/components/cover/mqtt.py @@ -65,9 +65,9 @@ TILT_FEATURES = (SUPPORT_OPEN_TILT | SUPPORT_CLOSE_TILT | SUPPORT_STOP_TILT | SUPPORT_SET_TILT_POSITION) PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_COMMAND_TOPIC, default=None): valid_publish_topic, - vol.Optional(CONF_POSITION_TOPIC, default=None): valid_publish_topic, - vol.Optional(CONF_SET_POSITION_TEMPLATE, default=None): cv.template, + vol.Optional(CONF_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_POSITION_TOPIC): valid_publish_topic, + vol.Optional(CONF_SET_POSITION_TEMPLATE): cv.template, vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, vol.Optional(CONF_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, @@ -78,8 +78,8 @@ PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_STATE_OPEN, default=STATE_OPEN): cv.string, vol.Optional(CONF_STATE_CLOSED, default=STATE_CLOSED): cv.string, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, - vol.Optional(CONF_TILT_COMMAND_TOPIC, default=None): valid_publish_topic, - vol.Optional(CONF_TILT_STATUS_TOPIC, default=None): valid_subscribe_topic, + vol.Optional(CONF_TILT_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_TILT_STATUS_TOPIC): valid_subscribe_topic, vol.Optional(CONF_TILT_CLOSED_POSITION, default=DEFAULT_TILT_CLOSED_POSITION): int, vol.Optional(CONF_TILT_OPEN_POSITION, diff --git a/homeassistant/components/cover/myq.py b/homeassistant/components/cover/myq.py index 8d59a90278c..f07d3849fae 100644 --- a/homeassistant/components/cover/myq.py +++ b/homeassistant/components/cover/myq.py @@ -84,11 +84,11 @@ class MyQDevice(CoverDevice): """Return true if cover is closed, else False.""" return self._status == STATE_CLOSED - def close_cover(self): + def close_cover(self, **kwargs): """Issue close command to cover.""" self.myq.close_device(self.device_id) - def open_cover(self): + def open_cover(self, **kwargs): """Issue open command to cover.""" self.myq.open_device(self.device_id) diff --git a/homeassistant/components/cover/opengarage.py b/homeassistant/components/cover/opengarage.py index d98c71e25fb..d68021d7db3 100644 --- a/homeassistant/components/cover/opengarage.py +++ b/homeassistant/components/cover/opengarage.py @@ -115,18 +115,18 @@ class OpenGarageCover(CoverDevice): @property def is_closed(self): """Return if the cover is closed.""" - if self._state == STATE_UNKNOWN: + if self._state in [STATE_UNKNOWN, STATE_OFFLINE]: return None return self._state in [STATE_CLOSED, STATE_OPENING] - def close_cover(self): + def close_cover(self, **kwargs): """Close the cover.""" if self._state not in [STATE_CLOSED, STATE_CLOSING]: self._state_before_move = self._state self._state = STATE_CLOSING self._push_button() - def open_cover(self): + def open_cover(self, **kwargs): """Open the cover.""" if self._state not in [STATE_OPEN, STATE_OPENING]: self._state_before_move = self._state diff --git a/homeassistant/components/cover/rpi_gpio.py b/homeassistant/components/cover/rpi_gpio.py index 981312140eb..77cd0b0f7e2 100644 --- a/homeassistant/components/cover/rpi_gpio.py +++ b/homeassistant/components/cover/rpi_gpio.py @@ -109,12 +109,12 @@ class RPiGPIOCover(CoverDevice): sleep(self._relay_time) rpi_gpio.write_output(self._relay_pin, not self._invert_relay) - def close_cover(self): + def close_cover(self, **kwargs): """Close the cover.""" if not self.is_closed: self._trigger() - def open_cover(self): + def open_cover(self, **kwargs): """Open the cover.""" if self.is_closed: self._trigger() diff --git a/homeassistant/components/cover/services.yaml b/homeassistant/components/cover/services.yaml index 1a3e020ed87..79f00180a89 100644 --- a/homeassistant/components/cover/services.yaml +++ b/homeassistant/components/cover/services.yaml @@ -1,63 +1,63 @@ -# Describes the format for available cover services - -open_cover: - description: Open all or specified cover. - fields: - entity_id: - description: Name(s) of cover(s) to open. - example: 'cover.living_room' - -close_cover: - description: Close all or specified cover. - fields: - entity_id: - description: Name(s) of cover(s) to close. - example: 'cover.living_room' - -set_cover_position: - description: Move to specific position all or specified cover. - fields: - entity_id: - description: Name(s) of cover(s) to set cover position. - example: 'cover.living_room' - position: - description: Position of the cover (0 to 100). - example: 30 - -stop_cover: - description: Stop all or specified cover. - fields: - entity_id: - description: Name(s) of cover(s) to stop. - example: 'cover.living_room' - -open_cover_tilt: - description: Open all or specified cover tilt. - fields: - entity_id: - description: Name(s) of cover(s) tilt to open. - example: 'cover.living_room' - -close_cover_tilt: - description: Close all or specified cover tilt. - fields: - entity_id: - description: Name(s) of cover(s) to close tilt. - example: 'cover.living_room' - -set_cover_tilt_position: - description: Move to specific position all or specified cover tilt. - fields: - entity_id: - description: Name(s) of cover(s) to set cover tilt position. - example: 'cover.living_room' - tilt_position: - description: Tilt position of the cover (0 to 100). - example: 30 - -stop_cover_tilt: - description: Stop all or specified cover. - fields: - entity_id: - description: Name(s) of cover(s) to stop. - example: 'cover.living_room' +# Describes the format for available cover services + +open_cover: + description: Open all or specified cover. + fields: + entity_id: + description: Name(s) of cover(s) to open. + example: 'cover.living_room' + +close_cover: + description: Close all or specified cover. + fields: + entity_id: + description: Name(s) of cover(s) to close. + example: 'cover.living_room' + +set_cover_position: + description: Move to specific position all or specified cover. + fields: + entity_id: + description: Name(s) of cover(s) to set cover position. + example: 'cover.living_room' + position: + description: Position of the cover (0 to 100). + example: 30 + +stop_cover: + description: Stop all or specified cover. + fields: + entity_id: + description: Name(s) of cover(s) to stop. + example: 'cover.living_room' + +open_cover_tilt: + description: Open all or specified cover tilt. + fields: + entity_id: + description: Name(s) of cover(s) tilt to open. + example: 'cover.living_room' + +close_cover_tilt: + description: Close all or specified cover tilt. + fields: + entity_id: + description: Name(s) of cover(s) to close tilt. + example: 'cover.living_room' + +set_cover_tilt_position: + description: Move to specific position all or specified cover tilt. + fields: + entity_id: + description: Name(s) of cover(s) to set cover tilt position. + example: 'cover.living_room' + tilt_position: + description: Tilt position of the cover (0 to 100). + example: 30 + +stop_cover_tilt: + description: Stop all or specified cover. + fields: + entity_id: + description: Name(s) of cover(s) to stop. + example: 'cover.living_room' diff --git a/homeassistant/components/cover/tahoma.py b/homeassistant/components/cover/tahoma.py index 19bd9f01417..6fb8e92e051 100644 --- a/homeassistant/components/cover/tahoma.py +++ b/homeassistant/components/cover/tahoma.py @@ -6,7 +6,7 @@ https://home-assistant.io/components/cover.tahoma/ """ import logging -from homeassistant.components.cover import CoverDevice +from homeassistant.components.cover import CoverDevice, ATTR_POSITION from homeassistant.components.tahoma import ( DOMAIN as TAHOMA_DOMAIN, TahomaDevice) @@ -49,9 +49,9 @@ class TahomaCover(TahomaDevice, CoverDevice): except KeyError: return None - def set_cover_position(self, position, **kwargs): + def set_cover_position(self, **kwargs): """Move the cover to a specific position.""" - self.apply_action('setPosition', 100 - position) + self.apply_action('setPosition', 100 - kwargs.get(ATTR_POSITION)) @property def is_closed(self): @@ -64,8 +64,7 @@ class TahomaCover(TahomaDevice, CoverDevice): """Return the class of the device.""" if self.tahoma_device.type == 'io:WindowOpenerVeluxIOComponent': return 'window' - else: - return None + return None def open_cover(self, **kwargs): """Open the cover.""" diff --git a/homeassistant/components/cover/vera.py b/homeassistant/components/cover/vera.py index 6cf269b75b3..ff9ba6f762b 100644 --- a/homeassistant/components/cover/vera.py +++ b/homeassistant/components/cover/vera.py @@ -6,7 +6,8 @@ https://home-assistant.io/components/cover.vera/ """ import logging -from homeassistant.components.cover import CoverDevice, ENTITY_ID_FORMAT +from homeassistant.components.cover import CoverDevice, ENTITY_ID_FORMAT, \ + ATTR_POSITION from homeassistant.components.vera import ( VERA_CONTROLLER, VERA_DEVICES, VeraDevice) @@ -44,9 +45,9 @@ class VeraCover(VeraDevice, CoverDevice): return 100 return position - def set_cover_position(self, position, **kwargs): + def set_cover_position(self, **kwargs): """Move the cover to a specific position.""" - self.vera_device.set_level(position) + self.vera_device.set_level(kwargs.get(ATTR_POSITION)) self.schedule_update_ha_state() @property diff --git a/homeassistant/components/cover/wink.py b/homeassistant/components/cover/wink.py index 35f14e80b5b..093ccd43473 100644 --- a/homeassistant/components/cover/wink.py +++ b/homeassistant/components/cover/wink.py @@ -6,7 +6,8 @@ https://home-assistant.io/components/cover.wink/ """ import asyncio -from homeassistant.components.cover import CoverDevice, STATE_UNKNOWN +from homeassistant.components.cover import CoverDevice, STATE_UNKNOWN, \ + ATTR_POSITION from homeassistant.components.wink import WinkDevice, DOMAIN DEPENDENCIES = ['wink'] @@ -42,17 +43,17 @@ class WinkCoverDevice(WinkDevice, CoverDevice): """Open the cover.""" self.wink.set_state(1) - def set_cover_position(self, position, **kwargs): + def set_cover_position(self, **kwargs): """Move the cover shutter to a specific position.""" - self.wink.set_state(float(position)/100) + position = kwargs.get(ATTR_POSITION) + self.wink.set_state(position/100) @property def current_cover_position(self): """Return the current position of cover shutter.""" if self.wink.state() is not None: return int(self.wink.state()*100) - else: - return STATE_UNKNOWN + return STATE_UNKNOWN @property def is_closed(self): diff --git a/homeassistant/components/cover/xiaomi_aqara.py b/homeassistant/components/cover/xiaomi_aqara.py index 29cb707fef5..14321149148 100644 --- a/homeassistant/components/cover/xiaomi_aqara.py +++ b/homeassistant/components/cover/xiaomi_aqara.py @@ -1,7 +1,7 @@ """Support for Xiaomi curtain.""" import logging -from homeassistant.components.cover import CoverDevice +from homeassistant.components.cover import CoverDevice, ATTR_POSITION from homeassistant.components.xiaomi_aqara import (PY_XIAOMI_GATEWAY, XiaomiDevice) @@ -55,8 +55,9 @@ class XiaomiGenericCover(XiaomiDevice, CoverDevice): """Stop the cover.""" self._write_to_hub(self._sid, **{self._data_key['status']: 'stop'}) - def set_cover_position(self, position, **kwargs): + def set_cover_position(self, **kwargs): """Move the cover to a specific position.""" + position = kwargs.get(ATTR_POSITION) self._write_to_hub(self._sid, **{self._data_key['pos']: str(position)}) def parse_data(self, data, raw_data): diff --git a/homeassistant/components/cover/zwave.py b/homeassistant/components/cover/zwave.py index 15100957242..6f4a11684bd 100644 --- a/homeassistant/components/cover/zwave.py +++ b/homeassistant/components/cover/zwave.py @@ -8,7 +8,7 @@ https://home-assistant.io/components/cover.zwave/ # pylint: disable=import-error import logging from homeassistant.components.cover import ( - DOMAIN, SUPPORT_OPEN, SUPPORT_CLOSE) + DOMAIN, SUPPORT_OPEN, SUPPORT_CLOSE, ATTR_POSITION) from homeassistant.components.zwave import ZWaveDeviceEntity from homeassistant.components import zwave from homeassistant.components.zwave import async_setup_platform # noqa # pylint: disable=unused-import @@ -97,9 +97,10 @@ class ZwaveRollershutter(zwave.ZWaveDeviceEntity, CoverDevice): """Move the roller shutter down.""" self._network.manager.pressButton(self._close_id) - def set_cover_position(self, position, **kwargs): + def set_cover_position(self, **kwargs): """Move the roller shutter to a specific position.""" - self.node.set_dimmer(self.values.primary.value_id, position) + self.node.set_dimmer(self.values.primary.value_id, + kwargs.get(ATTR_POSITION)) def stop_cover(self, **kwargs): """Stop the roller shutter.""" @@ -139,11 +140,11 @@ class ZwaveGarageDoorSwitch(ZwaveGarageDoorBase): """Return the current position of Zwave garage door.""" return not self._state - def close_cover(self): + def close_cover(self, **kwargs): """Close the garage door.""" self.values.primary.data = False - def open_cover(self): + def open_cover(self, **kwargs): """Open the garage door.""" self.values.primary.data = True @@ -166,10 +167,10 @@ class ZwaveGarageDoorBarrier(ZwaveGarageDoorBase): """Return the current position of Zwave garage door.""" return self._state == "Closed" - def close_cover(self): + def close_cover(self, **kwargs): """Close the garage door.""" self.values.primary.data = "Closed" - def open_cover(self): + def open_cover(self, **kwargs): """Open the garage door.""" self.values.primary.data = "Opened" diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index 9d7d253c328..693f3e4470a 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -4,6 +4,7 @@ Support for deCONZ devices. For more details about this component, please refer to the documentation at https://home-assistant.io/components/deconz/ """ + import asyncio import logging @@ -17,11 +18,12 @@ from homeassistant.helpers import discovery from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util.json import load_json, save_json -REQUIREMENTS = ['pydeconz==27'] +REQUIREMENTS = ['pydeconz==30'] _LOGGER = logging.getLogger(__name__) DOMAIN = 'deconz' +DATA_DECONZ_ID = 'deconz_entities' CONFIG_FILE = 'deconz.conf' @@ -34,13 +36,16 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) SERVICE_FIELD = 'field' +SERVICE_ENTITY = 'entity' SERVICE_DATA = 'data' SERVICE_SCHEMA = vol.Schema({ - vol.Required(SERVICE_FIELD): cv.string, + vol.Exclusive(SERVICE_FIELD, 'deconz_id'): cv.string, + vol.Exclusive(SERVICE_ENTITY, 'deconz_id'): cv.entity_id, vol.Required(SERVICE_DATA): dict, }) + CONFIG_INSTRUCTIONS = """ Unlock your deCONZ gateway to register with Home Assistant. @@ -100,6 +105,7 @@ def async_setup_deconz(hass, config, deconz_config): return False hass.data[DOMAIN] = deconz + hass.data[DATA_DECONZ_ID] = {} for component in ['binary_sensor', 'light', 'scene', 'sensor']: hass.async_add_job(discovery.async_load_platform( @@ -112,6 +118,7 @@ def async_setup_deconz(hass, config, deconz_config): Field is a string representing a specific device in deCONZ e.g. field='/lights/1/state'. + Entity_id can be used to retrieve the proper field. Data is a json object with what data you want to alter e.g. data={'on': true}. { @@ -121,9 +128,17 @@ def async_setup_deconz(hass, config, deconz_config): See Dresden Elektroniks REST API documentation for details: http://dresden-elektronik.github.io/deconz-rest-doc/rest/ """ - deconz = hass.data[DOMAIN] field = call.data.get(SERVICE_FIELD) + entity_id = call.data.get(SERVICE_ENTITY) data = call.data.get(SERVICE_DATA) + deconz = hass.data[DOMAIN] + if entity_id: + entities = hass.data.get(DATA_DECONZ_ID) + if entities: + field = entities.get(entity_id) + if field is None: + _LOGGER.error('Could not find the entity %s', entity_id) + return yield from deconz.async_put_state(field, data) hass.services.async_register( DOMAIN, 'configure', async_configure, schema=SERVICE_SCHEMA) diff --git a/homeassistant/components/deconz/services.yaml b/homeassistant/components/deconz/services.yaml index 2e6593c6ea0..78bf7041a93 100644 --- a/homeassistant/components/deconz/services.yaml +++ b/homeassistant/components/deconz/services.yaml @@ -1,10 +1,13 @@ configure: - description: Set attribute of device in Deconz. See Dresden Elektroniks REST API documentation for details http://dresden-elektronik.github.io/deconz-rest-doc/rest/ + description: Set attribute of device in deCONZ. See https://home-assistant.io/components/deconz/#device-services for details. fields: field: - description: Field is a string representing a specific device in Deconz. + description: Field is a string representing a specific device in deCONZ. example: '/lights/1/state' + entity: + description: Entity id representing a specific device in deCONZ. + example: 'light.rgb_light' data: description: Data is a json object with what data you want to alter. example: '{"on": true}' diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 2adee1e2330..19ab77350f3 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -99,17 +99,17 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({ @bind_hass -def is_on(hass: HomeAssistantType, entity_id: str=None): +def is_on(hass: HomeAssistantType, entity_id: str = None): """Return the state if any or a specified device is home.""" entity = entity_id or ENTITY_ID_ALL_DEVICES return hass.states.is_state(entity, STATE_HOME) -def see(hass: HomeAssistantType, mac: str=None, dev_id: str=None, - host_name: str=None, location_name: str=None, - gps: GPSType=None, gps_accuracy=None, - battery=None, attributes: dict=None): +def see(hass: HomeAssistantType, mac: str = None, dev_id: str = None, + host_name: str = None, location_name: str = None, + gps: GPSType = None, gps_accuracy=None, + battery=None, attributes: dict = None): """Call service to notify you see device.""" data = {key: value for key, value in ((ATTR_MAC, mac), @@ -239,11 +239,11 @@ class DeviceTracker(object): _LOGGER.warning('Duplicate device MAC addresses detected %s', dev.mac) - def see(self, mac: str=None, dev_id: str=None, host_name: str=None, - location_name: str=None, gps: GPSType=None, gps_accuracy=None, - battery: str=None, attributes: dict=None, - source_type: str=SOURCE_TYPE_GPS, picture: str=None, - icon: str=None): + def see(self, mac: str = None, dev_id: str = None, host_name: str = None, + location_name: str = None, gps: GPSType = None, gps_accuracy=None, + battery: str = None, attributes: dict = None, + source_type: str = SOURCE_TYPE_GPS, picture: str = None, + icon: str = None): """Notify the device tracker that you see a device.""" self.hass.add_job( self.async_see(mac, dev_id, host_name, location_name, gps, @@ -252,11 +252,11 @@ class DeviceTracker(object): ) @asyncio.coroutine - def async_see(self, mac: str=None, dev_id: str=None, host_name: str=None, - location_name: str=None, gps: GPSType=None, - gps_accuracy=None, battery: str=None, attributes: dict=None, - source_type: str=SOURCE_TYPE_GPS, picture: str=None, - icon: str=None): + def async_see(self, mac: str = None, dev_id: str = None, + host_name: str = None, location_name: str = None, + gps: GPSType = None, gps_accuracy=None, battery: str = None, + attributes: dict = None, source_type: str = SOURCE_TYPE_GPS, + picture: str = None, icon: str = None): """Notify the device tracker that you see a device. This method is a coroutine. @@ -396,9 +396,9 @@ class Device(Entity): _state = STATE_NOT_HOME def __init__(self, hass: HomeAssistantType, consider_home: timedelta, - track: bool, dev_id: str, mac: str, name: str=None, - picture: str=None, gravatar: str=None, icon: str=None, - hide_if_away: bool=False, vendor: str=None) -> None: + track: bool, dev_id: str, mac: str, name: str = None, + picture: str = None, gravatar: str = None, icon: str = None, + hide_if_away: bool = False, vendor: str = None) -> None: """Initialize a device.""" self.hass = hass self.entity_id = ENTITY_ID_FORMAT.format(dev_id) @@ -475,9 +475,10 @@ class Device(Entity): return self.away_hide and self.state != STATE_HOME @asyncio.coroutine - def async_seen(self, host_name: str=None, location_name: str=None, - gps: GPSType=None, gps_accuracy=0, battery: str=None, - attributes: dict=None, source_type: str=SOURCE_TYPE_GPS): + def async_seen(self, host_name: str = None, location_name: str = None, + gps: GPSType = None, gps_accuracy=0, battery: str = None, + attributes: dict = None, + source_type: str = SOURCE_TYPE_GPS): """Mark the device as seen.""" self.source_type = source_type self.last_seen = dt_util.utcnow() @@ -504,7 +505,7 @@ class Device(Entity): # pylint: disable=not-an-iterable yield from self.async_update() - def stale(self, now: dt_util.dt.datetime=None): + def stale(self, now: dt_util.dt.datetime = None): """Return if device state is stale. Async friendly. @@ -621,16 +622,16 @@ class DeviceScanner(object): """ return self.hass.async_add_job(self.scan_devices) - def get_device_name(self, mac: str) -> str: - """Get device name from mac.""" + def get_device_name(self, device: str) -> str: + """Get the name of a device.""" raise NotImplementedError() - def async_get_device_name(self, mac: str) -> Any: - """Get device name from mac. + def async_get_device_name(self, device: str) -> Any: + """Get the name of a device. This method must be run in the event loop and returns a coroutine. """ - return self.hass.async_add_job(self.get_device_name, mac) + return self.hass.async_add_job(self.get_device_name, device) def load_config(path: str, hass: HomeAssistantType, consider_home: timedelta): @@ -648,8 +649,7 @@ def async_load_config(path: str, hass: HomeAssistantType, """ dev_schema = vol.Schema({ vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_ICON, default=False): - vol.Any(None, cv.icon), + vol.Optional(CONF_ICON, default=None): vol.Any(None, cv.icon), vol.Optional('track', default=False): cv.boolean, vol.Optional(CONF_MAC, default=None): vol.Any(None, vol.All(cv.string, vol.Upper)), diff --git a/homeassistant/components/device_tracker/asuswrt.py b/homeassistant/components/device_tracker/asuswrt.py index fb47b26a687..1956e42cb78 100644 --- a/homeassistant/components/device_tracker/asuswrt.py +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -63,6 +63,7 @@ _IP_NEIGH_REGEX = re.compile( r'\w+\s' r'(\w+\s(?P(([0-9a-f]{2}[:-]){5}([0-9a-f]{2}))))?\s' r'\s?(router)?' + r'\s?(nud)?' r'(?P(\w+))') _ARP_CMD = 'arp -n' @@ -118,11 +119,10 @@ class AsusWrtDeviceScanner(DeviceScanner): if self.protocol == 'ssh': self.connection = SshConnection( self.host, self.port, self.username, self.password, - self.ssh_key, self.mode == 'ap') + self.ssh_key) else: self.connection = TelnetConnection( - self.host, self.port, self.username, self.password, - self.mode == 'ap') + self.host, self.port, self.username, self.password) self.last_results = {} @@ -212,6 +212,9 @@ class AsusWrtDeviceScanner(DeviceScanner): result = _parse_lines(lines, _IP_NEIGH_REGEX) devices = {} for device in result: + status = device['status'] + if status is None or status.upper() != 'REACHABLE': + continue if device['mac'] is not None: mac = device['mac'].upper() old_device = cur_devices.get(mac) @@ -226,7 +229,7 @@ class AsusWrtDeviceScanner(DeviceScanner): result = _parse_lines(lines, _ARP_REGEX) devices = {} for device in result: - if device['mac']: + if device['mac'] is not None: mac = device['mac'].upper() devices[mac] = Device(mac, device['ip'], None) return devices @@ -253,7 +256,7 @@ class _Connection: class SshConnection(_Connection): """Maintains an SSH connection to an ASUS-WRT router.""" - def __init__(self, host, port, username, password, ssh_key, ap): + def __init__(self, host, port, username, password, ssh_key): """Initialize the SSH connection properties.""" super().__init__() @@ -263,7 +266,6 @@ class SshConnection(_Connection): self._username = username self._password = password self._ssh_key = ssh_key - self._ap = ap def run_command(self, command): """Run commands through an SSH connection. @@ -323,7 +325,7 @@ class SshConnection(_Connection): class TelnetConnection(_Connection): """Maintains a Telnet connection to an ASUS-WRT router.""" - def __init__(self, host, port, username, password, ap): + def __init__(self, host, port, username, password): """Initialize the Telnet connection properties.""" super().__init__() @@ -332,7 +334,6 @@ class TelnetConnection(_Connection): self._port = port self._username = username self._password = password - self._ap = ap self._prompt_string = None def run_command(self, command): diff --git a/homeassistant/components/device_tracker/automatic.py b/homeassistant/components/device_tracker/automatic.py index 5ad3995ad2a..607f236f920 100644 --- a/homeassistant/components/device_tracker/automatic.py +++ b/homeassistant/components/device_tracker/automatic.py @@ -23,7 +23,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval -REQUIREMENTS = ['aioautomatic==0.6.4'] +REQUIREMENTS = ['aioautomatic==0.6.5'] _LOGGER = logging.getLogger(__name__) @@ -49,8 +49,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_CLIENT_ID): cv.string, vol.Required(CONF_SECRET): cv.string, vol.Optional(CONF_CURRENT_LOCATION, default=False): cv.boolean, - vol.Optional(CONF_DEVICES, default=None): - vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_DEVICES): vol.All(cv.ensure_list, [cv.string]), }) @@ -109,7 +108,7 @@ def async_setup_scanner(hass, config, async_see, discovery_info=None): _write_refresh_token_to_file, hass, filename, session.refresh_token) data = AutomaticData( - hass, client, session, config[CONF_DEVICES], async_see) + hass, client, session, config.get(CONF_DEVICES), async_see) # Load the initial vehicle data vehicles = yield from session.get_vehicles() @@ -177,10 +176,9 @@ class AutomaticAuthCallbackView(HomeAssistantView): _LOGGER.error( "Error authorizing Automatic: %s", params['error']) return response - else: - _LOGGER.error( - "Error authorizing Automatic. Invalid response returned") - return response + _LOGGER.error( + "Error authorizing Automatic. Invalid response returned") + return response if DATA_CONFIGURING not in hass.data or \ params['state'] not in hass.data[DATA_CONFIGURING]: diff --git a/homeassistant/components/device_tracker/bbox.py b/homeassistant/components/device_tracker/bbox.py index 23a94d093e2..6d870364dcb 100644 --- a/homeassistant/components/device_tracker/bbox.py +++ b/homeassistant/components/device_tracker/bbox.py @@ -45,10 +45,10 @@ class BboxDeviceScanner(DeviceScanner): return [device.mac for device in self.last_results] - def get_device_name(self, mac): + def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" - filter_named = [device.name for device in self.last_results if - device.mac == mac] + filter_named = [result.name for result in self.last_results if + result.mac == device] if filter_named: return filter_named[0] diff --git a/homeassistant/components/device_tracker/bluetooth_le_tracker.py b/homeassistant/components/device_tracker/bluetooth_le_tracker.py index 19582822913..d9cda24b699 100644 --- a/homeassistant/components/device_tracker/bluetooth_le_tracker.py +++ b/homeassistant/components/device_tracker/bluetooth_le_tracker.py @@ -102,7 +102,7 @@ def setup_scanner(hass, config, see, discovery_info=None): """Lookup Bluetooth LE devices and update status.""" devs = discover_ble_devices() for mac in devs_to_track: - _LOGGER.debug("Checking " + mac) + _LOGGER.debug("Checking %s", mac) result = mac in devs if not result: # Could not lookup device name diff --git a/homeassistant/components/device_tracker/bluetooth_tracker.py b/homeassistant/components/device_tracker/bluetooth_tracker.py index a535d87105e..9d41611d9a2 100644 --- a/homeassistant/components/device_tracker/bluetooth_tracker.py +++ b/homeassistant/components/device_tracker/bluetooth_tracker.py @@ -41,7 +41,7 @@ def setup_scanner(hass, config, see, discovery_info=None): result = bluetooth.discover_devices( duration=8, lookup_names=True, flush_cache=True, lookup_class=False) - _LOGGER.debug("Bluetooth devices discovered = " + str(len(result))) + _LOGGER.debug("Bluetooth devices discovered = %d", len(result)) return result yaml_path = hass.config.path(YAML_DEVICES) diff --git a/homeassistant/components/device_tracker/bmw_connected_drive.py b/homeassistant/components/device_tracker/bmw_connected_drive.py new file mode 100644 index 00000000000..6ba2681e4cd --- /dev/null +++ b/homeassistant/components/device_tracker/bmw_connected_drive.py @@ -0,0 +1,51 @@ +"""Device tracker for BMW Connected Drive vehicles. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.bmw_connected_drive/ +""" +import logging + +from homeassistant.components.bmw_connected_drive import DOMAIN \ + as BMW_DOMAIN +from homeassistant.util import slugify + +DEPENDENCIES = ['bmw_connected_drive'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_scanner(hass, config, see, discovery_info=None): + """Set up the BMW tracker.""" + accounts = hass.data[BMW_DOMAIN] + _LOGGER.debug('Found BMW accounts: %s', + ', '.join([a.name for a in accounts])) + for account in accounts: + for vehicle in account.account.vehicles: + tracker = BMWDeviceTracker(see, vehicle) + account.add_update_listener(tracker.update) + tracker.update() + return True + + +class BMWDeviceTracker(object): + """BMW Connected Drive device tracker.""" + + def __init__(self, see, vehicle): + """Initialize the Tracker.""" + self._see = see + self.vehicle = vehicle + + def update(self) -> None: + """Update the device info.""" + dev_id = slugify(self.vehicle.modelName) + _LOGGER.debug('Updating %s', dev_id) + attrs = { + 'trackr_id': dev_id, + 'id': dev_id, + 'name': self.vehicle.modelName + } + self._see( + dev_id=dev_id, host_name=self.vehicle.modelName, + gps=self.vehicle.state.gps_position, attributes=attrs, + icon='mdi:car' + ) diff --git a/homeassistant/components/device_tracker/fritz.py b/homeassistant/components/device_tracker/fritz.py index 58c23cb7d76..8c9d1988a71 100644 --- a/homeassistant/components/device_tracker/fritz.py +++ b/homeassistant/components/device_tracker/fritz.py @@ -75,9 +75,9 @@ class FritzBoxScanner(DeviceScanner): active_hosts.append(known_host['mac']) return active_hosts - def get_device_name(self, mac): + def get_device_name(self, device): """Return the name of the given device or None if is not known.""" - ret = self.fritz_box.get_specific_host_entry(mac).get( + ret = self.fritz_box.get_specific_host_entry(device).get( 'NewHostName' ) if ret == {}: diff --git a/homeassistant/components/device_tracker/geofency.py b/homeassistant/components/device_tracker/geofency.py index 58d69f39a1d..adb5c6f6d28 100644 --- a/homeassistant/components/device_tracker/geofency.py +++ b/homeassistant/components/device_tracker/geofency.py @@ -120,8 +120,7 @@ class GeofencyView(HomeAssistantView): """Return name of device tracker.""" if 'beaconUUID' in data: return "{}_{}".format(BEACON_DEV_PREFIX, data['name']) - else: - return data['device'] + return data['device'] @asyncio.coroutine def _set_location(self, hass, data, location_name): diff --git a/homeassistant/components/device_tracker/hitron_coda.py b/homeassistant/components/device_tracker/hitron_coda.py index 17dc34d1040..aa437eeef86 100644 --- a/homeassistant/components/device_tracker/hitron_coda.py +++ b/homeassistant/components/device_tracker/hitron_coda.py @@ -60,11 +60,11 @@ class HitronCODADeviceScanner(DeviceScanner): return [device.mac for device in self.last_results] - def get_device_name(self, mac): + def get_device_name(self, device): """Return the name of the device with the given MAC address.""" name = next(( - device.name for device in self.last_results - if device.mac == mac), None) + result.name for result in self.last_results + if result.mac == device), None) return name def _login(self): diff --git a/homeassistant/components/device_tracker/huawei_router.py b/homeassistant/components/device_tracker/huawei_router.py index 357dd0d36cf..775075b8a4a 100644 --- a/homeassistant/components/device_tracker/huawei_router.py +++ b/homeassistant/components/device_tracker/huawei_router.py @@ -86,6 +86,7 @@ class HuaweiDeviceScanner(DeviceScanner): active_clients = [client for client in data if client.state] self.last_results = active_clients + # pylint: disable=logging-not-lazy _LOGGER.debug("Active clients: " + "\n" .join((client.mac + " " + client.name) for client in active_clients)) diff --git a/homeassistant/components/device_tracker/keenetic_ndms2.py b/homeassistant/components/device_tracker/keenetic_ndms2.py index 5a7db36e479..36dc1182a92 100644 --- a/homeassistant/components/device_tracker/keenetic_ndms2.py +++ b/homeassistant/components/device_tracker/keenetic_ndms2.py @@ -67,10 +67,10 @@ class KeeneticNDMS2DeviceScanner(DeviceScanner): return [device.mac for device in self.last_results] - def get_device_name(self, mac): + def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" - filter_named = [device.name for device in self.last_results - if device.mac == mac] + filter_named = [result.name for result in self.last_results + if result.mac == device] if filter_named: return filter_named[0] diff --git a/homeassistant/components/device_tracker/linksys_ap.py b/homeassistant/components/device_tracker/linksys_ap.py index 20dc9052e11..8837b628b32 100644 --- a/homeassistant/components/device_tracker/linksys_ap.py +++ b/homeassistant/components/device_tracker/linksys_ap.py @@ -62,7 +62,7 @@ class LinksysAPDeviceScanner(DeviceScanner): return self.last_results # pylint: disable=no-self-use - def get_device_name(self, mac): + def get_device_name(self, device): """ Return the name (if known) of the device. diff --git a/homeassistant/components/device_tracker/linksys_smart.py b/homeassistant/components/device_tracker/linksys_smart.py index 4bcbb600b8b..c92f940f526 100644 --- a/homeassistant/components/device_tracker/linksys_smart.py +++ b/homeassistant/components/device_tracker/linksys_smart.py @@ -45,9 +45,9 @@ class LinksysSmartWifiDeviceScanner(DeviceScanner): return self.last_results.keys() - def get_device_name(self, mac): + def get_device_name(self, device): """Return the name (if known) of the device.""" - return self.last_results.get(mac) + return self.last_results.get(device) def _update_info(self): """Check for connected devices.""" diff --git a/homeassistant/components/device_tracker/meraki.py b/homeassistant/components/device_tracker/meraki.py index 9437486a0aa..9bbc6bf9ffe 100644 --- a/homeassistant/components/device_tracker/meraki.py +++ b/homeassistant/components/device_tracker/meraki.py @@ -85,7 +85,7 @@ class MerakiView(HomeAssistantView): return self.json_message('Invalid device type', HTTP_UNPROCESSABLE_ENTITY) _LOGGER.debug("Processing %s", data['type']) - if len(data["data"]["observations"]) == 0: + if not data["data"]["observations"]: _LOGGER.debug("No observations found") return self._handle(request.app['hass'], data) @@ -107,8 +107,7 @@ class MerakiView(HomeAssistantView): if lat == "NaN" or lng == "NaN": _LOGGER.debug( - "No coordinates received, skipping location for: " + mac - ) + "No coordinates received, skipping location for: %s", mac) gps_location = None accuracy = None else: diff --git a/homeassistant/components/device_tracker/mikrotik.py b/homeassistant/components/device_tracker/mikrotik.py index 1805559c252..1d9161c0d45 100644 --- a/homeassistant/components/device_tracker/mikrotik.py +++ b/homeassistant/components/device_tracker/mikrotik.py @@ -137,9 +137,9 @@ class MikrotikScanner(DeviceScanner): self._update_info() return [device for device in self.last_results] - def get_device_name(self, mac): + def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" - return self.last_results.get(mac) + return self.last_results.get(device) def _update_info(self): """Retrieve latest information from the Mikrotik box.""" diff --git a/homeassistant/components/device_tracker/mqtt_json.py b/homeassistant/components/device_tracker/mqtt_json.py index 7bcad60236a..9a5532fc9f4 100644 --- a/homeassistant/components/device_tracker/mqtt_json.py +++ b/homeassistant/components/device_tracker/mqtt_json.py @@ -26,8 +26,8 @@ _LOGGER = logging.getLogger(__name__) GPS_JSON_PAYLOAD_SCHEMA = vol.Schema({ vol.Required(ATTR_LATITUDE): vol.Coerce(float), vol.Required(ATTR_LONGITUDE): vol.Coerce(float), - vol.Optional(ATTR_GPS_ACCURACY, default=None): vol.Coerce(int), - vol.Optional(ATTR_BATTERY_LEVEL, default=None): vol.Coerce(str), + vol.Optional(ATTR_GPS_ACCURACY): vol.Coerce(int), + vol.Optional(ATTR_BATTERY_LEVEL): vol.Coerce(str), }, extra=vol.ALLOW_EXTRA) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(mqtt.SCHEMA_BASE).extend({ diff --git a/homeassistant/components/device_tracker/netgear.py b/homeassistant/components/device_tracker/netgear.py index d2b8bc274ca..25d5d38b2a7 100644 --- a/homeassistant/components/device_tracker/netgear.py +++ b/homeassistant/components/device_tracker/netgear.py @@ -70,11 +70,11 @@ class NetgearDeviceScanner(DeviceScanner): return (device.mac for device in self.last_results) - def get_device_name(self, mac): + def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" try: - return next(device.name for device in self.last_results - if device.mac == mac) + return next(result.name for result in self.last_results + if result.mac == device) except StopIteration: return None diff --git a/homeassistant/components/device_tracker/nmap_tracker.py b/homeassistant/components/device_tracker/nmap_tracker.py index e9d70142ad1..23cb7ea8f9d 100644 --- a/homeassistant/components/device_tracker/nmap_tracker.py +++ b/homeassistant/components/device_tracker/nmap_tracker.py @@ -33,7 +33,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOSTS): cv.ensure_list, vol.Required(CONF_HOME_INTERVAL, default=0): cv.positive_int, vol.Optional(CONF_EXCLUDE, default=[]): - vol.All(cv.ensure_list, vol.Length(min=1)), + vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_OPTIONS, default=DEFAULT_OPTIONS): cv.string }) @@ -41,9 +41,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def get_scanner(hass, config): """Validate the configuration and return a Nmap scanner.""" - scanner = NmapDeviceScanner(config[DOMAIN]) - - return scanner if scanner.success_init else None + return NmapDeviceScanner(config[DOMAIN]) Device = namedtuple('Device', ['mac', 'name', 'ip', 'last_update']) @@ -76,7 +74,6 @@ class NmapDeviceScanner(DeviceScanner): self._options = config[CONF_OPTIONS] self.home_interval = timedelta(minutes=minutes) - self.success_init = self._update_info() _LOGGER.info("Scanner initialized") def scan_devices(self): @@ -85,10 +82,10 @@ class NmapDeviceScanner(DeviceScanner): return [device.mac for device in self.last_results] - def get_device_name(self, mac): + def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" - filter_named = [device.name for device in self.last_results - if device.mac == mac] + filter_named = [result.name for result in self.last_results + if result.mac == device] if filter_named: return filter_named[0] diff --git a/homeassistant/components/device_tracker/tado.py b/homeassistant/components/device_tracker/tado.py index fca4998f7b5..dcf06036ea0 100644 --- a/homeassistant/components/device_tracker/tado.py +++ b/homeassistant/components/device_tracker/tado.py @@ -83,10 +83,10 @@ class TadoDeviceScanner(DeviceScanner): return [device.mac for device in self.last_results] @asyncio.coroutine - def async_get_device_name(self, mac): + def async_get_device_name(self, device): """Return the name of the given device or None if we don't know.""" - filter_named = [device.name for device in self.last_results - if device.mac == mac] + filter_named = [result.name for result in self.last_results + if result.mac == device] if filter_named: return filter_named[0] diff --git a/homeassistant/components/device_tracker/tomato.py b/homeassistant/components/device_tracker/tomato.py index 7cebf0abdf4..01ae2977f6d 100644 --- a/homeassistant/components/device_tracker/tomato.py +++ b/homeassistant/components/device_tracker/tomato.py @@ -24,7 +24,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=-1): cv.port, + vol.Optional(CONF_PORT): cv.port, vol.Optional(CONF_SSL, default=False): cv.boolean, vol.Optional(CONF_VERIFY_SSL, default=True): vol.Any( cv.boolean, cv.isfile), @@ -45,13 +45,11 @@ class TomatoDeviceScanner(DeviceScanner): def __init__(self, config): """Initialize the scanner.""" host, http_id = config[CONF_HOST], config[CONF_HTTP_ID] - port = config[CONF_PORT] + port = config.get(CONF_PORT) username, password = config[CONF_USERNAME], config[CONF_PASSWORD] self.ssl, self.verify_ssl = config[CONF_SSL], config[CONF_VERIFY_SSL] - if port == -1: - port = 80 - if self.ssl: - port = 443 + if port is None: + port = 443 if self.ssl else 80 self.req = requests.Request( 'POST', 'http{}://{}:{}/update.cgi'.format( diff --git a/homeassistant/components/device_tracker/ubus.py b/homeassistant/components/device_tracker/ubus.py index e66bb95a11a..946aae5fe56 100644 --- a/homeassistant/components/device_tracker/ubus.py +++ b/homeassistant/components/device_tracker/ubus.py @@ -96,11 +96,11 @@ class UbusDeviceScanner(DeviceScanner): raise NotImplementedError @_refresh_on_access_denied - def get_device_name(self, mac): + def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" if self.mac2name is None: self._generate_mac2name() - name = self.mac2name.get(mac.upper(), None) + name = self.mac2name.get(device.upper(), None) return name @_refresh_on_access_denied diff --git a/homeassistant/components/device_tracker/unifi.py b/homeassistant/components/device_tracker/unifi.py index d5b6b044f1f..8663930c4e6 100644 --- a/homeassistant/components/device_tracker/unifi.py +++ b/homeassistant/components/device_tracker/unifi.py @@ -21,6 +21,7 @@ _LOGGER = logging.getLogger(__name__) CONF_PORT = 'port' CONF_SITE_ID = 'site_id' CONF_DETECTION_TIME = 'detection_time' +CONF_SSID_FILTER = 'ssid_filter' DEFAULT_HOST = 'localhost' DEFAULT_PORT = 8443 @@ -39,7 +40,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): vol.Any( cv.boolean, cv.isfile), vol.Optional(CONF_DETECTION_TIME, default=DEFAULT_DETECTION_TIME): vol.All( - cv.time_period, cv.positive_timedelta) + cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_SSID_FILTER): vol.All(cv.ensure_list, [cv.string]) }) @@ -54,6 +56,7 @@ def get_scanner(hass, config): port = config[DOMAIN].get(CONF_PORT) verify_ssl = config[DOMAIN].get(CONF_VERIFY_SSL) detection_time = config[DOMAIN].get(CONF_DETECTION_TIME) + ssid_filter = config[DOMAIN].get(CONF_SSID_FILTER) try: ctrl = Controller(host, username, password, port, version='v4', @@ -69,16 +72,18 @@ def get_scanner(hass, config): notification_id=NOTIFICATION_ID) return False - return UnifiScanner(ctrl, detection_time) + return UnifiScanner(ctrl, detection_time, ssid_filter) class UnifiScanner(DeviceScanner): """Provide device_tracker support from Unifi WAP client data.""" - def __init__(self, controller, detection_time: timedelta) -> None: + def __init__(self, controller, detection_time: timedelta, + ssid_filter) -> None: """Initialize the scanner.""" self._detection_time = detection_time self._controller = controller + self._ssid_filter = ssid_filter self._update() def _update(self): @@ -90,6 +95,11 @@ class UnifiScanner(DeviceScanner): _LOGGER.error("Failed to scan clients: %s", ex) clients = [] + # Filter clients to provided SSID list + if self._ssid_filter: + clients = [client for client in clients + if client['essid'] in self._ssid_filter] + self._clients = { client['mac']: client for client in clients @@ -101,13 +111,13 @@ class UnifiScanner(DeviceScanner): self._update() return self._clients.keys() - def get_device_name(self, mac): + def get_device_name(self, device): """Return the name (if known) of the device. If a name has been set in Unifi, then return that, else return the hostname if it has been detected. """ - client = self._clients.get(mac, {}) + client = self._clients.get(device, {}) name = client.get('name') or client.get('hostname') - _LOGGER.debug("Device mac %s name %s", mac, name) + _LOGGER.debug("Device mac %s name %s", device, name) return name diff --git a/homeassistant/components/dominos.py b/homeassistant/components/dominos.py index 4bdb4c80add..2c9f763aaa8 100644 --- a/homeassistant/components/dominos.py +++ b/homeassistant/components/dominos.py @@ -140,21 +140,20 @@ class Dominos(): if self.closest_store is None: _LOGGER.warning('Cannot get menu. Store may be closed') return [] - else: - menu = self.closest_store.get_menu() - product_entries = [] + menu = self.closest_store.get_menu() + product_entries = [] - for product in menu.products: - item = {} - if isinstance(product.menu_data['Variants'], list): - variants = ', '.join(product.menu_data['Variants']) - else: - variants = product.menu_data['Variants'] - item['name'] = product.name - item['variants'] = variants - product_entries.append(item) + for product in menu.products: + item = {} + if isinstance(product.menu_data['Variants'], list): + variants = ', '.join(product.menu_data['Variants']) + else: + variants = product.menu_data['Variants'] + item['name'] = product.name + item['variants'] = variants + product_entries.append(item) - return product_entries + return product_entries class DominosProductListView(http.HomeAssistantView): @@ -203,8 +202,7 @@ class DominosOrder(Entity): """Return the state either closed, orderable or unorderable.""" if self.dominos.closest_store is None: return 'closed' - else: - return 'orderable' if self._orderable else 'unorderable' + return 'orderable' if self._orderable else 'unorderable' @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index 9fba21b81dc..c89e4fda358 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -14,7 +14,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ) from homeassistant.components.http import REQUIREMENTS # NOQA -from homeassistant.components.http import HomeAssistantWSGI +from homeassistant.components.http import HomeAssistantHTTP from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.deprecation import get_deprecated import homeassistant.helpers.config_validation as cv @@ -86,7 +86,7 @@ def setup(hass, yaml_config): """Activate the emulated_hue component.""" config = Config(hass, yaml_config.get(DOMAIN, {})) - server = HomeAssistantWSGI( + server = HomeAssistantHTTP( hass, server_host=config.host_ip_addr, server_port=config.listen_port, diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index 6e6d377986d..66790d02687 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -118,7 +118,7 @@ SERVICE_TO_METHOD = { @bind_hass -def is_on(hass, entity_id: str=None) -> bool: +def is_on(hass, entity_id: str = None) -> bool: """Return if the fans are on based on the statemachine.""" entity_id = entity_id or ENTITY_ID_ALL_FANS state = hass.states.get(entity_id) @@ -126,7 +126,7 @@ def is_on(hass, entity_id: str=None) -> bool: @bind_hass -def turn_on(hass, entity_id: str=None, speed: str=None) -> None: +def turn_on(hass, entity_id: str = None, speed: str = None) -> None: """Turn all or specified fan on.""" data = { key: value for key, value in [ @@ -139,7 +139,7 @@ def turn_on(hass, entity_id: str=None, speed: str=None) -> None: @bind_hass -def turn_off(hass, entity_id: str=None) -> None: +def turn_off(hass, entity_id: str = None) -> None: """Turn all or specified fan off.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} @@ -147,7 +147,7 @@ def turn_off(hass, entity_id: str=None) -> None: @bind_hass -def toggle(hass, entity_id: str=None) -> None: +def toggle(hass, entity_id: str = None) -> None: """Toggle all or specified fans.""" data = { ATTR_ENTITY_ID: entity_id @@ -157,7 +157,8 @@ def toggle(hass, entity_id: str=None) -> None: @bind_hass -def oscillate(hass, entity_id: str=None, should_oscillate: bool=True) -> None: +def oscillate(hass, entity_id: str = None, + should_oscillate: bool = True) -> None: """Set oscillation on all or specified fan.""" data = { key: value for key, value in [ @@ -170,7 +171,7 @@ def oscillate(hass, entity_id: str=None, should_oscillate: bool=True) -> None: @bind_hass -def set_speed(hass, entity_id: str=None, speed: str=None) -> None: +def set_speed(hass, entity_id: str = None, speed: str = None) -> None: """Set speed for all or specified fan.""" data = { key: value for key, value in [ @@ -183,7 +184,7 @@ def set_speed(hass, entity_id: str=None, speed: str=None) -> None: @bind_hass -def set_direction(hass, entity_id: str=None, direction: str=None) -> None: +def set_direction(hass, entity_id: str = None, direction: str = None) -> None: """Set direction for all or specified fan.""" data = { key: value for key, value in [ @@ -258,11 +259,13 @@ class FanEntity(ToggleEntity): """ return self.hass.async_add_job(self.set_direction, direction) - def turn_on(self: ToggleEntity, speed: str=None, **kwargs) -> None: + # pylint: disable=arguments-differ + def turn_on(self: ToggleEntity, speed: str = None, **kwargs) -> None: """Turn on the fan.""" raise NotImplementedError() - def async_turn_on(self: ToggleEntity, speed: str=None, **kwargs): + # pylint: disable=arguments-differ + def async_turn_on(self: ToggleEntity, speed: str = None, **kwargs): """Turn on the fan. This method must be run in the event loop and returns a coroutine. diff --git a/homeassistant/components/fan/comfoconnect.py b/homeassistant/components/fan/comfoconnect.py index c6d1232801f..12dc0b1104f 100644 --- a/homeassistant/components/fan/comfoconnect.py +++ b/homeassistant/components/fan/comfoconnect.py @@ -87,7 +87,7 @@ class ComfoConnectFan(FanEntity): """List of available fan modes.""" return [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] - def turn_on(self, speed: str=None, **kwargs) -> None: + def turn_on(self, speed: str = None, **kwargs) -> None: """Turn on the fan.""" if speed is None: speed = SPEED_LOW @@ -97,21 +97,21 @@ class ComfoConnectFan(FanEntity): """Turn off the fan (to away).""" self.set_speed(SPEED_OFF) - def set_speed(self, mode): + def set_speed(self, speed: str): """Set fan speed.""" - _LOGGER.debug('Changing fan mode to %s.', mode) + _LOGGER.debug('Changing fan speed to %s.', speed) from pycomfoconnect import ( CMD_FAN_MODE_AWAY, CMD_FAN_MODE_LOW, CMD_FAN_MODE_MEDIUM, CMD_FAN_MODE_HIGH) - if mode == SPEED_OFF: + if speed == SPEED_OFF: self._ccb.comfoconnect.cmd_rmi_request(CMD_FAN_MODE_AWAY) - elif mode == SPEED_LOW: + elif speed == SPEED_LOW: self._ccb.comfoconnect.cmd_rmi_request(CMD_FAN_MODE_LOW) - elif mode == SPEED_MEDIUM: + elif speed == SPEED_MEDIUM: self._ccb.comfoconnect.cmd_rmi_request(CMD_FAN_MODE_MEDIUM) - elif mode == SPEED_HIGH: + elif speed == SPEED_HIGH: self._ccb.comfoconnect.cmd_rmi_request(CMD_FAN_MODE_HIGH) # Update current mode diff --git a/homeassistant/components/fan/demo.py b/homeassistant/components/fan/demo.py index bdb1b784c8b..b328ebb3101 100644 --- a/homeassistant/components/fan/demo.py +++ b/homeassistant/components/fan/demo.py @@ -59,13 +59,13 @@ class DemoFan(FanEntity): """Get the list of available speeds.""" return [STATE_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] - def turn_on(self, speed: str=None) -> None: + def turn_on(self, speed: str = None, **kwargs) -> None: """Turn on the entity.""" if speed is None: speed = SPEED_MEDIUM self.set_speed(speed) - def turn_off(self) -> None: + def turn_off(self, **kwargs) -> None: """Turn off the entity.""" self.oscillate(False) self.set_speed(STATE_OFF) diff --git a/homeassistant/components/fan/dyson.py b/homeassistant/components/fan/dyson.py index c5e5b8736ae..5b689ece6ed 100644 --- a/homeassistant/components/fan/dyson.py +++ b/homeassistant/components/fan/dyson.py @@ -113,7 +113,7 @@ class DysonPureCoolLinkDevice(FanEntity): self._device.set_configuration( fan_mode=FanMode.FAN, fan_speed=fan_speed) - def turn_on(self: ToggleEntity, speed: str=None, **kwargs) -> None: + def turn_on(self: ToggleEntity, speed: str = None, **kwargs) -> None: """Turn on the fan.""" from libpurecoollink.const import FanSpeed, FanMode diff --git a/homeassistant/components/fan/insteon_local.py b/homeassistant/components/fan/insteon_local.py index e6f9424d852..b8a5c99add4 100644 --- a/homeassistant/components/fan/insteon_local.py +++ b/homeassistant/components/fan/insteon_local.py @@ -91,7 +91,7 @@ class InsteonLocalFanDevice(FanEntity): """Flag supported features.""" return SUPPORT_INSTEON_LOCAL - def turn_on(self: ToggleEntity, speed: str=None, **kwargs) -> None: + def turn_on(self: ToggleEntity, speed: str = None, **kwargs) -> None: """Turn device on.""" if speed is None: if ATTR_SPEED in kwargs: diff --git a/homeassistant/components/fan/isy994.py b/homeassistant/components/fan/isy994.py index 137bc400d0d..847ca3b325b 100644 --- a/homeassistant/components/fan/isy994.py +++ b/homeassistant/components/fan/isy994.py @@ -48,10 +48,6 @@ def setup_platform(hass, config: ConfigType, class ISYFanDevice(ISYDevice, FanEntity): """Representation of an ISY994 fan device.""" - def __init__(self, node) -> None: - """Initialize the ISY994 fan device.""" - super().__init__(node) - @property def speed(self) -> str: """Return the current speed.""" @@ -66,7 +62,7 @@ class ISYFanDevice(ISYDevice, FanEntity): """Send the set speed command to the ISY994 fan device.""" self._node.on(val=STATE_TO_VALUE.get(speed, 255)) - def turn_on(self, speed: str=None, **kwargs) -> None: + def turn_on(self, speed: str = None, **kwargs) -> None: """Send the turn on command to the ISY994 fan device.""" self.set_speed(speed) @@ -99,7 +95,7 @@ class ISYFanProgram(ISYFanDevice): if not self._actions.runThen(): _LOGGER.error("Unable to turn off the fan") - def turn_on(self, **kwargs) -> None: + def turn_on(self, speed: str = None, **kwargs) -> None: """Send the turn off command to ISY994 fan program.""" if not self._actions.runElse(): _LOGGER.error("Unable to turn on the fan") diff --git a/homeassistant/components/fan/mqtt.py b/homeassistant/components/fan/mqtt.py index 1ecbb12bcb4..95ff587c613 100644 --- a/homeassistant/components/fan/mqtt.py +++ b/homeassistant/components/fan/mqtt.py @@ -252,7 +252,7 @@ class MqttFan(MqttAvailability, FanEntity): return self._oscillation @asyncio.coroutine - def async_turn_on(self, speed: str=None) -> None: + def async_turn_on(self, speed: str = None, **kwargs) -> None: """Turn on the entity. This method is a coroutine. @@ -264,7 +264,7 @@ class MqttFan(MqttAvailability, FanEntity): yield from self.async_set_speed(speed) @asyncio.coroutine - def async_turn_off(self) -> None: + def async_turn_off(self, **kwargs) -> None: """Turn off the entity. This method is a coroutine. diff --git a/homeassistant/components/fan/velbus.py b/homeassistant/components/fan/velbus.py index c0d125aa5ab..e8208d1c990 100644 --- a/homeassistant/components/fan/velbus.py +++ b/homeassistant/components/fan/velbus.py @@ -128,13 +128,13 @@ class VelbusFan(FanEntity): """Get the list of available speeds.""" return [STATE_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] - def turn_on(self, speed, **kwargs): + def turn_on(self, speed=None, **kwargs): """Turn on the entity.""" if speed is None: speed = SPEED_MEDIUM self.set_speed(speed) - def turn_off(self): + def turn_off(self, **kwargs): """Turn off the entity.""" self.set_speed(STATE_OFF) diff --git a/homeassistant/components/fan/wink.py b/homeassistant/components/fan/wink.py index 827f134cc08..0cebd9cb9f8 100644 --- a/homeassistant/components/fan/wink.py +++ b/homeassistant/components/fan/wink.py @@ -47,7 +47,7 @@ class WinkFanDevice(WinkDevice, FanEntity): """Set the speed of the fan.""" self.wink.set_state(True, speed) - def turn_on(self: ToggleEntity, speed: str=None, **kwargs) -> None: + def turn_on(self: ToggleEntity, speed: str = None, **kwargs) -> None: """Turn on the fan.""" self.wink.set_state(True, speed) diff --git a/homeassistant/components/fan/xiaomi_miio.py b/homeassistant/components/fan/xiaomi_miio.py index 6938926a19b..b9bc54b5c79 100644 --- a/homeassistant/components/fan/xiaomi_miio.py +++ b/homeassistant/components/fan/xiaomi_miio.py @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant.helpers.entity import ToggleEntity from homeassistant.components.fan import (FanEntity, PLATFORM_SCHEMA, - SUPPORT_SET_SPEED, DOMAIN) + SUPPORT_SET_SPEED, DOMAIN, ) from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_TOKEN, ATTR_ENTITY_ID, ) from homeassistant.exceptions import PlatformNotReady @@ -29,7 +29,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) -REQUIREMENTS = ['python-miio==0.3.6'] +REQUIREMENTS = ['python-miio==0.3.7'] ATTR_TEMPERATURE = 'temperature' ATTR_HUMIDITY = 'humidity' @@ -167,6 +167,7 @@ class XiaomiAirPurifier(FanEntity): ATTR_AVERAGE_AIR_QUALITY_INDEX: None, ATTR_PURIFY_VOLUME: None, } + self._skip_update = False @property def supported_features(self): @@ -214,27 +215,39 @@ class XiaomiAirPurifier(FanEntity): return False @asyncio.coroutine - def async_turn_on(self: ToggleEntity, speed: str=None, **kwargs) -> None: + def async_turn_on(self: ToggleEntity, speed: str = None, **kwargs) -> None: """Turn the fan on.""" if speed: # If operation mode was set the device must not be turned on. - yield from self.async_set_speed(speed) - return + result = yield from self.async_set_speed(speed) + else: + result = yield from self._try_command( + "Turning the air purifier on failed.", self._air_purifier.on) - yield from self._try_command( - "Turning the air purifier on failed.", self._air_purifier.on) + if result: + self._state = True + self._skip_update = True @asyncio.coroutine def async_turn_off(self: ToggleEntity, **kwargs) -> None: """Turn the fan off.""" - yield from self._try_command( + result = yield from self._try_command( "Turning the air purifier off failed.", self._air_purifier.off) + if result: + self._state = False + self._skip_update = True + @asyncio.coroutine def async_update(self): """Fetch state from the device.""" from miio import DeviceException + # On state change the device doesn't provide the new state immediately. + if self._skip_update: + self._skip_update = False + return + try: state = yield from self.hass.async_add_job( self._air_purifier.status) @@ -262,6 +275,7 @@ class XiaomiAirPurifier(FanEntity): ATTR_LED_BRIGHTNESS] = state.led_brightness.value except DeviceException as ex: + self._state = None _LOGGER.error("Got exception while fetching the state: %s", ex) @property @@ -283,12 +297,12 @@ class XiaomiAirPurifier(FanEntity): @asyncio.coroutine def async_set_speed(self: ToggleEntity, speed: str) -> None: """Set the speed of the fan.""" - _LOGGER.debug("Setting the operation mode to: " + speed) + _LOGGER.debug("Setting the operation mode to: %s", speed) from miio.airpurifier import OperationMode yield from self._try_command( "Setting operation mode of the air purifier failed.", - self._air_purifier.set_mode, OperationMode[speed]) + self._air_purifier.set_mode, OperationMode[speed.title()]) @asyncio.coroutine def async_set_buzzer_on(self): @@ -333,7 +347,7 @@ class XiaomiAirPurifier(FanEntity): self._air_purifier.set_child_lock, False) @asyncio.coroutine - def async_set_led_brightness(self, brightness: int=2): + def async_set_led_brightness(self, brightness: int = 2): """Set the led brightness.""" from miio.airpurifier import LedBrightness @@ -342,7 +356,7 @@ class XiaomiAirPurifier(FanEntity): self._air_purifier.set_led_brightness, LedBrightness(brightness)) @asyncio.coroutine - def async_set_favorite_level(self, level: int=1): + def async_set_favorite_level(self, level: int = 1): """Set the favorite level.""" yield from self._try_command( "Setting the favorite level of the air purifier failed.", diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index eedd33478a7..2a9a7a8a38a 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -17,13 +17,13 @@ import jinja2 import homeassistant.helpers.config_validation as cv from homeassistant.components.http import HomeAssistantView -from homeassistant.components.http.auth import is_trusted_ip +from homeassistant.components.http.const import KEY_AUTHENTICATED from homeassistant.config import find_config_file, load_yaml_config_file from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import callback from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20180209.0', 'user-agents==1.1.0'] +REQUIREMENTS = ['home-assistant-frontend==20180221.1', 'user-agents==1.1.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] @@ -490,7 +490,7 @@ class IndexView(HomeAssistantView): panel_url = hass.data[DATA_PANELS][panel].webcomponent_url_es5 no_auth = '1' - if hass.config.api.api_password and not is_trusted_ip(request): + if hass.config.api.api_password and not request[KEY_AUTHENTICATED]: # do not try to auto connect on load no_auth = '0' diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index aac258b4e93..20dee082a08 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -27,7 +27,7 @@ from .const import ( CONF_EXPOSE_BY_DEFAULT, DEFAULT_EXPOSE_BY_DEFAULT, CONF_EXPOSED_DOMAINS, DEFAULT_EXPOSED_DOMAINS, CONF_AGENT_USER_ID, CONF_API_KEY, SERVICE_REQUEST_SYNC, REQUEST_SYNC_BASE_URL, CONF_ENTITY_CONFIG, - CONF_EXPOSE, CONF_ALIASES + CONF_EXPOSE, CONF_ALIASES, CONF_ROOM_HINT ) from .auth import GoogleAssistantAuthView from .http import async_register_http @@ -43,7 +43,8 @@ ENTITY_SCHEMA = vol.Schema({ vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_TYPE): vol.In(MAPPING_COMPONENT), vol.Optional(CONF_EXPOSE): cv.boolean, - vol.Optional(CONF_ALIASES): vol.All(cv.ensure_list, [cv.string]) + vol.Optional(CONF_ALIASES): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_ROOM_HINT): cv.string }) CONFIG_SCHEMA = vol.Schema( diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 0483f424ca3..1f1ae4682b4 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -13,6 +13,7 @@ CONF_CLIENT_ID = 'client_id' CONF_ALIASES = 'aliases' CONF_AGENT_USER_ID = 'agent_user_id' CONF_API_KEY = 'api_key' +CONF_ROOM_HINT = 'room' DEFAULT_EXPOSE_BY_DEFAULT = True DEFAULT_EXPOSED_DOMAINS = [ diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index b718c009160..f638b847bcb 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -33,7 +33,8 @@ from .const import ( TRAIT_ONOFF, TRAIT_BRIGHTNESS, TRAIT_COLOR_TEMP, TRAIT_RGB_COLOR, TRAIT_SCENE, TRAIT_TEMPERATURE_SETTING, TYPE_LIGHT, TYPE_SCENE, TYPE_SWITCH, TYPE_THERMOSTAT, - CONF_ALIASES, CLIMATE_SUPPORTED_MODES, CLIMATE_MODE_HEATCOOL + CONF_ALIASES, CONF_ROOM_HINT, CLIMATE_SUPPORTED_MODES, + CLIMATE_MODE_HEATCOOL ) HANDLERS = Registry() @@ -124,6 +125,11 @@ def entity_to_device(entity: Entity, config: Config, units: UnitSystem): if aliases: device['name']['nicknames'] = aliases + # add room hint if annotated + room = entity_config.get(CONF_ROOM_HINT) + if room: + device['roomHint'] = room + # add trait if entity supports feature if class_data[2]: supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) @@ -237,7 +243,10 @@ def query_response_sensor( def query_response_climate( entity: Entity, config: Config, units: UnitSystem) -> dict: """Convert a climate entity to a QUERY response.""" - mode = entity.attributes.get(climate.ATTR_OPERATION_MODE).lower() + mode = entity.attributes.get(climate.ATTR_OPERATION_MODE) + if mode is None: + mode = entity.state + mode = mode.lower() if mode not in CLIMATE_SUPPORTED_MODES: mode = 'heat' attrs = entity.attributes diff --git a/homeassistant/components/hassio.py b/homeassistant/components/hassio.py deleted file mode 100644 index 510b08e766f..00000000000 --- a/homeassistant/components/hassio.py +++ /dev/null @@ -1,451 +0,0 @@ -""" -Exposes regular REST commands as services. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/hassio/ -""" -import asyncio -from datetime import timedelta -import logging -import os -import re - -import aiohttp -from aiohttp import web -from aiohttp.hdrs import CONTENT_TYPE -from aiohttp.web_exceptions import HTTPBadGateway -import async_timeout -import voluptuous as vol - -from homeassistant.components import SERVICE_CHECK_CONFIG -from homeassistant.components.http import ( - CONF_API_PASSWORD, CONF_SERVER_HOST, CONF_SERVER_PORT, - CONF_SSL_CERTIFICATE, KEY_AUTHENTICATED, HomeAssistantView) -from homeassistant.const import ( - CONF_TIME_ZONE, CONTENT_TYPE_TEXT_PLAIN, SERVER_PORT, - SERVICE_HOMEASSISTANT_RESTART, SERVICE_HOMEASSISTANT_STOP) -from homeassistant.core import DOMAIN as HASS_DOMAIN -from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv -from homeassistant.loader import bind_hass -from homeassistant.util.dt import utcnow - -_LOGGER = logging.getLogger(__name__) - -DOMAIN = 'hassio' -DEPENDENCIES = ['http'] - -X_HASSIO = 'X-HASSIO-KEY' - -DATA_HOMEASSISTANT_VERSION = 'hassio_hass_version' -HASSIO_UPDATE_INTERVAL = timedelta(minutes=55) - -SERVICE_ADDON_START = 'addon_start' -SERVICE_ADDON_STOP = 'addon_stop' -SERVICE_ADDON_RESTART = 'addon_restart' -SERVICE_ADDON_STDIN = 'addon_stdin' -SERVICE_HOST_SHUTDOWN = 'host_shutdown' -SERVICE_HOST_REBOOT = 'host_reboot' -SERVICE_SNAPSHOT_FULL = 'snapshot_full' -SERVICE_SNAPSHOT_PARTIAL = 'snapshot_partial' -SERVICE_RESTORE_FULL = 'restore_full' -SERVICE_RESTORE_PARTIAL = 'restore_partial' - -ATTR_ADDON = 'addon' -ATTR_INPUT = 'input' -ATTR_SNAPSHOT = 'snapshot' -ATTR_ADDONS = 'addons' -ATTR_FOLDERS = 'folders' -ATTR_HOMEASSISTANT = 'homeassistant' -ATTR_NAME = 'name' - -NO_TIMEOUT = { - re.compile(r'^homeassistant/update$'), - re.compile(r'^host/update$'), - re.compile(r'^supervisor/update$'), - re.compile(r'^addons/[^/]*/update$'), - re.compile(r'^addons/[^/]*/install$'), - re.compile(r'^addons/[^/]*/rebuild$'), - re.compile(r'^snapshots/.*/full$'), - re.compile(r'^snapshots/.*/partial$'), -} - -NO_AUTH = { - re.compile(r'^app-(es5|latest)/(index|hassio-app).html$'), - re.compile(r'^addons/[^/]*/logo$') -} - -SCHEMA_NO_DATA = vol.Schema({}) - -SCHEMA_ADDON = vol.Schema({ - vol.Required(ATTR_ADDON): cv.slug, -}) - -SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend({ - vol.Required(ATTR_INPUT): vol.Any(dict, cv.string) -}) - -SCHEMA_SNAPSHOT_FULL = vol.Schema({ - vol.Optional(ATTR_NAME): cv.string, -}) - -SCHEMA_SNAPSHOT_PARTIAL = SCHEMA_SNAPSHOT_FULL.extend({ - vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(ATTR_ADDONS): vol.All(cv.ensure_list, [cv.string]), -}) - -SCHEMA_RESTORE_FULL = vol.Schema({ - vol.Required(ATTR_SNAPSHOT): cv.slug, -}) - -SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend({ - vol.Optional(ATTR_HOMEASSISTANT): cv.boolean, - vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(ATTR_ADDONS): vol.All(cv.ensure_list, [cv.string]), -}) - -MAP_SERVICE_API = { - SERVICE_ADDON_START: ('/addons/{addon}/start', SCHEMA_ADDON, 60, False), - SERVICE_ADDON_STOP: ('/addons/{addon}/stop', SCHEMA_ADDON, 60, False), - SERVICE_ADDON_RESTART: - ('/addons/{addon}/restart', SCHEMA_ADDON, 60, False), - SERVICE_ADDON_STDIN: - ('/addons/{addon}/stdin', SCHEMA_ADDON_STDIN, 60, False), - SERVICE_HOST_SHUTDOWN: ('/host/shutdown', SCHEMA_NO_DATA, 60, False), - SERVICE_HOST_REBOOT: ('/host/reboot', SCHEMA_NO_DATA, 60, False), - SERVICE_SNAPSHOT_FULL: - ('/snapshots/new/full', SCHEMA_SNAPSHOT_FULL, 300, True), - SERVICE_SNAPSHOT_PARTIAL: - ('/snapshots/new/partial', SCHEMA_SNAPSHOT_PARTIAL, 300, True), - SERVICE_RESTORE_FULL: - ('/snapshots/{snapshot}/restore/full', SCHEMA_RESTORE_FULL, 300, True), - SERVICE_RESTORE_PARTIAL: - ('/snapshots/{snapshot}/restore/partial', SCHEMA_RESTORE_PARTIAL, 300, - True), -} - - -@callback -@bind_hass -def get_homeassistant_version(hass): - """Return latest available Home Assistant version. - - Async friendly. - """ - return hass.data.get(DATA_HOMEASSISTANT_VERSION) - - -@callback -@bind_hass -def is_hassio(hass): - """Return true if hass.io is loaded. - - Async friendly. - """ - return DOMAIN in hass.config.components - - -@bind_hass -@asyncio.coroutine -def async_check_config(hass): - """Check configuration over Hass.io API.""" - result = yield from hass.data[DOMAIN].send_command( - '/homeassistant/check', timeout=300) - - if not result: - return "Hass.io config check API error" - elif result['result'] == "error": - return result['message'] - return None - - -@asyncio.coroutine -def async_setup(hass, config): - """Set up the Hass.io component.""" - try: - host = os.environ['HASSIO'] - except KeyError: - _LOGGER.error("No Hass.io supervisor detect") - return False - - websession = hass.helpers.aiohttp_client.async_get_clientsession() - hass.data[DOMAIN] = hassio = HassIO(hass.loop, websession, host) - - if not (yield from hassio.is_connected()): - _LOGGER.error("Not connected with Hass.io") - return False - - hass.http.register_view(HassIOView(hassio)) - - if 'frontend' in hass.config.components: - yield from hass.components.frontend.async_register_built_in_panel( - 'hassio', 'Hass.io', 'mdi:access-point-network') - - if 'http' in config: - yield from hassio.update_hass_api(config['http']) - - if 'homeassistant' in config: - yield from hassio.update_hass_timezone(config['homeassistant']) - - @asyncio.coroutine - def async_service_handler(service): - """Handle service calls for Hass.io.""" - api_command = MAP_SERVICE_API[service.service][0] - data = service.data.copy() - addon = data.pop(ATTR_ADDON, None) - snapshot = data.pop(ATTR_SNAPSHOT, None) - payload = None - - # Pass data to hass.io API - if service.service == SERVICE_ADDON_STDIN: - payload = data[ATTR_INPUT] - elif MAP_SERVICE_API[service.service][3]: - payload = data - - # Call API - ret = yield from hassio.send_command( - api_command.format(addon=addon, snapshot=snapshot), - payload=payload, timeout=MAP_SERVICE_API[service.service][2] - ) - - if not ret or ret['result'] != "ok": - _LOGGER.error("Error on Hass.io API: %s", ret['message']) - - for service, settings in MAP_SERVICE_API.items(): - hass.services.async_register( - DOMAIN, service, async_service_handler, schema=settings[1]) - - @asyncio.coroutine - def update_homeassistant_version(now): - """Update last available Home Assistant version.""" - data = yield from hassio.get_homeassistant_info() - if data: - hass.data[DATA_HOMEASSISTANT_VERSION] = \ - data['data']['last_version'] - - hass.helpers.event.async_track_point_in_utc_time( - update_homeassistant_version, utcnow() + HASSIO_UPDATE_INTERVAL) - - # Fetch last version - yield from update_homeassistant_version(None) - - @asyncio.coroutine - def async_handle_core_service(call): - """Service handler for handling core services.""" - if call.service == SERVICE_HOMEASSISTANT_STOP: - yield from hassio.send_command('/homeassistant/stop') - return - - error = yield from async_check_config(hass) - if error: - _LOGGER.error(error) - hass.components.persistent_notification.async_create( - "Config error. See dev-info panel for details.", - "Config validating", "{0}.check_config".format(HASS_DOMAIN)) - return - - if call.service == SERVICE_HOMEASSISTANT_RESTART: - yield from hassio.send_command('/homeassistant/restart') - - # Mock core services - for service in (SERVICE_HOMEASSISTANT_STOP, SERVICE_HOMEASSISTANT_RESTART, - SERVICE_CHECK_CONFIG): - hass.services.async_register( - HASS_DOMAIN, service, async_handle_core_service) - - return True - - -def _api_bool(funct): - """Return a boolean.""" - @asyncio.coroutine - def _wrapper(*argv, **kwargs): - """Wrap function.""" - data = yield from funct(*argv, **kwargs) - return data and data['result'] == "ok" - - return _wrapper - - -class HassIO(object): - """Small API wrapper for Hass.io.""" - - def __init__(self, loop, websession, ip): - """Initialize Hass.io API.""" - self.loop = loop - self.websession = websession - self._ip = ip - - @_api_bool - def is_connected(self): - """Return true if it connected to Hass.io supervisor. - - This method return a coroutine. - """ - return self.send_command("/supervisor/ping", method="get") - - def get_homeassistant_info(self): - """Return data for Home Assistant. - - This method return a coroutine. - """ - return self.send_command("/homeassistant/info", method="get") - - @_api_bool - def update_hass_api(self, http_config): - """Update Home Assistant API data on Hass.io. - - This method return a coroutine. - """ - port = http_config.get(CONF_SERVER_PORT) or SERVER_PORT - options = { - 'ssl': CONF_SSL_CERTIFICATE in http_config, - 'port': port, - 'password': http_config.get(CONF_API_PASSWORD), - 'watchdog': True, - } - - if CONF_SERVER_HOST in http_config: - options['watchdog'] = False - _LOGGER.warning("Don't use 'server_host' options with Hass.io") - - return self.send_command("/homeassistant/options", payload=options) - - @_api_bool - def update_hass_timezone(self, core_config): - """Update Home-Assistant timezone data on Hass.io. - - This method return a coroutine. - """ - return self.send_command("/supervisor/options", payload={ - 'timezone': core_config.get(CONF_TIME_ZONE) - }) - - @asyncio.coroutine - def send_command(self, command, method="post", payload=None, timeout=10): - """Send API command to Hass.io. - - This method is a coroutine. - """ - try: - with async_timeout.timeout(timeout, loop=self.loop): - request = yield from self.websession.request( - method, "http://{}{}".format(self._ip, command), - json=payload, headers={ - X_HASSIO: os.environ.get('HASSIO_TOKEN', "") - }) - - if request.status not in (200, 400): - _LOGGER.error( - "%s return code %d.", command, request.status) - return None - - answer = yield from request.json() - return answer - - except asyncio.TimeoutError: - _LOGGER.error("Timeout on %s request", command) - - except aiohttp.ClientError as err: - _LOGGER.error("Client error on %s request %s", command, err) - - return None - - @asyncio.coroutine - def command_proxy(self, path, request): - """Return a client request with proxy origin for Hass.io supervisor. - - This method is a coroutine. - """ - read_timeout = _get_timeout(path) - - try: - data = None - headers = {X_HASSIO: os.environ.get('HASSIO_TOKEN', "")} - with async_timeout.timeout(10, loop=self.loop): - data = yield from request.read() - if data: - headers[CONTENT_TYPE] = request.content_type - else: - data = None - - method = getattr(self.websession, request.method.lower()) - client = yield from method( - "http://{}/{}".format(self._ip, path), data=data, - headers=headers, timeout=read_timeout - ) - - return client - - except aiohttp.ClientError as err: - _LOGGER.error("Client error on api %s request %s", path, err) - - except asyncio.TimeoutError: - _LOGGER.error("Client timeout error on API request %s", path) - - raise HTTPBadGateway() - - -class HassIOView(HomeAssistantView): - """Hass.io view to handle base part.""" - - name = "api:hassio" - url = "/api/hassio/{path:.+}" - requires_auth = False - - def __init__(self, hassio): - """Initialize a Hass.io base view.""" - self.hassio = hassio - - @asyncio.coroutine - def _handle(self, request, path): - """Route data to Hass.io.""" - if _need_auth(path) and not request[KEY_AUTHENTICATED]: - return web.Response(status=401) - - client = yield from self.hassio.command_proxy(path, request) - - data = yield from client.read() - if path.endswith('/logs'): - return _create_response_log(client, data) - return _create_response(client, data) - - get = _handle - post = _handle - - -def _create_response(client, data): - """Convert a response from client request.""" - return web.Response( - body=data, - status=client.status, - content_type=client.content_type, - ) - - -def _create_response_log(client, data): - """Convert a response from client request.""" - # Remove color codes - log = re.sub(r"\x1b(\[.*?[@-~]|\].*?(\x07|\x1b\\))", "", data.decode()) - - return web.Response( - text=log, - status=client.status, - content_type=CONTENT_TYPE_TEXT_PLAIN, - ) - - -def _get_timeout(path): - """Return timeout for a URL path.""" - for re_path in NO_TIMEOUT: - if re_path.match(path): - return 0 - return 300 - - -def _need_auth(path): - """Return if a path need authentication.""" - for re_path in NO_AUTH: - if re_path.match(path): - return False - return True diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py new file mode 100644 index 00000000000..540659273b3 --- /dev/null +++ b/homeassistant/components/hassio/__init__.py @@ -0,0 +1,232 @@ +""" +Exposes regular REST commands as services. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/hassio/ +""" +import asyncio +from datetime import timedelta +import logging +import os + +import voluptuous as vol + +from homeassistant.components import SERVICE_CHECK_CONFIG +from homeassistant.const import ( + SERVICE_HOMEASSISTANT_RESTART, SERVICE_HOMEASSISTANT_STOP) +from homeassistant.core import DOMAIN as HASS_DOMAIN +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.loader import bind_hass +from homeassistant.util.dt import utcnow +from .handler import HassIO +from .http import HassIOView + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'hassio' +DEPENDENCIES = ['http'] + +DATA_HOMEASSISTANT_VERSION = 'hassio_hass_version' +HASSIO_UPDATE_INTERVAL = timedelta(minutes=55) + +SERVICE_ADDON_START = 'addon_start' +SERVICE_ADDON_STOP = 'addon_stop' +SERVICE_ADDON_RESTART = 'addon_restart' +SERVICE_ADDON_STDIN = 'addon_stdin' +SERVICE_HOST_SHUTDOWN = 'host_shutdown' +SERVICE_HOST_REBOOT = 'host_reboot' +SERVICE_SNAPSHOT_FULL = 'snapshot_full' +SERVICE_SNAPSHOT_PARTIAL = 'snapshot_partial' +SERVICE_RESTORE_FULL = 'restore_full' +SERVICE_RESTORE_PARTIAL = 'restore_partial' + +ATTR_ADDON = 'addon' +ATTR_INPUT = 'input' +ATTR_SNAPSHOT = 'snapshot' +ATTR_ADDONS = 'addons' +ATTR_FOLDERS = 'folders' +ATTR_HOMEASSISTANT = 'homeassistant' +ATTR_NAME = 'name' +ATTR_PASSWORD = 'password' + +SCHEMA_NO_DATA = vol.Schema({}) + +SCHEMA_ADDON = vol.Schema({ + vol.Required(ATTR_ADDON): cv.slug, +}) + +SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend({ + vol.Required(ATTR_INPUT): vol.Any(dict, cv.string) +}) + +SCHEMA_SNAPSHOT_FULL = vol.Schema({ + vol.Optional(ATTR_NAME): cv.string, + vol.Optional(ATTR_PASSWORD): cv.string, +}) + +SCHEMA_SNAPSHOT_PARTIAL = SCHEMA_SNAPSHOT_FULL.extend({ + vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_ADDONS): vol.All(cv.ensure_list, [cv.string]), +}) + +SCHEMA_RESTORE_FULL = vol.Schema({ + vol.Required(ATTR_SNAPSHOT): cv.slug, + vol.Optional(ATTR_PASSWORD): cv.string, +}) + +SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend({ + vol.Optional(ATTR_HOMEASSISTANT): cv.boolean, + vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_ADDONS): vol.All(cv.ensure_list, [cv.string]), +}) + +MAP_SERVICE_API = { + SERVICE_ADDON_START: ('/addons/{addon}/start', SCHEMA_ADDON, 60, False), + SERVICE_ADDON_STOP: ('/addons/{addon}/stop', SCHEMA_ADDON, 60, False), + SERVICE_ADDON_RESTART: + ('/addons/{addon}/restart', SCHEMA_ADDON, 60, False), + SERVICE_ADDON_STDIN: + ('/addons/{addon}/stdin', SCHEMA_ADDON_STDIN, 60, False), + SERVICE_HOST_SHUTDOWN: ('/host/shutdown', SCHEMA_NO_DATA, 60, False), + SERVICE_HOST_REBOOT: ('/host/reboot', SCHEMA_NO_DATA, 60, False), + SERVICE_SNAPSHOT_FULL: + ('/snapshots/new/full', SCHEMA_SNAPSHOT_FULL, 300, True), + SERVICE_SNAPSHOT_PARTIAL: + ('/snapshots/new/partial', SCHEMA_SNAPSHOT_PARTIAL, 300, True), + SERVICE_RESTORE_FULL: + ('/snapshots/{snapshot}/restore/full', SCHEMA_RESTORE_FULL, 300, True), + SERVICE_RESTORE_PARTIAL: + ('/snapshots/{snapshot}/restore/partial', SCHEMA_RESTORE_PARTIAL, 300, + True), +} + + +@callback +@bind_hass +def get_homeassistant_version(hass): + """Return latest available Home Assistant version. + + Async friendly. + """ + return hass.data.get(DATA_HOMEASSISTANT_VERSION) + + +@callback +@bind_hass +def is_hassio(hass): + """Return true if hass.io is loaded. + + Async friendly. + """ + return DOMAIN in hass.config.components + + +@bind_hass +@asyncio.coroutine +def async_check_config(hass): + """Check configuration over Hass.io API.""" + hassio = hass.data[DOMAIN] + result = yield from hassio.check_homeassistant_config() + + if not result: + return "Hass.io config check API error" + elif result['result'] == "error": + return result['message'] + return None + + +@asyncio.coroutine +def async_setup(hass, config): + """Set up the Hass.io component.""" + try: + host = os.environ['HASSIO'] + except KeyError: + _LOGGER.error("No Hass.io supervisor detect") + return False + + websession = hass.helpers.aiohttp_client.async_get_clientsession() + hass.data[DOMAIN] = hassio = HassIO(hass.loop, websession, host) + + if not (yield from hassio.is_connected()): + _LOGGER.error("Not connected with Hass.io") + return False + + hass.http.register_view(HassIOView(host, websession)) + + if 'frontend' in hass.config.components: + yield from hass.components.frontend.async_register_built_in_panel( + 'hassio', 'Hass.io', 'mdi:access-point-network') + + if 'http' in config: + yield from hassio.update_hass_api(config['http']) + + if 'homeassistant' in config: + yield from hassio.update_hass_timezone(config['homeassistant']) + + @asyncio.coroutine + def async_service_handler(service): + """Handle service calls for Hass.io.""" + api_command = MAP_SERVICE_API[service.service][0] + data = service.data.copy() + addon = data.pop(ATTR_ADDON, None) + snapshot = data.pop(ATTR_SNAPSHOT, None) + payload = None + + # Pass data to hass.io API + if service.service == SERVICE_ADDON_STDIN: + payload = data[ATTR_INPUT] + elif MAP_SERVICE_API[service.service][3]: + payload = data + + # Call API + ret = yield from hassio.send_command( + api_command.format(addon=addon, snapshot=snapshot), + payload=payload, timeout=MAP_SERVICE_API[service.service][2] + ) + + if not ret or ret['result'] != "ok": + _LOGGER.error("Error on Hass.io API: %s", ret['message']) + + for service, settings in MAP_SERVICE_API.items(): + hass.services.async_register( + DOMAIN, service, async_service_handler, schema=settings[1]) + + @asyncio.coroutine + def update_homeassistant_version(now): + """Update last available Home Assistant version.""" + data = yield from hassio.get_homeassistant_info() + if data: + hass.data[DATA_HOMEASSISTANT_VERSION] = data['last_version'] + + hass.helpers.event.async_track_point_in_utc_time( + update_homeassistant_version, utcnow() + HASSIO_UPDATE_INTERVAL) + + # Fetch last version + yield from update_homeassistant_version(None) + + @asyncio.coroutine + def async_handle_core_service(call): + """Service handler for handling core services.""" + if call.service == SERVICE_HOMEASSISTANT_STOP: + yield from hassio.stop_homeassistant() + return + + error = yield from async_check_config(hass) + if error: + _LOGGER.error(error) + hass.components.persistent_notification.async_create( + "Config error. See dev-info panel for details.", + "Config validating", "{0}.check_config".format(HASS_DOMAIN)) + return + + if call.service == SERVICE_HOMEASSISTANT_RESTART: + yield from hassio.restart_homeassistant() + + # Mock core services + for service in (SERVICE_HOMEASSISTANT_STOP, SERVICE_HOMEASSISTANT_RESTART, + SERVICE_CHECK_CONFIG): + hass.services.async_register( + HASS_DOMAIN, service, async_handle_core_service) + + return True diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py new file mode 100644 index 00000000000..a954aaccbd4 --- /dev/null +++ b/homeassistant/components/hassio/handler.py @@ -0,0 +1,154 @@ +""" +Exposes regular REST commands as services. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/hassio/ +""" +import asyncio +import logging +import os + +import aiohttp +import async_timeout + +from homeassistant.components.http import ( + CONF_API_PASSWORD, CONF_SERVER_HOST, CONF_SERVER_PORT, + CONF_SSL_CERTIFICATE) +from homeassistant.const import CONF_TIME_ZONE, SERVER_PORT + +_LOGGER = logging.getLogger(__name__) + +X_HASSIO = 'X-HASSIO-KEY' + + +def _api_bool(funct): + """Return a boolean.""" + @asyncio.coroutine + def _wrapper(*argv, **kwargs): + """Wrap function.""" + data = yield from funct(*argv, **kwargs) + return data and data['result'] == "ok" + + return _wrapper + + +def _api_data(funct): + """Return a api data.""" + @asyncio.coroutine + def _wrapper(*argv, **kwargs): + """Wrap function.""" + data = yield from funct(*argv, **kwargs) + if data and data['result'] == "ok": + return data['data'] + return None + + return _wrapper + + +class HassIO(object): + """Small API wrapper for Hass.io.""" + + def __init__(self, loop, websession, ip): + """Initialize Hass.io API.""" + self.loop = loop + self.websession = websession + self._ip = ip + + @_api_bool + def is_connected(self): + """Return true if it connected to Hass.io supervisor. + + This method return a coroutine. + """ + return self.send_command("/supervisor/ping", method="get") + + @_api_data + def get_homeassistant_info(self): + """Return data for Home Assistant. + + This method return a coroutine. + """ + return self.send_command("/homeassistant/info", method="get") + + @_api_bool + def restart_homeassistant(self): + """Restart Home-Assistant container. + + This method return a coroutine. + """ + return self.send_command("/homeassistant/restart") + + @_api_bool + def stop_homeassistant(self): + """Stop Home-Assistant container. + + This method return a coroutine. + """ + return self.send_command("/homeassistant/stop") + + def check_homeassistant_config(self): + """Check Home-Assistant config with Hass.io API. + + This method return a coroutine. + """ + return self.send_command("/homeassistant/check", timeout=300) + + @_api_bool + def update_hass_api(self, http_config): + """Update Home Assistant API data on Hass.io. + + This method return a coroutine. + """ + port = http_config.get(CONF_SERVER_PORT) or SERVER_PORT + options = { + 'ssl': CONF_SSL_CERTIFICATE in http_config, + 'port': port, + 'password': http_config.get(CONF_API_PASSWORD), + 'watchdog': True, + } + + if CONF_SERVER_HOST in http_config: + options['watchdog'] = False + _LOGGER.warning("Don't use 'server_host' options with Hass.io") + + return self.send_command("/homeassistant/options", payload=options) + + @_api_bool + def update_hass_timezone(self, core_config): + """Update Home-Assistant timezone data on Hass.io. + + This method return a coroutine. + """ + return self.send_command("/supervisor/options", payload={ + 'timezone': core_config.get(CONF_TIME_ZONE) + }) + + @asyncio.coroutine + def send_command(self, command, method="post", payload=None, timeout=10): + """Send API command to Hass.io. + + This method is a coroutine. + """ + try: + with async_timeout.timeout(timeout, loop=self.loop): + request = yield from self.websession.request( + method, "http://{}{}".format(self._ip, command), + json=payload, headers={ + X_HASSIO: os.environ.get('HASSIO_TOKEN', "") + }) + + if request.status not in (200, 400): + _LOGGER.error( + "%s return code %d.", command, request.status) + return None + + answer = yield from request.json() + return answer + + except asyncio.TimeoutError: + _LOGGER.error("Timeout on %s request", command) + + except aiohttp.ClientError as err: + _LOGGER.error("Client error on %s request %s", command, err) + + return None diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py new file mode 100644 index 00000000000..9dd6427ec38 --- /dev/null +++ b/homeassistant/components/hassio/http.py @@ -0,0 +1,142 @@ +""" +Exposes regular REST commands as services. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/hassio/ +""" +import asyncio +import logging +import os +import re + +import async_timeout +import aiohttp +from aiohttp import web +from aiohttp.hdrs import CONTENT_TYPE +from aiohttp.web_exceptions import HTTPBadGateway + +from homeassistant.const import CONTENT_TYPE_TEXT_PLAIN +from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView + +_LOGGER = logging.getLogger(__name__) + +X_HASSIO = 'X-HASSIO-KEY' + +NO_TIMEOUT = { + re.compile(r'^homeassistant/update$'), + re.compile(r'^host/update$'), + re.compile(r'^supervisor/update$'), + re.compile(r'^addons/[^/]*/update$'), + re.compile(r'^addons/[^/]*/install$'), + re.compile(r'^addons/[^/]*/rebuild$'), + re.compile(r'^snapshots/.*/full$'), + re.compile(r'^snapshots/.*/partial$'), + re.compile(r'^snapshots/[^/]*/upload$'), + re.compile(r'^snapshots/[^/]*/download$'), +} + +NO_AUTH = { + re.compile(r'^app-(es5|latest)/(index|hassio-app).html$'), + re.compile(r'^addons/[^/]*/logo$') +} + + +class HassIOView(HomeAssistantView): + """Hass.io view to handle base part.""" + + name = "api:hassio" + url = "/api/hassio/{path:.+}" + requires_auth = False + + def __init__(self, host, websession): + """Initialize a Hass.io base view.""" + self._host = host + self._websession = websession + + @asyncio.coroutine + def _handle(self, request, path): + """Route data to Hass.io.""" + if _need_auth(path) and not request[KEY_AUTHENTICATED]: + return web.Response(status=401) + + client = yield from self._command_proxy(path, request) + + data = yield from client.read() + if path.endswith('/logs'): + return _create_response_log(client, data) + return _create_response(client, data) + + get = _handle + post = _handle + + @asyncio.coroutine + def _command_proxy(self, path, request): + """Return a client request with proxy origin for Hass.io supervisor. + + This method is a coroutine. + """ + read_timeout = _get_timeout(path) + hass = request.app['hass'] + + try: + data = None + headers = {X_HASSIO: os.environ.get('HASSIO_TOKEN', "")} + with async_timeout.timeout(10, loop=hass.loop): + data = yield from request.read() + if data: + headers[CONTENT_TYPE] = request.content_type + else: + data = None + + method = getattr(self._websession, request.method.lower()) + client = yield from method( + "http://{}/{}".format(self._host, path), data=data, + headers=headers, timeout=read_timeout + ) + + return client + + except aiohttp.ClientError as err: + _LOGGER.error("Client error on api %s request %s", path, err) + + except asyncio.TimeoutError: + _LOGGER.error("Client timeout error on API request %s", path) + + raise HTTPBadGateway() + + +def _create_response(client, data): + """Convert a response from client request.""" + return web.Response( + body=data, + status=client.status, + content_type=client.content_type, + ) + + +def _create_response_log(client, data): + """Convert a response from client request.""" + # Remove color codes + log = re.sub(r"\x1b(\[.*?[@-~]|\].*?(\x07|\x1b\\))", "", data.decode()) + + return web.Response( + text=log, + status=client.status, + content_type=CONTENT_TYPE_TEXT_PLAIN, + ) + + +def _get_timeout(path): + """Return timeout for a URL path.""" + for re_path in NO_TIMEOUT: + if re_path.match(path): + return 0 + return 300 + + +def _need_auth(path): + """Return if a path need authentication.""" + for re_path in NO_AUTH: + if re_path.match(path): + return False + return True diff --git a/homeassistant/components/history.py b/homeassistant/components/history.py index 8f58f5f7e17..dd14bbf6811 100644 --- a/homeassistant/components/history.py +++ b/homeassistant/components/history.py @@ -241,12 +241,12 @@ def async_setup(hass, config): filters = Filters() exclude = config[DOMAIN].get(CONF_EXCLUDE) if exclude: - filters.excluded_entities = exclude[CONF_ENTITIES] - filters.excluded_domains = exclude[CONF_DOMAINS] + filters.excluded_entities = exclude.get(CONF_ENTITIES, []) + filters.excluded_domains = exclude.get(CONF_DOMAINS, []) include = config[DOMAIN].get(CONF_INCLUDE) if include: - filters.included_entities = include[CONF_ENTITIES] - filters.included_domains = include[CONF_DOMAINS] + filters.included_entities = include.get(CONF_ENTITIES, []) + filters.included_domains = include.get(CONF_DOMAINS, []) use_include_order = config[DOMAIN].get(CONF_ORDER) hass.http.register_view(HistoryPeriodView(filters, use_include_order)) @@ -303,8 +303,10 @@ class HistoryPeriodView(HomeAssistantView): entity_ids = entity_ids.lower().split(',') include_start_time_state = 'skip_initial_state' not in request.query - result = yield from request.app['hass'].async_add_job( - get_significant_states, request.app['hass'], start_time, end_time, + hass = request.app['hass'] + + result = yield from hass.async_add_job( + get_significant_states, hass, start_time, end_time, entity_ids, self.filters, include_start_time_state) result = result.values() if _LOGGER.isEnabledFor(logging.DEBUG): @@ -327,7 +329,8 @@ class HistoryPeriodView(HomeAssistantView): sorted_result.extend(result) result = sorted_result - return self.json(result) + response = yield from hass.async_add_job(self.json, result) + return response class Filters(object): diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py new file mode 100644 index 00000000000..021c682466e --- /dev/null +++ b/homeassistant/components/homekit/__init__.py @@ -0,0 +1,133 @@ +"""Support for Apple Homekit. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/homekit/ +""" +import asyncio +import logging +import re + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, CONF_PORT, + TEMP_CELSIUS, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) +from homeassistant.util import get_local_ip +from homeassistant.util.decorator import Registry + +TYPES = Registry() +_LOGGER = logging.getLogger(__name__) + +_RE_VALID_PINCODE = re.compile(r"^(\d{3}-\d{2}-\d{3})$") + +DOMAIN = 'homekit' +REQUIREMENTS = ['HAP-python==1.1.5'] + +BRIDGE_NAME = 'Home Assistant' +CONF_PIN_CODE = 'pincode' + +HOMEKIT_FILE = '.homekit.state' + + +def valid_pin(value): + """Validate pincode value.""" + match = _RE_VALID_PINCODE.findall(value.strip()) + if match == []: + raise vol.Invalid("Pin must be in the format: '123-45-678'") + return match[0] + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.All({ + vol.Optional(CONF_PORT, default=51826): vol.Coerce(int), + vol.Optional(CONF_PIN_CODE, default='123-45-678'): valid_pin, + }) +}, extra=vol.ALLOW_EXTRA) + + +@asyncio.coroutine +def async_setup(hass, config): + """Setup the homekit component.""" + _LOGGER.debug("Begin setup homekit") + + conf = config[DOMAIN] + port = conf.get(CONF_PORT) + pin = str.encode(conf.get(CONF_PIN_CODE)) + + homekit = Homekit(hass, port) + homekit.setup_bridge(pin) + + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, homekit.start_driver) + return True + + +def import_types(): + """Import all types from files in the homekit dir.""" + _LOGGER.debug("Import type files.") + # pylint: disable=unused-variable + from .covers import Window # noqa F401 + # pylint: disable=unused-variable + from .sensors import TemperatureSensor # noqa F401 + + +def get_accessory(hass, state): + """Take state and return an accessory object if supported.""" + if state.domain == 'sensor': + if state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS: + _LOGGER.debug("Add \"%s\" as \"%s\"", + state.entity_id, 'TemperatureSensor') + return TYPES['TemperatureSensor'](hass, state.entity_id, + state.name) + + elif state.domain == 'cover': + # Only add covers that support set_cover_position + if state.attributes.get(ATTR_SUPPORTED_FEATURES) & 4: + _LOGGER.debug("Add \"%s\" as \"%s\"", + state.entity_id, 'Window') + return TYPES['Window'](hass, state.entity_id, state.name) + + return None + + +class Homekit(): + """Class to handle all actions between homekit and Home Assistant.""" + + def __init__(self, hass, port): + """Initialize a homekit object.""" + self._hass = hass + self._port = port + self.bridge = None + self.driver = None + + def setup_bridge(self, pin): + """Setup the bridge component to track all accessories.""" + from .accessories import HomeBridge + self.bridge = HomeBridge(BRIDGE_NAME, pincode=pin) + self.bridge.set_accessory_info('homekit.bridge') + + def start_driver(self, event): + """Start the accessory driver.""" + from pyhap.accessory_driver import AccessoryDriver + self._hass.bus.listen_once( + EVENT_HOMEASSISTANT_STOP, self.stop_driver) + + import_types() + _LOGGER.debug("Start adding accessories.") + for state in self._hass.states.all(): + acc = get_accessory(self._hass, state) + if acc is not None: + self.bridge.add_accessory(acc) + + ip_address = get_local_ip() + path = self._hass.config.path(HOMEKIT_FILE) + self.driver = AccessoryDriver(self.bridge, self._port, + ip_address, path) + _LOGGER.debug("Driver started") + self.driver.start() + + def stop_driver(self, event): + """Stop the accessory driver.""" + _LOGGER.debug("Driver stop") + if self.driver is not None: + self.driver.stop() diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py new file mode 100644 index 00000000000..e1a25a2c976 --- /dev/null +++ b/homeassistant/components/homekit/accessories.py @@ -0,0 +1,55 @@ +"""Extend the basic Accessory and Bridge functions.""" +from pyhap.accessory import Accessory, Bridge, Category + +from .const import ( + SERVICES_ACCESSORY_INFO, MANUFACTURER, + CHAR_MODEL, CHAR_MANUFACTURER, CHAR_SERIAL_NUMBER) + + +class HomeAccessory(Accessory): + """Class to extend the Accessory class.""" + + ALL_CATEGORIES = Category + + def __init__(self, display_name): + """Initialize a Accessory object.""" + super().__init__(display_name) + + def set_category(self, category): + """Set the category of the accessory.""" + self.category = category + + def add_preload_service(self, service): + """Define the services to be available for the accessory.""" + from pyhap.loader import get_serv_loader + self.add_service(get_serv_loader().get(service)) + + def set_accessory_info(self, model, manufacturer=MANUFACTURER, + serial_number='0000'): + """Set the default accessory information.""" + service_info = self.get_service(SERVICES_ACCESSORY_INFO) + service_info.get_characteristic(CHAR_MODEL) \ + .set_value(model) + service_info.get_characteristic(CHAR_MANUFACTURER) \ + .set_value(manufacturer) + service_info.get_characteristic(CHAR_SERIAL_NUMBER) \ + .set_value(serial_number) + + +class HomeBridge(Bridge): + """Class to extend the Bridge class.""" + + def __init__(self, display_name, pincode): + """Initialize a Bridge object.""" + super().__init__(display_name, pincode=pincode) + + def set_accessory_info(self, model, manufacturer=MANUFACTURER, + serial_number='0000'): + """Set the default accessory information.""" + service_info = self.get_service(SERVICES_ACCESSORY_INFO) + service_info.get_characteristic(CHAR_MODEL) \ + .set_value(model) + service_info.get_characteristic(CHAR_MANUFACTURER) \ + .set_value(manufacturer) + service_info.get_characteristic(CHAR_SERIAL_NUMBER) \ + .set_value(serial_number) diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py new file mode 100644 index 00000000000..6c58b7fe45f --- /dev/null +++ b/homeassistant/components/homekit/const.py @@ -0,0 +1,18 @@ +"""Constants used be the homekit component.""" +MANUFACTURER = 'HomeAssistant' + +# Service: AccessoryInfomation +SERVICES_ACCESSORY_INFO = 'AccessoryInformation' +CHAR_MODEL = 'Model' +CHAR_MANUFACTURER = 'Manufacturer' +CHAR_SERIAL_NUMBER = 'SerialNumber' + +# Service: TemperatureSensor +SERVICES_TEMPERATURE_SENSOR = 'TemperatureSensor' +CHAR_CURRENT_TEMPERATURE = 'CurrentTemperature' + +# Service: WindowCovering +SERVICES_WINDOW_COVERING = 'WindowCovering' +CHAR_CURRENT_POSITION = 'CurrentPosition' +CHAR_TARGET_POSITION = 'TargetPosition' +CHAR_POSITION_STATE = 'PositionState' diff --git a/homeassistant/components/homekit/covers.py b/homeassistant/components/homekit/covers.py new file mode 100644 index 00000000000..1068b1e0e3f --- /dev/null +++ b/homeassistant/components/homekit/covers.py @@ -0,0 +1,84 @@ +"""Class to hold all cover accessories.""" +import logging + +from homeassistant.components.cover import ATTR_CURRENT_POSITION +from homeassistant.helpers.event import async_track_state_change + +from . import TYPES +from .accessories import HomeAccessory +from .const import ( + SERVICES_WINDOW_COVERING, CHAR_CURRENT_POSITION, + CHAR_TARGET_POSITION, CHAR_POSITION_STATE) + + +_LOGGER = logging.getLogger(__name__) + + +@TYPES.register('Window') +class Window(HomeAccessory): + """Generate a Window accessory for a cover entity. + + The cover entity must support: set_cover_position. + """ + + def __init__(self, hass, entity_id, display_name): + """Initialize a Window accessory object.""" + super().__init__(display_name) + self.set_category(self.ALL_CATEGORIES.WINDOW) + self.set_accessory_info(entity_id) + self.add_preload_service(SERVICES_WINDOW_COVERING) + + self._hass = hass + self._entity_id = entity_id + + self.current_position = None + self.homekit_target = None + + self.service_cover = self.get_service(SERVICES_WINDOW_COVERING) + self.char_current_position = self.service_cover. \ + get_characteristic(CHAR_CURRENT_POSITION) + self.char_target_position = self.service_cover. \ + get_characteristic(CHAR_TARGET_POSITION) + self.char_position_state = self.service_cover. \ + get_characteristic(CHAR_POSITION_STATE) + + self.char_target_position.setter_callback = self.move_cover + + def run(self): + """Method called be object after driver is started.""" + state = self._hass.states.get(self._entity_id) + self.update_cover_position(new_state=state) + + async_track_state_change( + self._hass, self._entity_id, self.update_cover_position) + + def move_cover(self, value): + """Move cover to value if call came from homekit.""" + if value != self.current_position: + _LOGGER.debug("%s: Set position to %d", self._entity_id, value) + self.homekit_target = value + if value > self.current_position: + self.char_position_state.set_value(1) + elif value < self.current_position: + self.char_position_state.set_value(0) + self._hass.services.call( + 'cover', 'set_cover_position', + {'entity_id': self._entity_id, 'position': value}) + + def update_cover_position(self, entity_id=None, old_state=None, + new_state=None): + """Update cover position after state changed.""" + if new_state is None: + return + + current_position = new_state.attributes[ATTR_CURRENT_POSITION] + if current_position is None: + return + self.current_position = int(current_position) + self.char_current_position.set_value(self.current_position) + + if self.homekit_target is None or \ + abs(self.current_position - self.homekit_target) < 6: + self.char_target_position.set_value(self.current_position) + self.char_position_state.set_value(2) + self.homekit_target = None diff --git a/homeassistant/components/homekit/sensors.py b/homeassistant/components/homekit/sensors.py new file mode 100644 index 00000000000..db9ba2d628a --- /dev/null +++ b/homeassistant/components/homekit/sensors.py @@ -0,0 +1,53 @@ +"""Class to hold all sensor accessories.""" +import logging + +from homeassistant.const import STATE_UNKNOWN +from homeassistant.helpers.event import async_track_state_change + +from . import TYPES +from .accessories import HomeAccessory +from .const import ( + SERVICES_TEMPERATURE_SENSOR, CHAR_CURRENT_TEMPERATURE) + + +_LOGGER = logging.getLogger(__name__) + + +@TYPES.register('TemperatureSensor') +class TemperatureSensor(HomeAccessory): + """Generate a TemperatureSensor accessory for a temperature sensor. + + Sensor entity must return either temperature in °C or STATE_UNKNOWN. + """ + + def __init__(self, hass, entity_id, display_name): + """Initialize a TemperatureSensor accessory object.""" + super().__init__(display_name) + self.set_category(self.ALL_CATEGORIES.SENSOR) + self.set_accessory_info(entity_id) + self.add_preload_service(SERVICES_TEMPERATURE_SENSOR) + + self._hass = hass + self._entity_id = entity_id + + self.service_temp = self.get_service(SERVICES_TEMPERATURE_SENSOR) + self.char_temp = self.service_temp. \ + get_characteristic(CHAR_CURRENT_TEMPERATURE) + + def run(self): + """Method called be object after driver is started.""" + state = self._hass.states.get(self._entity_id) + self.update_temperature(new_state=state) + + async_track_state_change( + self._hass, self._entity_id, self.update_temperature) + + def update_temperature(self, entity_id=None, old_state=None, + new_state=None): + """Update temperature after state changed.""" + if new_state is None: + return + + temperature = new_state.state + if temperature != STATE_UNKNOWN: + self.char_temp.set_value(float(temperature)) diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index 9c08984a23e..38ce712b9b0 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -180,7 +180,7 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, }}, vol.Optional(CONF_LOCAL_IP, default=DEFAULT_LOCAL_IP): cv.string, - vol.Optional(CONF_LOCAL_PORT, default=DEFAULT_LOCAL_PORT): cv.port, + vol.Optional(CONF_LOCAL_PORT): cv.port, }), }, extra=vol.ALLOW_EXTRA) @@ -310,7 +310,7 @@ def setup(hass, config): bound_system_callback = partial(_system_callback_handler, hass, config) hass.data[DATA_HOMEMATIC] = homematic = HMConnection( local=config[DOMAIN].get(CONF_LOCAL_IP), - localport=config[DOMAIN].get(CONF_LOCAL_PORT), + localport=config[DOMAIN].get(CONF_LOCAL_PORT, DEFAULT_LOCAL_PORT), remotes=remotes, systemcallback=bound_system_callback, interface_id='homeassistant' diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 33f97395945..450d802e408 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -5,7 +5,6 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/http/ """ import asyncio -from functools import wraps from ipaddress import ip_network import json import logging @@ -13,35 +12,28 @@ import os import ssl from aiohttp import web -from aiohttp.hdrs import ACCEPT, ORIGIN, CONTENT_TYPE from aiohttp.web_exceptions import HTTPUnauthorized, HTTPMovedPermanently import voluptuous as vol from homeassistant.const import ( - SERVER_PORT, CONTENT_TYPE_JSON, HTTP_HEADER_HA_AUTH, - EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START, - HTTP_HEADER_X_REQUESTED_WITH) + SERVER_PORT, CONTENT_TYPE_JSON, + EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START,) from homeassistant.core import is_callback import homeassistant.helpers.config_validation as cv import homeassistant.remote as rem import homeassistant.util as hass_util from homeassistant.util.logging import HideSensitiveDataFilter -from .auth import auth_middleware -from .ban import ban_middleware -from .const import ( - KEY_BANS_ENABLED, KEY_AUTHENTICATED, KEY_LOGIN_THRESHOLD, - KEY_TRUSTED_NETWORKS, KEY_USE_X_FORWARDED_FOR) +from .auth import setup_auth +from .ban import setup_bans +from .cors import setup_cors +from .real_ip import setup_real_ip +from .const import KEY_AUTHENTICATED, KEY_REAL_IP from .static import ( CachingFileResponse, CachingStaticResource, staticresource_middleware) -from .util import get_real_ip REQUIREMENTS = ['aiohttp_cors==0.6.0'] -ALLOWED_CORS_HEADERS = [ - ORIGIN, ACCEPT, HTTP_HEADER_X_REQUESTED_WITH, CONTENT_TYPE, - HTTP_HEADER_HA_AUTH] - DOMAIN = 'http' CONF_API_PASSWORD = 'api_password' @@ -81,22 +73,23 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_SERVER_HOST = '0.0.0.0' DEFAULT_DEVELOPMENT = '0' -DEFAULT_LOGIN_ATTEMPT_THRESHOLD = -1 +NO_LOGIN_ATTEMPT_THRESHOLD = -1 HTTP_SCHEMA = vol.Schema({ - vol.Optional(CONF_API_PASSWORD, default=None): cv.string, + vol.Optional(CONF_API_PASSWORD): cv.string, vol.Optional(CONF_SERVER_HOST, default=DEFAULT_SERVER_HOST): cv.string, vol.Optional(CONF_SERVER_PORT, default=SERVER_PORT): cv.port, vol.Optional(CONF_BASE_URL): cv.string, - vol.Optional(CONF_SSL_CERTIFICATE, default=None): cv.isfile, - vol.Optional(CONF_SSL_KEY, default=None): cv.isfile, + vol.Optional(CONF_SSL_CERTIFICATE): cv.isfile, + vol.Optional(CONF_SSL_KEY): cv.isfile, vol.Optional(CONF_CORS_ORIGINS, default=[]): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_USE_X_FORWARDED_FOR, default=False): cv.boolean, vol.Optional(CONF_TRUSTED_NETWORKS, default=[]): vol.All(cv.ensure_list, [ip_network]), vol.Optional(CONF_LOGIN_ATTEMPTS_THRESHOLD, - default=DEFAULT_LOGIN_ATTEMPT_THRESHOLD): cv.positive_int, + default=NO_LOGIN_ATTEMPT_THRESHOLD): + vol.Any(cv.positive_int, NO_LOGIN_ATTEMPT_THRESHOLD), vol.Optional(CONF_IP_BAN_ENABLED, default=True): cv.boolean }) @@ -113,11 +106,11 @@ def async_setup(hass, config): if conf is None: conf = HTTP_SCHEMA({}) - api_password = conf[CONF_API_PASSWORD] + api_password = conf.get(CONF_API_PASSWORD) server_host = conf[CONF_SERVER_HOST] server_port = conf[CONF_SERVER_PORT] - ssl_certificate = conf[CONF_SSL_CERTIFICATE] - ssl_key = conf[CONF_SSL_KEY] + ssl_certificate = conf.get(CONF_SSL_CERTIFICATE) + ssl_key = conf.get(CONF_SSL_KEY) cors_origins = conf[CONF_CORS_ORIGINS] use_x_forwarded_for = conf[CONF_USE_X_FORWARDED_FOR] trusted_networks = conf[CONF_TRUSTED_NETWORKS] @@ -128,7 +121,7 @@ def async_setup(hass, config): logging.getLogger('aiohttp.access').addFilter( HideSensitiveDataFilter(api_password)) - server = HomeAssistantWSGI( + server = HomeAssistantHTTP( hass, server_host=server_host, server_port=server_port, @@ -174,25 +167,29 @@ def async_setup(hass, config): return True -class HomeAssistantWSGI(object): - """WSGI server for Home Assistant.""" +class HomeAssistantHTTP(object): + """HTTP server for Home Assistant.""" def __init__(self, hass, api_password, ssl_certificate, ssl_key, server_host, server_port, cors_origins, use_x_forwarded_for, trusted_networks, login_threshold, is_ban_enabled): - """Initialize the WSGI Home Assistant server.""" - middlewares = [auth_middleware, staticresource_middleware] + """Initialize the HTTP Home Assistant server.""" + app = self.app = web.Application( + middlewares=[staticresource_middleware]) + + # This order matters + setup_real_ip(app, use_x_forwarded_for) if is_ban_enabled: - middlewares.insert(0, ban_middleware) + setup_bans(hass, app, login_threshold) - self.app = web.Application(middlewares=middlewares) - self.app['hass'] = hass - self.app[KEY_USE_X_FORWARDED_FOR] = use_x_forwarded_for - self.app[KEY_TRUSTED_NETWORKS] = trusted_networks - self.app[KEY_BANS_ENABLED] = is_ban_enabled - self.app[KEY_LOGIN_THRESHOLD] = login_threshold + setup_auth(app, trusted_networks, api_password) + + if cors_origins: + setup_cors(app, cors_origins) + + app['hass'] = hass self.hass = hass self.api_password = api_password @@ -200,21 +197,10 @@ class HomeAssistantWSGI(object): self.ssl_key = ssl_key self.server_host = server_host self.server_port = server_port + self.is_ban_enabled = is_ban_enabled self._handler = None self.server = None - if cors_origins: - import aiohttp_cors - - self.cors = aiohttp_cors.setup(self.app, defaults={ - host: aiohttp_cors.ResourceOptions( - allow_headers=ALLOWED_CORS_HEADERS, - allow_methods='*', - ) for host in cors_origins - }) - else: - self.cors = None - def register_view(self, view): """Register a view with the WSGI server. @@ -293,15 +279,7 @@ class HomeAssistantWSGI(object): @asyncio.coroutine def start(self): """Start the WSGI server.""" - cors_added = set() - if self.cors is not None: - for route in list(self.app.router.routes()): - if hasattr(route, 'resource'): - route = route.resource - if route in cors_added: - continue - self.cors.add(route) - cors_added.add(route) + yield from self.app.startup() if self.ssl_certificate: try: @@ -362,9 +340,11 @@ class HomeAssistantView(object): """Return a JSON response.""" msg = json.dumps( result, sort_keys=True, cls=rem.JSONEncoder).encode('UTF-8') - return web.Response( + response = web.Response( body=msg, content_type=CONTENT_TYPE_JSON, status=status_code, headers=headers) + response.enable_compression() + return response def json_message(self, message, status_code=200, message_code=None, headers=None): @@ -415,14 +395,13 @@ def request_handler_factory(view, handler): if not request.app['hass'].is_running: return web.Response(status=503) - remote_addr = get_real_ip(request) authenticated = request.get(KEY_AUTHENTICATED, False) if view.requires_auth and not authenticated: raise HTTPUnauthorized() _LOGGER.info('Serving %s to %s (auth: %s)', - request.path, remote_addr, authenticated) + request.path, request.get(KEY_REAL_IP), authenticated) result = handler(request, **request.match_info) @@ -449,41 +428,3 @@ def request_handler_factory(view, handler): 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/http/auth.py b/homeassistant/components/http/auth.py index a6a412b6ba2..3128489437a 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -7,55 +7,66 @@ import logging from aiohttp import hdrs from aiohttp.web import middleware +from homeassistant.core import callback from homeassistant.const import HTTP_HEADER_HA_AUTH -from .util import get_real_ip -from .const import KEY_TRUSTED_NETWORKS, KEY_AUTHENTICATED +from .const import KEY_AUTHENTICATED, KEY_REAL_IP DATA_API_PASSWORD = 'api_password' _LOGGER = logging.getLogger(__name__) -@middleware -@asyncio.coroutine -def auth_middleware(request, handler): - """Authenticate as middleware.""" - # If no password set, just always set authenticated=True - if request.app['hass'].http.api_password is None: - request[KEY_AUTHENTICATED] = True +@callback +def setup_auth(app, trusted_networks, api_password): + """Create auth middleware for the app.""" + @middleware + @asyncio.coroutine + def auth_middleware(request, handler): + """Authenticate as middleware.""" + # If no password set, just always set authenticated=True + if api_password is None: + request[KEY_AUTHENTICATED] = True + return (yield from handler(request)) + + # Check authentication + authenticated = False + + if (HTTP_HEADER_HA_AUTH in request.headers and + hmac.compare_digest( + api_password, request.headers[HTTP_HEADER_HA_AUTH])): + # A valid auth header has been set + authenticated = True + + elif (DATA_API_PASSWORD in request.query and + hmac.compare_digest(api_password, + request.query[DATA_API_PASSWORD])): + authenticated = True + + elif (hdrs.AUTHORIZATION in request.headers and + validate_authorization_header(api_password, request)): + authenticated = True + + elif _is_trusted_ip(request, trusted_networks): + authenticated = True + + request[KEY_AUTHENTICATED] = authenticated return (yield from handler(request)) - # Check authentication - authenticated = False + @asyncio.coroutine + def auth_startup(app): + """Initialize auth middleware when app starts up.""" + app.middlewares.append(auth_middleware) - if (HTTP_HEADER_HA_AUTH in request.headers and - validate_password( - request, request.headers[HTTP_HEADER_HA_AUTH])): - # A valid auth header has been set - authenticated = True - - elif (DATA_API_PASSWORD in request.query and - validate_password(request, request.query[DATA_API_PASSWORD])): - authenticated = True - - elif (hdrs.AUTHORIZATION in request.headers and - validate_authorization_header(request)): - authenticated = True - - elif is_trusted_ip(request): - authenticated = True - - request[KEY_AUTHENTICATED] = authenticated - return (yield from handler(request)) + app.on_startup.append(auth_startup) -def is_trusted_ip(request): +def _is_trusted_ip(request, trusted_networks): """Test if request is from a trusted ip.""" - ip_addr = get_real_ip(request) + ip_addr = request[KEY_REAL_IP] - return ip_addr and any( + return any( ip_addr in trusted_network for trusted_network - in request.app[KEY_TRUSTED_NETWORKS]) + in trusted_networks) def validate_password(request, api_password): @@ -64,7 +75,7 @@ def validate_password(request, api_password): api_password, request.app['hass'].http.api_password) -def validate_authorization_header(request): +def validate_authorization_header(api_password, request): """Test an authorization header if valid password.""" if hdrs.AUTHORIZATION not in request.headers: return False @@ -80,4 +91,4 @@ def validate_authorization_header(request): if username != 'homeassistant': return False - return validate_password(request, password) + return hmac.compare_digest(api_password, password) diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index f636ad80c36..4c797b05b19 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -10,18 +10,20 @@ from aiohttp.web import middleware from aiohttp.web_exceptions import HTTPForbidden, HTTPUnauthorized import voluptuous as vol +from homeassistant.core import callback from homeassistant.components import persistent_notification from homeassistant.config import load_yaml_config_file from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.util.yaml import dump -from .const import ( - KEY_BANS_ENABLED, KEY_BANNED_IPS, KEY_LOGIN_THRESHOLD, - KEY_FAILED_LOGIN_ATTEMPTS) -from .util import get_real_ip +from .const import KEY_REAL_IP _LOGGER = logging.getLogger(__name__) +KEY_BANNED_IPS = 'ha_banned_ips' +KEY_FAILED_LOGIN_ATTEMPTS = 'ha_failed_login_attempts' +KEY_LOGIN_THRESHOLD = 'ha_login_threshold' + NOTIFICATION_ID_BAN = 'ip-ban' NOTIFICATION_ID_LOGIN = 'http-login' @@ -33,21 +35,31 @@ SCHEMA_IP_BAN_ENTRY = vol.Schema({ }) +@callback +def setup_bans(hass, app, login_threshold): + """Create IP Ban middleware for the app.""" + @asyncio.coroutine + def ban_startup(app): + """Initialize bans when app starts up.""" + app.middlewares.append(ban_middleware) + app[KEY_BANNED_IPS] = yield from hass.async_add_job( + load_ip_bans_config, hass.config.path(IP_BANS_FILE)) + app[KEY_FAILED_LOGIN_ATTEMPTS] = defaultdict(int) + app[KEY_LOGIN_THRESHOLD] = login_threshold + + app.on_startup.append(ban_startup) + + @middleware @asyncio.coroutine def ban_middleware(request, handler): """IP Ban middleware.""" - if not request.app[KEY_BANS_ENABLED]: + if KEY_BANNED_IPS not in request.app: + _LOGGER.error('IP Ban middleware loaded but banned IPs not loaded') return (yield from handler(request)) - if KEY_BANNED_IPS not in request.app: - hass = request.app['hass'] - request.app[KEY_BANNED_IPS] = yield from hass.async_add_job( - load_ip_bans_config, hass.config.path(IP_BANS_FILE)) - # Verify if IP is not banned - ip_address_ = get_real_ip(request) - + ip_address_ = request[KEY_REAL_IP] is_banned = any(ip_ban.ip_address == ip_address_ for ip_ban in request.app[KEY_BANNED_IPS]) @@ -64,7 +76,7 @@ def ban_middleware(request, handler): @asyncio.coroutine def process_wrong_login(request): """Process a wrong login attempt.""" - remote_addr = get_real_ip(request) + remote_addr = request[KEY_REAL_IP] msg = ('Login attempt or request with invalid authentication ' 'from {}'.format(remote_addr)) @@ -73,13 +85,11 @@ def process_wrong_login(request): request.app['hass'], msg, 'Login attempt failed', NOTIFICATION_ID_LOGIN) - if (not request.app[KEY_BANS_ENABLED] or + # Check if ban middleware is loaded + if (KEY_BANNED_IPS not in request.app or request.app[KEY_LOGIN_THRESHOLD] < 1): return - if KEY_FAILED_LOGIN_ATTEMPTS not in request.app: - request.app[KEY_FAILED_LOGIN_ATTEMPTS] = defaultdict(int) - request.app[KEY_FAILED_LOGIN_ATTEMPTS][remote_addr] += 1 if (request.app[KEY_FAILED_LOGIN_ATTEMPTS][remote_addr] > @@ -103,7 +113,7 @@ def process_wrong_login(request): class IpBan(object): """Represents banned IP address.""" - def __init__(self, ip_ban: str, banned_at: datetime=None) -> None: + def __init__(self, ip_ban: str, banned_at: datetime = None) -> None: """Initialize IP Ban object.""" self.ip_address = ip_address(ip_ban) self.banned_at = banned_at or datetime.utcnow() diff --git a/homeassistant/components/http/const.py b/homeassistant/components/http/const.py index 4250dd32514..e5494e945c4 100644 --- a/homeassistant/components/http/const.py +++ b/homeassistant/components/http/const.py @@ -1,11 +1,3 @@ """HTTP specific constants.""" KEY_AUTHENTICATED = 'ha_authenticated' -KEY_USE_X_FORWARDED_FOR = 'ha_use_x_forwarded_for' -KEY_TRUSTED_NETWORKS = 'ha_trusted_networks' KEY_REAL_IP = 'ha_real_ip' -KEY_BANS_ENABLED = 'ha_bans_enabled' -KEY_BANNED_IPS = 'ha_banned_ips' -KEY_FAILED_LOGIN_ATTEMPTS = 'ha_failed_login_attempts' -KEY_LOGIN_THRESHOLD = 'ha_login_threshold' - -HTTP_HEADER_X_FORWARDED_FOR = 'X-Forwarded-For' diff --git a/homeassistant/components/http/cors.py b/homeassistant/components/http/cors.py new file mode 100644 index 00000000000..2eb92732d1e --- /dev/null +++ b/homeassistant/components/http/cors.py @@ -0,0 +1,43 @@ +"""Provide cors support for the HTTP component.""" +import asyncio + +from aiohttp.hdrs import ACCEPT, ORIGIN, CONTENT_TYPE + +from homeassistant.const import ( + HTTP_HEADER_X_REQUESTED_WITH, HTTP_HEADER_HA_AUTH) + + +from homeassistant.core import callback + + +ALLOWED_CORS_HEADERS = [ + ORIGIN, ACCEPT, HTTP_HEADER_X_REQUESTED_WITH, CONTENT_TYPE, + HTTP_HEADER_HA_AUTH] + + +@callback +def setup_cors(app, origins): + """Setup cors.""" + import aiohttp_cors + + cors = aiohttp_cors.setup(app, defaults={ + host: aiohttp_cors.ResourceOptions( + allow_headers=ALLOWED_CORS_HEADERS, + allow_methods='*', + ) for host in origins + }) + + @asyncio.coroutine + def cors_startup(app): + """Initialize cors when app starts up.""" + cors_added = set() + + for route in list(app.router.routes()): + if hasattr(route, 'resource'): + route = route.resource + if route in cors_added: + continue + cors.add(route) + cors_added.add(route) + + app.on_startup.append(cors_startup) diff --git a/homeassistant/components/http/data_validator.py b/homeassistant/components/http/data_validator.py new file mode 100644 index 00000000000..528c0a598e3 --- /dev/null +++ b/homeassistant/components/http/data_validator.py @@ -0,0 +1,51 @@ +"""Decorator for view methods to help with data validation.""" +import asyncio +from functools import wraps +import logging + +import voluptuous as vol + +_LOGGER = logging.getLogger(__name__) + + +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, allow_empty=False): + """Initialize the decorator.""" + self._schema = schema + self._allow_empty = allow_empty + + def __call__(self, method): + """Decorate a function.""" + @asyncio.coroutine + @wraps(method) + def wrapper(view, request, *args, **kwargs): + """Wrap a request handler with data validation.""" + data = None + try: + data = yield from request.json() + except ValueError: + if not self._allow_empty or \ + (yield from request.content.read()) != b'': + _LOGGER.error('Invalid JSON received.') + return view.json_message('Invalid JSON.', 400) + data = {} + + 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/http/real_ip.py b/homeassistant/components/http/real_ip.py new file mode 100644 index 00000000000..1e50f33f69e --- /dev/null +++ b/homeassistant/components/http/real_ip.py @@ -0,0 +1,35 @@ +"""Middleware to fetch real IP.""" +import asyncio +from ipaddress import ip_address + +from aiohttp.web import middleware +from aiohttp.hdrs import X_FORWARDED_FOR + +from homeassistant.core import callback + +from .const import KEY_REAL_IP + + +@callback +def setup_real_ip(app, use_x_forwarded_for): + """Create IP Ban middleware for the app.""" + @middleware + @asyncio.coroutine + def real_ip_middleware(request, handler): + """Real IP middleware.""" + if (use_x_forwarded_for and + X_FORWARDED_FOR in request.headers): + request[KEY_REAL_IP] = ip_address( + request.headers.get(X_FORWARDED_FOR).split(',')[0]) + else: + request[KEY_REAL_IP] = \ + ip_address(request.transport.get_extra_info('peername')[0]) + + return (yield from handler(request)) + + @asyncio.coroutine + def app_startup(app): + """Initialize bans when app starts up.""" + app.middlewares.append(real_ip_middleware) + + app.on_startup.append(app_startup) diff --git a/homeassistant/components/http/util.py b/homeassistant/components/http/util.py deleted file mode 100644 index 1a5a3d98a22..00000000000 --- a/homeassistant/components/http/util.py +++ /dev/null @@ -1,25 +0,0 @@ -"""HTTP utilities.""" -from ipaddress import ip_address - -from .const import ( - KEY_REAL_IP, KEY_USE_X_FORWARDED_FOR, HTTP_HEADER_X_FORWARDED_FOR) - - -def get_real_ip(request): - """Get IP address of client.""" - if KEY_REAL_IP in request: - return request[KEY_REAL_IP] - - if (request.app[KEY_USE_X_FORWARDED_FOR] and - HTTP_HEADER_X_FORWARDED_FOR in request.headers): - request[KEY_REAL_IP] = ip_address( - request.headers.get(HTTP_HEADER_X_FORWARDED_FOR).split(',')[0]) - else: - peername = request.transport.get_extra_info('peername') - - if peername: - request[KEY_REAL_IP] = ip_address(peername[0]) - else: - request[KEY_REAL_IP] = None - - return request[KEY_REAL_IP] diff --git a/homeassistant/components/ihc/__init__.py b/homeassistant/components/ihc/__init__.py index f3cd9d79046..04be7dd5ab0 100644 --- a/homeassistant/components/ihc/__init__.py +++ b/homeassistant/components/ihc/__init__.py @@ -46,7 +46,7 @@ AUTO_SETUP_SCHEMA = vol.Schema({ vol.All({ vol.Required(CONF_XPATH): cv.string, vol.Required(CONF_NODE): cv.string, - vol.Optional(CONF_TYPE, default=None): cv.string, + vol.Optional(CONF_TYPE): cv.string, vol.Optional(CONF_INVERTING, default=False): cv.boolean, }) ]), diff --git a/homeassistant/components/ihc/ihcdevice.py b/homeassistant/components/ihc/ihcdevice.py index 999dda42015..59f4d95f0a1 100644 --- a/homeassistant/components/ihc/ihcdevice.py +++ b/homeassistant/components/ihc/ihcdevice.py @@ -14,7 +14,7 @@ class IHCDevice(Entity): """ def __init__(self, ihc_controller, name, ihc_id: int, info: bool, - product: Element=None) -> None: + product: Element = None) -> None: """Initialize IHC attributes.""" self.ihc_controller = ihc_controller self._name = name diff --git a/homeassistant/components/image_processing/opencv.py b/homeassistant/components/image_processing/opencv.py index 0abc449afba..df58e2e9dc4 100644 --- a/homeassistant/components/image_processing/opencv.py +++ b/homeassistant/components/image_processing/opencv.py @@ -42,7 +42,7 @@ DEFAULT_TIMEOUT = 10 SCAN_INTERVAL = timedelta(seconds=2) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_CLASSIFIER, default=None): { + vol.Optional(CONF_CLASSIFIER): { cv.string: vol.Any( cv.isfile, vol.Schema({ @@ -60,7 +60,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def _create_processor_from_config(hass, camera_entity, config): """Create an OpenCV processor from configuration.""" - classifier_config = config[CONF_CLASSIFIER] + classifier_config = config.get(CONF_CLASSIFIER) name = '{} {}'.format( config[CONF_NAME], split_entity_id(camera_entity)[1].replace('_', ' ')) @@ -93,7 +93,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): return entities = [] - if config[CONF_CLASSIFIER] is None: + if CONF_CLASSIFIER not in config: dest_path = hass.config.path(DEFAULT_CLASSIFIER_PATH) _get_default_classifier(dest_path) config[CONF_CLASSIFIER] = { diff --git a/homeassistant/components/image_processing/seven_segments.py b/homeassistant/components/image_processing/seven_segments.py index 1ef8a4bb847..b49739bcec3 100644 --- a/homeassistant/components/image_processing/seven_segments.py +++ b/homeassistant/components/image_processing/seven_segments.py @@ -33,7 +33,7 @@ DEFAULT_BINARY = 'ssocr' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_EXTRA_ARGUMENTS, default=''): cv.string, - vol.Optional(CONF_DIGITS, default=-1): cv.positive_int, + vol.Optional(CONF_DIGITS): cv.positive_int, vol.Optional(CONF_HEIGHT, default=0): cv.positive_int, vol.Optional(CONF_SSOCR_BIN, default=DEFAULT_BINARY): cv.string, vol.Optional(CONF_THRESHOLD, default=0): cv.positive_int, @@ -73,7 +73,7 @@ class ImageProcessingSsocr(ImageProcessingEntity): self.filepath = os.path.join(self.hass.config.config_dir, 'ocr.png') crop = ['crop', str(config[CONF_X_POS]), str(config[CONF_Y_POS]), str(config[CONF_WIDTH]), str(config[CONF_HEIGHT])] - digits = ['-d', str(config[CONF_DIGITS])] + digits = ['-d', str(config.get(CONF_DIGITS, -1))] rotate = ['rotate', str(config[CONF_ROTATE])] threshold = ['-t', str(config[CONF_THRESHOLD])] extra_arguments = config[CONF_EXTRA_ARGUMENTS].split(' ') diff --git a/homeassistant/components/input_datetime.py b/homeassistant/components/input_datetime.py index fecc31f14ae..a77b67792f5 100644 --- a/homeassistant/components/input_datetime.py +++ b/homeassistant/components/input_datetime.py @@ -43,8 +43,8 @@ CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ cv.slug: vol.All({ vol.Optional(CONF_NAME): cv.string, - vol.Required(CONF_HAS_DATE): cv.boolean, - vol.Required(CONF_HAS_TIME): cv.boolean, + vol.Optional(CONF_HAS_DATE, default=False): cv.boolean, + vol.Optional(CONF_HAS_TIME, default=False): cv.boolean, vol.Optional(CONF_ICON): cv.icon, vol.Optional(CONF_INITIAL): cv.string, }, cv.has_at_least_one_key_value((CONF_HAS_DATE, True), diff --git a/homeassistant/components/insteon_plm.py b/homeassistant/components/insteon_plm.py index 94b70e47cba..4e2e8e02c7a 100644 --- a/homeassistant/components/insteon_plm.py +++ b/homeassistant/components/insteon_plm.py @@ -26,8 +26,7 @@ CONF_OVERRIDE = 'device_override' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_PORT): cv.string, - vol.Optional(CONF_OVERRIDE, default=[]): vol.All( - cv.ensure_list_csv, vol.Length(min=1)) + vol.Optional(CONF_OVERRIDE, default=[]): cv.ensure_list_csv, }) }, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/components/isy994.py b/homeassistant/components/isy994.py index df93bf51250..48a9499d1a9 100644 --- a/homeassistant/components/isy994.py +++ b/homeassistant/components/isy994.py @@ -83,9 +83,9 @@ NODE_FILTERS = { }, 'fan': { 'uom': [], - 'states': ['off', 'low', 'medium', 'high'], + 'states': ['off', 'low', 'med', 'high'], 'node_def_id': ['FanLincMotor'], - 'insteon_type': [] + 'insteon_type': ['1.46.'] }, 'cover': { 'uom': ['97'], @@ -135,7 +135,7 @@ WeatherNode = namedtuple('WeatherNode', ('status', 'name', 'uom')) def _check_for_node_def(hass: HomeAssistant, node, - single_domain: str=None) -> bool: + single_domain: str = None) -> bool: """Check if the node matches the node_def_id for any domains. This is only present on the 5.0 ISY firmware, and is the most reliable @@ -157,7 +157,7 @@ def _check_for_node_def(hass: HomeAssistant, node, def _check_for_insteon_type(hass: HomeAssistant, node, - single_domain: str=None) -> bool: + single_domain: str = None) -> bool: """Check if the node matches the Insteon type for any domains. This is for (presumably) every version of the ISY firmware, but only @@ -173,6 +173,14 @@ def _check_for_insteon_type(hass: HomeAssistant, node, for domain in domains: if any([device_type.startswith(t) for t in set(NODE_FILTERS[domain]['insteon_type'])]): + + # Hacky special-case just for FanLinc, which has a light module + # as one of its nodes. Note that this special-case is not necessary + # on ISY 5.x firmware as it uses the superior NodeDefs method + if domain == 'fan' and int(node.nid[-1]) == 1: + hass.data[ISY994_NODES]['light'].append(node) + return True + hass.data[ISY994_NODES][domain].append(node) return True @@ -180,7 +188,8 @@ def _check_for_insteon_type(hass: HomeAssistant, node, def _check_for_uom_id(hass: HomeAssistant, node, - single_domain: str=None, uom_list: list=None) -> bool: + single_domain: str = None, + uom_list: list = None) -> bool: """Check if a node's uom matches any of the domains uom filter. This is used for versions of the ISY firmware that report uoms as a single @@ -207,8 +216,8 @@ def _check_for_uom_id(hass: HomeAssistant, node, def _check_for_states_in_uom(hass: HomeAssistant, node, - single_domain: str=None, - states_list: list=None) -> bool: + single_domain: str = None, + states_list: list = None) -> bool: """Check if a list of uoms matches two possible filters. This is for versions of the ISY firmware that report uoms as a list of all @@ -302,24 +311,25 @@ def _categorize_programs(hass: HomeAssistant, programs: dict) -> None: pass else: for dtype, _, node_id in folder.children: - if dtype == KEY_FOLDER: - entity_folder = folder[node_id] - try: - status = entity_folder[KEY_STATUS] - assert status.dtype == 'program', 'Not a program' - if domain != 'binary_sensor': - actions = entity_folder[KEY_ACTIONS] - assert actions.dtype == 'program', 'Not a program' - else: - actions = None - except (AttributeError, KeyError, AssertionError): - _LOGGER.warning("Program entity '%s' not loaded due " - "to invalid folder structure.", - entity_folder.name) - continue + if dtype != KEY_FOLDER: + continue + entity_folder = folder[node_id] + try: + status = entity_folder[KEY_STATUS] + assert status.dtype == 'program', 'Not a program' + if domain != 'binary_sensor': + actions = entity_folder[KEY_ACTIONS] + assert actions.dtype == 'program', 'Not a program' + else: + actions = None + except (AttributeError, KeyError, AssertionError): + _LOGGER.warning("Program entity '%s' not loaded due " + "to invalid folder structure.", + entity_folder.name) + continue - entity = (entity_folder.name, status, actions) - hass.data[ISY994_PROGRAMS][domain].append(entity) + entity = (entity_folder.name, status, actions) + hass.data[ISY994_PROGRAMS][domain].append(entity) def _categorize_weather(hass: HomeAssistant, climate) -> None: @@ -464,8 +474,7 @@ class ISYDevice(Entity): """Return the state of the ISY device.""" if self.is_unknown(): return None - else: - return super().state + return super().state @property def device_state_attributes(self) -> Dict: diff --git a/homeassistant/components/knx.py b/homeassistant/components/knx.py index eb5ae9a4590..a90a5246759 100644 --- a/homeassistant/components/knx.py +++ b/homeassistant/components/knx.py @@ -14,7 +14,7 @@ from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.script import Script -REQUIREMENTS = ['xknx==0.7.18'] +REQUIREMENTS = ['xknx==0.8.3'] DOMAIN = "knx" DATA_KNX = "data_knx" @@ -120,7 +120,6 @@ class KNXModule(object): self.hass = hass self.config = config self.connected = False - self.initialized = True self.init_xknx() self.register_callbacks() @@ -216,7 +215,7 @@ class KNXModule(object): @asyncio.coroutine def service_send_to_knx_bus(self, call): """Service for sending an arbitrary KNX message to the KNX bus.""" - from xknx.knx import Telegram, Address, DPTBinary, DPTArray + from xknx.knx import Telegram, GroupAddress, DPTBinary, DPTArray attr_payload = call.data.get(SERVICE_KNX_ATTR_PAYLOAD) attr_address = call.data.get(SERVICE_KNX_ATTR_ADDRESS) @@ -226,7 +225,7 @@ class KNXModule(object): return DPTBinary(attr_payload) return DPTArray(attr_payload) payload = calculate_payload(attr_payload) - address = Address(attr_address) + address = GroupAddress(attr_address) telegram = Telegram() telegram.payload = payload diff --git a/homeassistant/components/light/avion.py b/homeassistant/components/light/avion.py index 5344c3dce6d..b4b9f4e7775 100644 --- a/homeassistant/components/light/avion.py +++ b/homeassistant/components/light/avion.py @@ -37,7 +37,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up an Avion switch.""" - # pylint: disable=import-error + # pylint: disable=import-error, no-member import avion lights = [] @@ -70,7 +70,7 @@ class AvionLight(Light): def __init__(self, device): """Initialize the light.""" - # pylint: disable=import-error + # pylint: disable=import-error, no-member import avion self._name = device['name'] @@ -117,7 +117,7 @@ class AvionLight(Light): def set_state(self, brightness): """Set the state of this lamp to the provided brightness.""" - # pylint: disable=import-error + # pylint: disable=import-error, no-member import avion # Bluetooth LE is unreliable, and the connection may drop at any diff --git a/homeassistant/components/light/blinkt.py b/homeassistant/components/light/blinkt.py index e331fba32c2..db3171cf4cf 100644 --- a/homeassistant/components/light/blinkt.py +++ b/homeassistant/components/light/blinkt.py @@ -29,7 +29,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Blinkt Light platform.""" - # pylint: disable=import-error + # pylint: disable=import-error, no-member import blinkt # ensure that the lights are off when exiting diff --git a/homeassistant/components/light/deconz.py b/homeassistant/components/light/deconz.py index 529917c36e2..0eef5a868b4 100644 --- a/homeassistant/components/light/deconz.py +++ b/homeassistant/components/light/deconz.py @@ -6,7 +6,8 @@ https://home-assistant.io/components/light.deconz/ """ import asyncio -from homeassistant.components.deconz import DOMAIN as DECONZ_DATA +from homeassistant.components.deconz import ( + DOMAIN as DATA_DECONZ, DATA_DECONZ_ID) from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, ATTR_RGB_COLOR, ATTR_TRANSITION, ATTR_XY_COLOR, EFFECT_COLORLOOP, FLASH_LONG, FLASH_SHORT, @@ -17,8 +18,6 @@ from homeassistant.util.color import color_RGB_to_xy DEPENDENCIES = ['deconz'] -ATTR_LIGHT_GROUP = 'LightGroup' - @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): @@ -26,8 +25,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if discovery_info is None: return - lights = hass.data[DECONZ_DATA].lights - groups = hass.data[DECONZ_DATA].groups + lights = hass.data[DATA_DECONZ].lights + groups = hass.data[DATA_DECONZ].groups entities = [] for light in lights.values(): @@ -64,6 +63,7 @@ class DeconzLight(Light): def async_added_to_hass(self): """Subscribe to lights events.""" self._light.register_async_callback(self.async_update_callback) + self.hass.data[DATA_DECONZ_ID][self.entity_id] = self._light.deconz_id @callback def async_update_callback(self, reason): diff --git a/homeassistant/components/light/decora.py b/homeassistant/components/light/decora.py index 03441dd8ea6..c7478b435ee 100644 --- a/homeassistant/components/light/decora.py +++ b/homeassistant/components/light/decora.py @@ -37,7 +37,7 @@ def retry(method): @wraps(method) def wrapper_retry(device, *args, **kwargs): """Try send command and retry on error.""" - # pylint: disable=import-error + # pylint: disable=import-error, no-member import decora import bluepy @@ -75,7 +75,7 @@ class DecoraLight(Light): def __init__(self, device): """Initialize the light.""" - # pylint: disable=import-error + # pylint: disable=import-error, no-member import decora self._name = device['name'] diff --git a/homeassistant/components/light/decora_wifi.py b/homeassistant/components/light/decora_wifi.py index 971ad21e84b..111d39f2019 100644 --- a/homeassistant/components/light/decora_wifi.py +++ b/homeassistant/components/light/decora_wifi.py @@ -36,7 +36,7 @@ NOTIFICATION_TITLE = 'myLeviton Decora Setup' def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Decora WiFi platform.""" - # pylint: disable=import-error + # pylint: disable=import-error, no-member, no-name-in-module from decora_wifi import DecoraWiFiSession from decora_wifi.models.person import Person from decora_wifi.models.residential_account import ResidentialAccount @@ -93,8 +93,7 @@ class DecoraWifiLight(Light): """Return supported features.""" if self._switch.canSetLevel: return SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION - else: - return 0 + return 0 @property def name(self): diff --git a/homeassistant/components/light/flux_led.py b/homeassistant/components/light/flux_led.py index 075b98117f8..2a239c9ae10 100644 --- a/homeassistant/components/light/flux_led.py +++ b/homeassistant/components/light/flux_led.py @@ -84,7 +84,7 @@ DEVICE_SCHEMA = vol.Schema({ vol.Optional(CONF_NAME): cv.string, vol.Optional(ATTR_MODE, default=MODE_RGBW): vol.All(cv.string, vol.In([MODE_RGBW, MODE_RGB])), - vol.Optional(CONF_PROTOCOL, default=None): + vol.Optional(CONF_PROTOCOL): vol.All(cv.string, vol.In(['ledenet'])), }) @@ -104,7 +104,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): device = {} device['name'] = device_config[CONF_NAME] device['ipaddr'] = ipaddr - device[CONF_PROTOCOL] = device_config[CONF_PROTOCOL] + device[CONF_PROTOCOL] = device_config.get(CONF_PROTOCOL) device[ATTR_MODE] = device_config[ATTR_MODE] light = FluxLight(device) lights.append(light) diff --git a/homeassistant/components/light/hive.py b/homeassistant/components/light/hive.py index 5ba162a20d2..e57bdf2c046 100644 --- a/homeassistant/components/light/hive.py +++ b/homeassistant/components/light/hive.py @@ -116,7 +116,7 @@ class HiveDeviceLight(Light): for entity in self.session.entities: entity.handle_update(self.data_updatesource) - def turn_off(self): + def turn_off(self, **kwargs): """Instruct the light to turn off.""" self.session.light.turn_off(self.node_id) for entity in self.session.entities: diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index 07ba069d831..ffca48743e9 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -91,7 +91,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if discovery_info is None or 'bridge_id' not in discovery_info: return - if config is not None and len(config) > 0: + if config is not None and config: # Legacy configuration, will be removed in 0.60 config_str = yaml.dump([config]) # Indent so it renders in a fixed-width font diff --git a/homeassistant/components/light/iglo.py b/homeassistant/components/light/iglo.py index ba78546cf77..e39b5dbf540 100644 --- a/homeassistant/components/light/iglo.py +++ b/homeassistant/components/light/iglo.py @@ -10,13 +10,14 @@ import math import voluptuous as vol from homeassistant.components.light import ( - ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_RGB_COLOR, PLATFORM_SCHEMA, - SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR, Light) + ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_RGB_COLOR, + SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR, SUPPORT_EFFECT, + PLATFORM_SCHEMA, Light) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT import homeassistant.helpers.config_validation as cv import homeassistant.util.color as color_util -REQUIREMENTS = ['iglo==1.1.3'] +REQUIREMENTS = ['iglo==1.2.5'] _LOGGER = logging.getLogger(__name__) @@ -46,10 +47,6 @@ class IGloLamp(Light): from iglo import Lamp self._name = name self._lamp = Lamp(0, host, port) - self._on = True - self._brightness = 255 - self._rgb = (0, 0, 0) - self._color_temp = 0 @property def name(self): @@ -59,12 +56,13 @@ class IGloLamp(Light): @property def brightness(self): """Return the brightness of this light between 0..255.""" - return int((self._brightness / 200.0) * 255) + return int((self._lamp.state['brightness'] / 200.0) * 255) @property def color_temp(self): """Return the color temperature.""" - return color_util.color_temperature_kelvin_to_mired(self._color_temp) + return color_util.color_temperature_kelvin_to_mired( + self._lamp.state['white']) @property def min_mireds(self): @@ -81,21 +79,32 @@ class IGloLamp(Light): @property def rgb_color(self): """Return the RGB value.""" - return self._rgb + return self._lamp.state['rgb'] + + @property + def effect(self): + """Return the current effect.""" + return self._lamp.state['effect'] + + @property + def effect_list(self): + """Return the list of supported effects.""" + return self._lamp.effect_list @property def supported_features(self): """Flag supported features.""" - return SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_RGB_COLOR + return (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | + SUPPORT_RGB_COLOR | SUPPORT_EFFECT) @property def is_on(self): """Return true if light is on.""" - return self._on + return self._lamp.state['on'] def turn_on(self, **kwargs): """Turn the light on.""" - if not self._on: + if not self.is_on: self._lamp.switch(True) if ATTR_BRIGHTNESS in kwargs: brightness = int((kwargs[ATTR_BRIGHTNESS] / 255.0) * 200.0) @@ -113,14 +122,11 @@ class IGloLamp(Light): self._lamp.white(kelvin) return + if ATTR_EFFECT in kwargs: + effect = kwargs[ATTR_EFFECT] + self._lamp.effect(effect) + return + def turn_off(self, **kwargs): """Turn the light off.""" self._lamp.switch(False) - - def update(self): - """Update light status.""" - state = self._lamp.state() - self._on = state['on'] - self._brightness = state['brightness'] - self._rgb = state['rgb'] - self._color_temp = state['white'] diff --git a/homeassistant/components/light/ihc.py b/homeassistant/components/light/ihc.py index ead0f153562..c9ceda8651a 100644 --- a/homeassistant/components/light/ihc.py +++ b/homeassistant/components/light/ihc.py @@ -64,7 +64,7 @@ class IhcLight(IHCDevice, Light): """ def __init__(self, ihc_controller, name, ihc_id: int, info: bool, - dimmable=False, product: Element=None) -> None: + dimmable=False, product: Element = None) -> None: """Initialize the light.""" super().__init__(ihc_controller, name, ihc_id, info, product) self._brightness = 0 diff --git a/homeassistant/components/light/isy994.py b/homeassistant/components/light/isy994.py index cee8155c322..d2ed865892e 100644 --- a/homeassistant/components/light/isy994.py +++ b/homeassistant/components/light/isy994.py @@ -29,10 +29,6 @@ def setup_platform(hass, config: ConfigType, class ISYLightDevice(ISYDevice, Light): """Representation of an ISY994 light device.""" - def __init__(self, node: object) -> None: - """Initialize the ISY994 light device.""" - super().__init__(node) - @property def is_on(self) -> bool: """Get whether the ISY994 light is on.""" @@ -48,6 +44,7 @@ class ISYLightDevice(ISYDevice, Light): if not self._node.off(): _LOGGER.debug("Unable to turn off light") + # pylint: disable=arguments-differ def turn_on(self, brightness=None, **kwargs) -> None: """Send the turn on command to the ISY994 light device.""" if not self._node.on(val=brightness): diff --git a/homeassistant/components/light/knx.py b/homeassistant/components/light/knx.py index 8c9e78ab2b0..020184b8501 100644 --- a/homeassistant/components/light/knx.py +++ b/homeassistant/components/light/knx.py @@ -10,7 +10,8 @@ import voluptuous as vol from homeassistant.components.knx import ATTR_DISCOVER_DEVICES, DATA_KNX from homeassistant.components.light import ( - ATTR_BRIGHTNESS, PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, Light) + ATTR_BRIGHTNESS, ATTR_RGB_COLOR, PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, + SUPPORT_RGB_COLOR, Light) from homeassistant.const import CONF_NAME from homeassistant.core import callback import homeassistant.helpers.config_validation as cv @@ -19,6 +20,8 @@ CONF_ADDRESS = 'address' CONF_STATE_ADDRESS = 'state_address' CONF_BRIGHTNESS_ADDRESS = 'brightness_address' CONF_BRIGHTNESS_STATE_ADDRESS = 'brightness_state_address' +CONF_COLOR_ADDRESS = 'color_address' +CONF_COLOR_STATE_ADDRESS = 'color_state_address' DEFAULT_NAME = 'KNX Light' DEPENDENCIES = ['knx'] @@ -29,16 +32,14 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_STATE_ADDRESS): cv.string, vol.Optional(CONF_BRIGHTNESS_ADDRESS): cv.string, vol.Optional(CONF_BRIGHTNESS_STATE_ADDRESS): cv.string, + vol.Optional(CONF_COLOR_ADDRESS): cv.string, + vol.Optional(CONF_COLOR_STATE_ADDRESS): cv.string, }) @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up lights for KNX platform.""" - if DATA_KNX not in hass.data \ - or not hass.data[DATA_KNX].initialized: - return - if discovery_info is not None: async_add_devices_discovery(hass, discovery_info, async_add_devices) else: @@ -66,7 +67,9 @@ def async_add_devices_config(hass, config, async_add_devices): group_address_switch_state=config.get(CONF_STATE_ADDRESS), group_address_brightness=config.get(CONF_BRIGHTNESS_ADDRESS), group_address_brightness_state=config.get( - CONF_BRIGHTNESS_STATE_ADDRESS)) + CONF_BRIGHTNESS_STATE_ADDRESS), + group_address_color=config.get(CONF_COLOR_ADDRESS), + group_address_color_state=config.get(CONF_COLOR_STATE_ADDRESS)) hass.data[DATA_KNX].xknx.devices.add(light) async_add_devices([KNXLight(hass, light)]) @@ -120,6 +123,8 @@ class KNXLight(Light): @property def rgb_color(self): """Return the RBG color value.""" + if self.device.supports_color: + return self.device.current_color() return None @property @@ -153,6 +158,8 @@ class KNXLight(Light): flags = 0 if self.device.supports_dimming: flags |= SUPPORT_BRIGHTNESS + if self.device.supports_color: + flags |= SUPPORT_RGB_COLOR return flags @asyncio.coroutine @@ -160,6 +167,8 @@ class KNXLight(Light): """Turn the light on.""" if ATTR_BRIGHTNESS in kwargs and self.device.supports_dimming: yield from self.device.set_brightness(int(kwargs[ATTR_BRIGHTNESS])) + elif ATTR_RGB_COLOR in kwargs: + yield from self.device.set_color(kwargs[ATTR_RGB_COLOR]) else: yield from self.device.set_on() diff --git a/homeassistant/components/light/lifx_legacy.py b/homeassistant/components/light/lifx_legacy.py index cc48f4cf4c1..cf3dba848a8 100644 --- a/homeassistant/components/light/lifx_legacy.py +++ b/homeassistant/components/light/lifx_legacy.py @@ -41,8 +41,8 @@ SUPPORT_LIFX = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_RGB_COLOR | SUPPORT_TRANSITION) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_SERVER, default=None): cv.string, - vol.Optional(CONF_BROADCAST, default=None): cv.string, + vol.Optional(CONF_SERVER): cv.string, + vol.Optional(CONF_BROADCAST): cv.string, }) diff --git a/homeassistant/components/light/limitlessled.py b/homeassistant/components/light/limitlessled.py index aad2abdd183..0606d097d49 100644 --- a/homeassistant/components/light/limitlessled.py +++ b/homeassistant/components/light/limitlessled.py @@ -4,19 +4,22 @@ Support for LimitlessLED bulbs. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.limitlessled/ """ +import asyncio import logging import voluptuous as vol -from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_PORT, CONF_TYPE) +from homeassistant.const import ( + CONF_NAME, CONF_HOST, CONF_PORT, CONF_TYPE, STATE_ON) from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, ATTR_RGB_COLOR, ATTR_TRANSITION, EFFECT_COLORLOOP, EFFECT_WHITE, FLASH_LONG, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_FLASH, SUPPORT_RGB_COLOR, SUPPORT_TRANSITION, Light, PLATFORM_SCHEMA) import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.restore_state import async_get_last_state -REQUIREMENTS = ['limitlessled==1.0.8'] +REQUIREMENTS = ['limitlessled==1.1.0'] _LOGGER = logging.getLogger(__name__) @@ -32,7 +35,9 @@ DEFAULT_TRANSITION = 0 DEFAULT_VERSION = 6 DEFAULT_FADE = False -LED_TYPE = ['rgbw', 'rgbww', 'white', 'bridge-led'] +LED_TYPE = ['rgbw', 'rgbww', 'white', 'bridge-led', 'dimmer'] + +EFFECT_NIGHT = 'night' RGB_BOUNDARY = 40 @@ -40,6 +45,7 @@ WHITE = [255, 255, 255] SUPPORT_LIMITLESSLED_WHITE = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_TRANSITION) +SUPPORT_LIMITLESSLED_DIMMER = (SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION) SUPPORT_LIMITLESSLED_RGB = (SUPPORT_BRIGHTNESS | SUPPORT_EFFECT | SUPPORT_FLASH | SUPPORT_RGB_COLOR | SUPPORT_TRANSITION) @@ -115,7 +121,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): group_conf.get(CONF_NUMBER), group_conf.get(CONF_NAME), group_conf.get(CONF_TYPE, DEFAULT_LED_TYPE)) - lights.append(LimitlessLEDGroup.factory(group, { + lights.append(LimitlessLEDGroup(group, { 'fade': group_conf[CONF_FADE] })) add_devices(lights) @@ -138,9 +144,6 @@ def state(new_state): if self.repeating: self.repeating = False self.group.stop() - # Not on and should be? Turn on. - if not self.is_on and new_state is True: - pipeline.on() # Set transition time. if ATTR_TRANSITION in kwargs: transition_time = int(kwargs[ATTR_TRANSITION]) @@ -159,30 +162,51 @@ class LimitlessLEDGroup(Light): def __init__(self, group, config): """Initialize a group.""" + from limitlessled.group.rgbw import RgbwGroup + from limitlessled.group.white import WhiteGroup + from limitlessled.group.dimmer import DimmerGroup + from limitlessled.group.rgbww import RgbwwGroup + if isinstance(group, WhiteGroup): + self._supported = SUPPORT_LIMITLESSLED_WHITE + self._effect_list = [EFFECT_NIGHT] + elif isinstance(group, DimmerGroup): + self._supported = SUPPORT_LIMITLESSLED_DIMMER + self._effect_list = [] + elif isinstance(group, RgbwGroup): + self._supported = SUPPORT_LIMITLESSLED_RGB + self._effect_list = [EFFECT_COLORLOOP, EFFECT_NIGHT, EFFECT_WHITE] + elif isinstance(group, RgbwwGroup): + self._supported = SUPPORT_LIMITLESSLED_RGBWW + self._effect_list = [EFFECT_COLORLOOP, EFFECT_NIGHT, EFFECT_WHITE] + self.group = group + self.config = config self.repeating = False self._is_on = False self._brightness = None - self.config = config + self._temperature = None + self._color = None - @staticmethod - def factory(group, config): - """Produce LimitlessLEDGroup objects.""" - from limitlessled.group.rgbw import RgbwGroup - from limitlessled.group.white import WhiteGroup - from limitlessled.group.rgbww import RgbwwGroup - if isinstance(group, WhiteGroup): - return LimitlessLEDWhiteGroup(group, config) - elif isinstance(group, RgbwGroup): - return LimitlessLEDRGBWGroup(group, config) - elif isinstance(group, RgbwwGroup): - return LimitlessLEDRGBWWGroup(group, config) + @asyncio.coroutine + def async_added_to_hass(self): + """Called when entity is about to be added to hass.""" + last_state = yield from async_get_last_state(self.hass, self.entity_id) + if last_state: + self._is_on = (last_state.state == STATE_ON) + self._brightness = last_state.attributes.get('brightness') + self._temperature = last_state.attributes.get('color_temp') + self._color = last_state.attributes.get('rgb_color') @property def should_poll(self): """No polling needed.""" return False + @property + def assumed_state(self): + """Return True because unable to access real state of the entity.""" + return True + @property def name(self): """Return the name of the group.""" @@ -198,215 +222,103 @@ class LimitlessLEDGroup(Light): """Return the brightness property.""" return self._brightness + @property + def color_temp(self): + """Return the temperature property.""" + return self._temperature + + @property + def rgb_color(self): + """Return the color property.""" + return self._color + + @property + def supported_features(self): + """Flag supported features.""" + return self._supported + + @property + def effect_list(self): + """Return the list of supported effects for this light.""" + return self._effect_list + + # pylint: disable=arguments-differ @state(False) def turn_off(self, transition_time, pipeline, **kwargs): """Turn off a group.""" - if self.is_on: - if self.config[CONF_FADE]: - pipeline.transition(transition_time, brightness=0.0) - pipeline.off() - - -class LimitlessLEDWhiteGroup(LimitlessLEDGroup): - """Representation of a LimitlessLED White group.""" - - def __init__(self, group, config): - """Initialize White group.""" - super().__init__(group, config) - # Initialize group with known values. - self.group.on = True - self.group.temperature = 1.0 - self.group.brightness = 0.0 - self._brightness = _to_hass_brightness(1.0) - self._temperature = _to_hass_temperature(self.group.temperature) - self.group.on = False - - @property - def color_temp(self): - """Return the temperature property.""" - return self._temperature - - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORT_LIMITLESSLED_WHITE + if self.config[CONF_FADE]: + pipeline.transition(transition_time, brightness=0.0) + pipeline.off() + # pylint: disable=arguments-differ @state(True) def turn_on(self, transition_time, pipeline, **kwargs): """Turn on (or adjust property of) a group.""" - # Check arguments. + # The night effect does not need a turned on light + if kwargs.get(ATTR_EFFECT) == EFFECT_NIGHT: + if EFFECT_NIGHT in self._effect_list: + pipeline.night_light() + return + + pipeline.on() + + # Set up transition. + args = {} + if self.config[CONF_FADE] and not self.is_on and self._brightness: + args['brightness'] = self.limitlessled_brightness() + if ATTR_BRIGHTNESS in kwargs: self._brightness = kwargs[ATTR_BRIGHTNESS] + args['brightness'] = self.limitlessled_brightness() + + if ATTR_RGB_COLOR in kwargs and self._supported & SUPPORT_RGB_COLOR: + self._color = kwargs[ATTR_RGB_COLOR] + # White is a special case. + if min(self._color) > 256 - RGB_BOUNDARY: + pipeline.white() + self._color = WHITE + else: + args['color'] = self.limitlessled_color() + if ATTR_COLOR_TEMP in kwargs: - self._temperature = kwargs[ATTR_COLOR_TEMP] - # Set up transition. - pipeline.transition( - transition_time, - brightness=_from_hass_brightness(self._brightness), - temperature=_from_hass_temperature(self._temperature) - ) - - -class LimitlessLEDRGBWGroup(LimitlessLEDGroup): - """Representation of a LimitlessLED RGBW group.""" - - def __init__(self, group, config): - """Initialize RGBW group.""" - super().__init__(group, config) - # Initialize group with known values. - self.group.on = True - self.group.white() - self._color = WHITE - self.group.brightness = 0.0 - self._brightness = _to_hass_brightness(1.0) - self.group.on = False - - @property - def rgb_color(self): - """Return the color property.""" - return self._color - - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORT_LIMITLESSLED_RGB - - @state(True) - def turn_on(self, transition_time, pipeline, **kwargs): - """Turn on (or adjust property of) a group.""" - from limitlessled.presets import COLORLOOP - # Check arguments. - if ATTR_BRIGHTNESS in kwargs: - self._brightness = kwargs[ATTR_BRIGHTNESS] - if ATTR_RGB_COLOR in kwargs: - self._color = kwargs[ATTR_RGB_COLOR] - # White is a special case. - if min(self._color) > 256 - RGB_BOUNDARY: - pipeline.white() + if self._supported & SUPPORT_RGB_COLOR: + pipeline.white() self._color = WHITE - # Set up transition. - pipeline.transition( - transition_time, - brightness=_from_hass_brightness(self._brightness), - color=_from_hass_color(self._color) - ) + if self._supported & SUPPORT_COLOR_TEMP: + self._temperature = kwargs[ATTR_COLOR_TEMP] + args['temperature'] = self.limitlessled_temperature() + + if args: + pipeline.transition(transition_time, **args) + # Flash. - if ATTR_FLASH in kwargs: + if ATTR_FLASH in kwargs and self._supported & SUPPORT_FLASH: duration = 0 if kwargs[ATTR_FLASH] == FLASH_LONG: duration = 1 pipeline.flash(duration=duration) + # Add effects. - if ATTR_EFFECT in kwargs: + if ATTR_EFFECT in kwargs and self._effect_list: if kwargs[ATTR_EFFECT] == EFFECT_COLORLOOP: + from limitlessled.presets import COLORLOOP self.repeating = True pipeline.append(COLORLOOP) if kwargs[ATTR_EFFECT] == EFFECT_WHITE: pipeline.white() self._color = WHITE + def limitlessled_temperature(self): + """Convert Home Assistant color temperature units to percentage.""" + width = self.max_mireds - self.min_mireds + temperature = 1 - (self._temperature - self.min_mireds) / width + return max(0, min(1, temperature)) -class LimitlessLEDRGBWWGroup(LimitlessLEDGroup): - """Representation of a LimitlessLED RGBWW group.""" + def limitlessled_brightness(self): + """Convert Home Assistant brightness units to percentage.""" + return self._brightness / 255 - def __init__(self, group, config): - """Initialize RGBWW group.""" - super().__init__(group, config) - # Initialize group with known values. - self.group.on = True - self.group.white() - self.group.temperature = 0.0 - self._color = WHITE - self.group.brightness = 0.0 - self._brightness = _to_hass_brightness(1.0) - self._temperature = _to_hass_temperature(self.group.temperature) - self.group.on = False - - @property - def rgb_color(self): - """Return the color property.""" - return self._color - - @property - def color_temp(self): - """Return the temperature property.""" - return self._temperature - - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORT_LIMITLESSLED_RGBWW - - @state(True) - def turn_on(self, transition_time, pipeline, **kwargs): - """Turn on (or adjust property of) a group.""" - from limitlessled.presets import COLORLOOP - # Check arguments. - if ATTR_BRIGHTNESS in kwargs: - self._brightness = kwargs[ATTR_BRIGHTNESS] - if ATTR_RGB_COLOR in kwargs: - self._color = kwargs[ATTR_RGB_COLOR] - elif ATTR_COLOR_TEMP in kwargs: - self._temperature = kwargs[ATTR_COLOR_TEMP] - # White is a special case. - if min(self._color) > 256 - RGB_BOUNDARY: - pipeline.white() - self._color = WHITE - # Set up transition. - if self._color == WHITE: - pipeline.transition( - transition_time, - brightness=_from_hass_brightness(self._brightness), - temperature=_from_hass_temperature(self._temperature) - ) - else: - pipeline.transition( - transition_time, - brightness=_from_hass_brightness(self._brightness), - color=_from_hass_color(self._color) - ) - # Flash. - if ATTR_FLASH in kwargs: - duration = 0 - if kwargs[ATTR_FLASH] == FLASH_LONG: - duration = 1 - pipeline.flash(duration=duration) - # Add effects. - if ATTR_EFFECT in kwargs: - if kwargs[ATTR_EFFECT] == EFFECT_COLORLOOP: - self.repeating = True - pipeline.append(COLORLOOP) - if kwargs[ATTR_EFFECT] == EFFECT_WHITE: - pipeline.white() - self._color = WHITE - - -def _from_hass_temperature(temperature): - """Convert Home Assistant color temperature units to percentage.""" - return 1 - (temperature - 154) / 346 - - -def _to_hass_temperature(temperature): - """Convert percentage to Home Assistant color temperature units.""" - return 500 - int(temperature * 346) - - -def _from_hass_brightness(brightness): - """Convert Home Assistant brightness units to percentage.""" - return brightness / 255 - - -def _to_hass_brightness(brightness): - """Convert percentage to Home Assistant brightness units.""" - return int(brightness * 255) - - -def _from_hass_color(color): - """Convert Home Assistant RGB list to Color tuple.""" - from limitlessled import Color - return Color(*tuple(color)) - - -def _to_hass_color(color): - """Convert from Color tuple to Home Assistant RGB list.""" - return list([int(c) for c in color]) + def limitlessled_color(self): + """Convert Home Assistant RGB list to Color tuple.""" + from limitlessled import Color + return Color(*tuple(self._color)) diff --git a/homeassistant/components/light/mysensors.py b/homeassistant/components/light/mysensors.py index 9a48b13ed3b..a37553017e7 100644 --- a/homeassistant/components/light/mysensors.py +++ b/homeassistant/components/light/mysensors.py @@ -130,7 +130,7 @@ class MySensorsLight(mysensors.MySensorsEntity, Light): self._white = white self._values[self.value_type] = hex_color - def turn_off(self): + def turn_off(self, **kwargs): """Turn the device off.""" value_type = self.gateway.const.SetReq.V_LIGHT self.gateway.set_child_value( diff --git a/homeassistant/components/light/template.py b/homeassistant/components/light/template.py index cfd050f54f2..38cac649a1a 100644 --- a/homeassistant/components/light/template.py +++ b/homeassistant/components/light/template.py @@ -35,11 +35,11 @@ CONF_LEVEL_TEMPLATE = 'level_template' LIGHT_SCHEMA = vol.Schema({ vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_VALUE_TEMPLATE, default=None): cv.template, - vol.Optional(CONF_ICON_TEMPLATE, default=None): cv.template, - vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE, default=None): cv.template, - vol.Optional(CONF_LEVEL_ACTION, default=None): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_LEVEL_TEMPLATE, default=None): cv.template, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_ICON_TEMPLATE): cv.template, + vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template, + vol.Optional(CONF_LEVEL_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_LEVEL_TEMPLATE): cv.template, vol.Optional(CONF_FRIENDLY_NAME): cv.string, vol.Optional(CONF_ENTITY_ID): cv.entity_ids }) @@ -56,14 +56,14 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): for device, device_config in config[CONF_LIGHTS].items(): friendly_name = device_config.get(CONF_FRIENDLY_NAME, device) - state_template = device_config[CONF_VALUE_TEMPLATE] + state_template = device_config.get(CONF_VALUE_TEMPLATE) icon_template = device_config.get(CONF_ICON_TEMPLATE) entity_picture_template = device_config.get( CONF_ENTITY_PICTURE_TEMPLATE) on_action = device_config[CONF_ON_ACTION] off_action = device_config[CONF_OFF_ACTION] level_action = device_config.get(CONF_LEVEL_ACTION) - level_template = device_config[CONF_LEVEL_TEMPLATE] + level_template = device_config.get(CONF_LEVEL_TEMPLATE) template_entity_ids = set() diff --git a/homeassistant/components/light/tplink.py b/homeassistant/components/light/tplink.py index 6aee02ee914..f87d624b83a 100644 --- a/homeassistant/components/light/tplink.py +++ b/homeassistant/components/light/tplink.py @@ -118,7 +118,7 @@ class TPLinkSmartBulb(Light): rgb = kwargs.get(ATTR_RGB_COLOR) self.smartbulb.hsv = rgb_to_hsv(rgb) - def turn_off(self): + def turn_off(self, **kwargs): """Turn the light off.""" self.smartbulb.state = self.smartbulb.BULB_STATE_OFF diff --git a/homeassistant/components/light/wink.py b/homeassistant/components/light/wink.py index 02605d24faf..e329fa04837 100644 --- a/homeassistant/components/light/wink.py +++ b/homeassistant/components/light/wink.py @@ -118,6 +118,6 @@ class WinkLight(WinkDevice, Light): self.wink.set_state(True, **state_kwargs) - def turn_off(self): + def turn_off(self, **kwargs): """Turn the switch off.""" self.wink.set_state(False) diff --git a/homeassistant/components/light/xiaomi_miio.py b/homeassistant/components/light/xiaomi_miio.py index f2d327575b1..eaf41691903 100644 --- a/homeassistant/components/light/xiaomi_miio.py +++ b/homeassistant/components/light/xiaomi_miio.py @@ -30,7 +30,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) -REQUIREMENTS = ['python-miio==0.3.6'] +REQUIREMENTS = ['python-miio==0.3.7'] # The light does not accept cct values < 1 CCT_MIN = 1 @@ -242,7 +242,7 @@ class XiaomiPhilipsGenericLight(Light): _LOGGER.error("Got exception while fetching the state: %s", ex) @asyncio.coroutine - def async_set_scene(self, scene: int=1): + def async_set_scene(self, scene: int = 1): """Set the fixed scene.""" yield from self._try_command( "Setting a fixed scene failed.", @@ -260,10 +260,6 @@ class XiaomiPhilipsGenericLight(Light): class XiaomiPhilipsLightBall(XiaomiPhilipsGenericLight, Light): """Representation of a Xiaomi Philips Light Ball.""" - def __init__(self, name, light, device_info): - """Initialize the light device.""" - super().__init__(name, light, device_info) - @property def color_temp(self): """Return the color temperature.""" @@ -293,6 +289,28 @@ class XiaomiPhilipsLightBall(XiaomiPhilipsGenericLight, Light): color_temp, self.max_mireds, self.min_mireds, CCT_MIN, CCT_MAX) + if ATTR_BRIGHTNESS in kwargs: + brightness = kwargs[ATTR_BRIGHTNESS] + percent_brightness = ceil(100 * brightness / 255.0) + + if ATTR_BRIGHTNESS in kwargs and ATTR_COLOR_TEMP in kwargs: + _LOGGER.debug( + "Setting brightness and color temperature: " + "%s %s%%, %s mireds, %s%% cct", + brightness, percent_brightness, + color_temp, percent_color_temp) + + result = yield from self._try_command( + "Setting brightness and color temperature failed: " + "%s bri, %s cct", + self._light.set_brightness_and_color_temperature, + percent_brightness, percent_color_temp) + + if result: + self._color_temp = color_temp + self._brightness = brightness + + elif ATTR_COLOR_TEMP in kwargs: _LOGGER.debug( "Setting color temperature: " "%s mireds, %s%% cct", @@ -305,7 +323,7 @@ class XiaomiPhilipsLightBall(XiaomiPhilipsGenericLight, Light): if result: self._color_temp = color_temp - if ATTR_BRIGHTNESS in kwargs: + elif ATTR_BRIGHTNESS in kwargs: brightness = kwargs[ATTR_BRIGHTNESS] percent_brightness = ceil(100 * brightness / 255.0) @@ -320,8 +338,9 @@ class XiaomiPhilipsLightBall(XiaomiPhilipsGenericLight, Light): if result: self._brightness = brightness - self._state = yield from self._try_command( - "Turning the light on failed.", self._light.on) + else: + self._state = yield from self._try_command( + "Turning the light on failed.", self._light.on) @asyncio.coroutine def async_update(self): @@ -345,10 +364,6 @@ class XiaomiPhilipsLightBall(XiaomiPhilipsGenericLight, Light): class XiaomiPhilipsCeilingLamp(XiaomiPhilipsLightBall, Light): """Representation of a Xiaomi Philips Ceiling Lamp.""" - def __init__(self, name, light, device_info): - """Initialize the light device.""" - super().__init__(name, light, device_info) - @property def min_mireds(self): """Return the coldest color_temp that this light supports.""" @@ -363,6 +378,4 @@ class XiaomiPhilipsCeilingLamp(XiaomiPhilipsLightBall, Light): class XiaomiPhilipsEyecareLamp(XiaomiPhilipsGenericLight, Light): """Representation of a Xiaomi Philips Eyecare Lamp 2.""" - def __init__(self, name, light, device_info): - """Initialize the light device.""" - super().__init__(name, light, device_info) + pass diff --git a/homeassistant/components/lirc.py b/homeassistant/components/lirc.py index ea4df658ef6..0cd49ab6c9a 100644 --- a/homeassistant/components/lirc.py +++ b/homeassistant/components/lirc.py @@ -4,7 +4,7 @@ LIRC interface to receive signals from an infrared remote control. For more details about this component, please refer to the documentation at https://home-assistant.io/components/lirc/ """ -# pylint: disable=import-error +# pylint: disable=import-error,no-member import threading import time import logging diff --git a/homeassistant/components/lock/august.py b/homeassistant/components/lock/august.py new file mode 100644 index 00000000000..9ca63cb493b --- /dev/null +++ b/homeassistant/components/lock/august.py @@ -0,0 +1,82 @@ +""" +Support for August lock. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/lock.august/ +""" +from datetime import timedelta + +from homeassistant.components.august import DATA_AUGUST +from homeassistant.components.lock import LockDevice +from homeassistant.const import ATTR_BATTERY_LEVEL + +DEPENDENCIES = ['august'] + +SCAN_INTERVAL = timedelta(seconds=5) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up August locks.""" + data = hass.data[DATA_AUGUST] + devices = [] + + for lock in data.locks: + devices.append(AugustLock(data, lock)) + + add_devices(devices, True) + + +class AugustLock(LockDevice): + """Representation of an August lock.""" + + def __init__(self, data, lock): + """Initialize the lock.""" + self._data = data + self._lock = lock + self._lock_status = None + self._lock_detail = None + self._changed_by = None + + def lock(self, **kwargs): + """Lock the device.""" + self._data.lock(self._lock.device_id) + + def unlock(self, **kwargs): + """Unlock the device.""" + self._data.unlock(self._lock.device_id) + + def update(self): + """Get the latest state of the sensor.""" + self._lock_status = self._data.get_lock_status(self._lock.device_id) + self._lock_detail = self._data.get_lock_detail(self._lock.device_id) + + from august.activity import ActivityType + activity = self._data.get_latest_device_activity( + self._lock.device_id, + ActivityType.LOCK_OPERATION) + + if activity is not None: + self._changed_by = activity.operated_by + + @property + def name(self): + """Return the name of this device.""" + return self._lock.device_name + + @property + def is_locked(self): + """Return true if device is on.""" + from august.lock import LockStatus + return self._lock_status is LockStatus.LOCKED + + @property + def changed_by(self): + """Last change triggered by.""" + return self._changed_by + + @property + def device_state_attributes(self): + """Return the device specific state attributes.""" + return { + ATTR_BATTERY_LEVEL: self._lock_detail.battery_level, + } diff --git a/homeassistant/components/lock/isy994.py b/homeassistant/components/lock/isy994.py index 33e2a0bea25..50371fdc9ae 100644 --- a/homeassistant/components/lock/isy994.py +++ b/homeassistant/components/lock/isy994.py @@ -53,8 +53,7 @@ class ISYLockDevice(ISYDevice, LockDevice): """Get the state of the lock.""" if self.is_unknown(): return None - else: - return VALUE_TO_STATE.get(self.value, STATE_UNKNOWN) + return VALUE_TO_STATE.get(self.value, STATE_UNKNOWN) def lock(self, **kwargs) -> None: """Send the lock command to the ISY994 device.""" diff --git a/homeassistant/components/lock/zwave.py b/homeassistant/components/lock/zwave.py index c0560722966..8f39d440cae 100644 --- a/homeassistant/components/lock/zwave.py +++ b/homeassistant/components/lock/zwave.py @@ -49,6 +49,7 @@ LOCK_NOTIFICATION = { LOCK_ALARM_TYPE = { '9': 'Deadbolt Jammed', + '16': 'Unlocked by Bluetooth ', '18': 'Locked with Keypad by user ', '19': 'Unlocked with Keypad by user ', '21': 'Manually Locked ', @@ -60,6 +61,7 @@ LOCK_ALARM_TYPE = { '112': 'Master code changed or User added: ', '113': 'Duplicate Pin-code: ', '130': 'RF module, power restored', + '144': 'Unlocked by NFC Tag or Card by user ', '161': 'Tamper Alarm: ', '167': 'Low Battery', '168': 'Critical Battery Level', @@ -98,7 +100,8 @@ ALARM_TYPE_STD = [ '19', '33', '112', - '113' + '113', + '144' ] SET_USERCODE_SCHEMA = vol.Schema({ diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index 9e1e2e54ad9..1fc6d1587fc 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -47,6 +47,11 @@ CONFIG_SCHEMA = vol.Schema({ }), }, extra=vol.ALLOW_EXTRA) +ALL_EVENT_TYPES = [ + EVENT_STATE_CHANGED, EVENT_LOGBOOK_ENTRY, + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP +] + GROUP_BY_MINUTES = 15 CONTINUOUS_DOMAINS = ['proximity', 'sensor'] @@ -136,7 +141,8 @@ class LogbookView(HomeAssistantView): events = yield from hass.async_add_job( _get_events, hass, self.config, start_day, end_day) - return self.json(events) + response = yield from hass.async_add_job(self.json, events) + return response class Entry(object): @@ -169,6 +175,8 @@ def humanify(events): - if 2+ sensor updates in GROUP_BY_MINUTES, show last - if home assistant stop and start happen in same minute call it restarted """ + domain_prefixes = tuple('{}.'.format(dom) for dom in CONTINUOUS_DOMAINS) + # Group events in batches of GROUP_BY_MINUTES for _, g_events in groupby( events, @@ -188,11 +196,7 @@ def humanify(events): if event.event_type == EVENT_STATE_CHANGED: entity_id = event.data.get('entity_id') - if entity_id is None: - continue - - if entity_id.startswith(tuple('{}.'.format( - domain) for domain in CONTINUOUS_DOMAINS)): + if entity_id.startswith(domain_prefixes): last_sensor_event[entity_id] = event elif event.event_type == EVENT_HOMEASSISTANT_STOP: @@ -213,14 +217,6 @@ def humanify(events): to_state = State.from_dict(event.data.get('new_state')) - # If last_changed != last_updated only attributes have changed - # we do not report on that yet. Also filter auto groups. - if not to_state or \ - to_state.last_changed != to_state.last_updated or \ - to_state.domain == 'group' and \ - to_state.attributes.get('auto', False): - continue - domain = to_state.domain # Skip all but the last sensor state @@ -275,21 +271,24 @@ def humanify(events): def _get_events(hass, config, start_day, end_day): """Get events for a period of time.""" - from homeassistant.components.recorder.models import Events + from homeassistant.components.recorder.models import Events, States from homeassistant.components.recorder.util import ( execute, session_scope) with session_scope(hass=hass) as session: - query = session.query(Events).order_by( - Events.time_fired).filter( - (Events.time_fired > start_day) & - (Events.time_fired < end_day)) + query = session.query(Events).order_by(Events.time_fired) \ + .outerjoin(States, (Events.event_id == States.event_id)) \ + .filter(Events.event_type.in_(ALL_EVENT_TYPES)) \ + .filter((Events.time_fired > start_day) + & (Events.time_fired < end_day)) \ + .filter((States.last_updated == States.last_changed) + | (States.last_updated.is_(None))) events = execute(query) return humanify(_exclude_events(events, config)) def _exclude_events(events, config): - """Get lists of excluded entities and platforms.""" + """Get list of filtered events.""" excluded_entities = [] excluded_domains = [] included_entities = [] @@ -308,23 +307,41 @@ def _exclude_events(events, config): domain, entity_id = None, None if event.event_type == EVENT_STATE_CHANGED: - to_state = State.from_dict(event.data.get('new_state')) + entity_id = event.data.get('entity_id') + + if entity_id is None: + continue + # Do not report on new entities if event.data.get('old_state') is None: continue + new_state = event.data.get('new_state') + # Do not report on entity removal - if not to_state: + if not new_state: + continue + + attributes = new_state.get('attributes', {}) + + # If last_changed != last_updated only attributes have changed + # we do not report on that yet. + last_changed = new_state.get('last_changed') + last_updated = new_state.get('last_updated') + if last_changed != last_updated: + continue + + domain = split_entity_id(entity_id)[0] + + # Also filter auto groups. + if domain == 'group' and attributes.get('auto', False): continue # exclude entities which are customized hidden - hidden = to_state.attributes.get(ATTR_HIDDEN, False) + hidden = attributes.get(ATTR_HIDDEN, False) if hidden: continue - domain = to_state.domain - entity_id = to_state.entity_id - elif event.event_type == EVENT_LOGBOOK_ENTRY: domain = event.data.get(ATTR_DOMAIN) entity_id = event.data.get(ATTR_ENTITY_ID) diff --git a/homeassistant/components/media_extractor.py b/homeassistant/components/media_extractor.py index f712007ccec..265784be74d 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -14,7 +14,7 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA) from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['youtube_dl==2018.01.21'] +REQUIREMENTS = ['youtube_dl==2018.02.11'] _LOGGER = logging.getLogger(__name__) @@ -85,7 +85,7 @@ class MediaExtractor(object): else: entities = self.get_entities() - if len(entities) == 0: + if not entities: self.call_media_player_service(stream_selector, None) for entity_id in entities: @@ -108,7 +108,7 @@ class MediaExtractor(object): _LOGGER.warning( "Playlists are not supported, looking for the first video") entries = list(all_media['entries']) - if len(entries) > 0: + if entries: selected_media = entries[0] else: _LOGGER.error("Playlist is empty") diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 06e89548785..37536bf5586 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -31,7 +31,6 @@ from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.loader import bind_hass -from homeassistant.util.async import run_coroutine_threadsafe _LOGGER = logging.getLogger(__name__) _RND = SystemRandom() @@ -878,12 +877,6 @@ class MediaPlayerDevice(Entity): return state_attr - def preload_media_image_url(self, url): - """Preload and cache a media image for future use.""" - run_coroutine_threadsafe( - _async_fetch_image(self.hass, url), self.hass.loop - ).result() - @asyncio.coroutine def _async_fetch_image(hass, url): diff --git a/homeassistant/components/media_player/aquostv.py b/homeassistant/components/media_player/aquostv.py index ae6d9e04643..6933286f0fe 100644 --- a/homeassistant/components/media_player/aquostv.py +++ b/homeassistant/components/media_player/aquostv.py @@ -201,9 +201,9 @@ class SharpAquosTVDevice(MediaPlayerDevice): self._remote.volume(int(self._volume * 60) - 2) @_retry - def set_volume_level(self, level): + def set_volume_level(self, volume): """Set Volume media player.""" - self._remote.volume(int(level * 60)) + self._remote.volume(int(volume * 60)) @_retry def mute_volume(self, mute): diff --git a/homeassistant/components/media_player/bluesound.py b/homeassistant/components/media_player/bluesound.py index 848c6abe91f..d308b94e64c 100644 --- a/homeassistant/components/media_player/bluesound.py +++ b/homeassistant/components/media_player/bluesound.py @@ -16,14 +16,16 @@ import async_timeout import voluptuous as vol from homeassistant.components.media_player import ( - MEDIA_TYPE_MUSIC, PLATFORM_SCHEMA, SUPPORT_CLEAR_PLAYLIST, - SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, - SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, SUPPORT_SELECT_SOURCE, SUPPORT_STOP, + ATTR_MEDIA_ENQUEUE, DOMAIN, MEDIA_TYPE_MUSIC, PLATFORM_SCHEMA, + SUPPORT_CLEAR_PLAYLIST, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, + SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, + SUPPORT_SELECT_SOURCE, SUPPORT_SHUFFLE_SET, SUPPORT_STOP, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP, MediaPlayerDevice) from homeassistant.const import ( - CONF_HOST, CONF_HOSTS, CONF_NAME, CONF_PORT, EVENT_HOMEASSISTANT_START, - EVENT_HOMEASSISTANT_STOP, STATE_IDLE, STATE_PAUSED, STATE_PLAYING) + ATTR_ENTITY_ID, CONF_HOST, CONF_HOSTS, CONF_NAME, CONF_PORT, + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, STATE_IDLE, + STATE_OFF, STATE_PAUSED, STATE_PLAYING) from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -35,10 +37,14 @@ REQUIREMENTS = ['xmltodict==0.11.0'] _LOGGER = logging.getLogger(__name__) -STATE_OFFLINE = 'offline' -ATTR_MODEL = 'model' -ATTR_MODEL_NAME = 'model_name' -ATTR_BRAND = 'brand' +STATE_GROUPED = 'grouped' + +ATTR_MASTER = 'master' + +SERVICE_JOIN = 'bluesound_join' +SERVICE_UNJOIN = 'bluesound_unjoin' +SERVICE_SET_TIMER = 'bluesound_set_sleep_timer' +SERVICE_CLEAR_TIMER = 'bluesound_clear_sleep_timer' DATA_BLUESOUND = 'bluesound' DEFAULT_PORT = 11000 @@ -58,6 +64,29 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }]) }) +BS_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, +}) + +BS_JOIN_SCHEMA = BS_SCHEMA.extend({ + vol.Required(ATTR_MASTER): cv.entity_id, +}) + +SERVICE_TO_METHOD = { + SERVICE_JOIN: { + 'method': 'async_join', + 'schema': BS_JOIN_SCHEMA}, + SERVICE_UNJOIN: { + 'method': 'async_unjoin', + 'schema': BS_SCHEMA}, + SERVICE_SET_TIMER: { + 'method': 'async_increase_timer', + 'schema': BS_SCHEMA}, + SERVICE_CLEAR_TIMER: { + 'method': 'async_clear_timer', + 'schema': BS_SCHEMA} +} + def _add_player(hass, async_add_devices, host, port=None, name=None): """Add Bluesound players.""" @@ -120,6 +149,30 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): hass, async_add_devices, host.get(CONF_HOST), host.get(CONF_PORT), host.get(CONF_NAME)) + @asyncio.coroutine + def async_service_handler(service): + """Map services to method of Bluesound devices.""" + method = SERVICE_TO_METHOD.get(service.service) + if not method: + return + + params = {key: value for key, value in service.data.items() + if key != ATTR_ENTITY_ID} + entity_ids = service.data.get(ATTR_ENTITY_ID) + if entity_ids: + target_players = [player for player in hass.data[DATA_BLUESOUND] + if player.entity_id in entity_ids] + else: + target_players = hass.data[DATA_BLUESOUND] + + for player in target_players: + yield from getattr(player, method['method'])(**params) + + for service in SERVICE_TO_METHOD: + schema = SERVICE_TO_METHOD[service]['schema'] + hass.services.async_register( + DOMAIN, service, async_service_handler, schema=schema) + class BluesoundPlayer(MediaPlayerDevice): """Representation of a Bluesound Player.""" @@ -128,13 +181,10 @@ class BluesoundPlayer(MediaPlayerDevice): """Initialize the media player.""" self.host = host self._hass = hass - self._port = port + self.port = port self._polling_session = async_get_clientsession(hass) self._polling_task = None # The actual polling task. self._name = name - self._brand = None - self._model = None - self._model_name = None self._icon = None self._capture_items = [] self._services_items = [] @@ -145,9 +195,13 @@ class BluesoundPlayer(MediaPlayerDevice): self._is_online = False self._retry_remove = None self._lastvol = None + self._master = None + self._is_master = False + self._group_name = None + self._init_callback = init_callback - if self._port is None: - self._port = DEFAULT_PORT + if self.port is None: + self.port = DEFAULT_PORT @staticmethod def _try_get_index(string, search_string): @@ -158,7 +212,7 @@ class BluesoundPlayer(MediaPlayerDevice): return -1 @asyncio.coroutine - def _internal_update_sync_status( + def force_update_sync_status( self, on_updated_cb=None, raise_timeout=False): """Update the internal status.""" resp = None @@ -174,14 +228,27 @@ class BluesoundPlayer(MediaPlayerDevice): if not self._name: self._name = self._sync_status.get('@name', self.host) - if not self._brand: - self._brand = self._sync_status.get('@brand', self.host) - if not self._model: - self._model = self._sync_status.get('@model', self.host) if not self._icon: self._icon = self._sync_status.get('@icon', self.host) - if not self._model_name: - self._model_name = self._sync_status.get('@modelName', self.host) + + master = self._sync_status.get('master', None) + if master is not None: + self._is_master = False + master_host = master.get('#text') + master_device = [device for device in + self._hass.data[DATA_BLUESOUND] + if device.host == master_host] + + if master_device and master_host != self.host: + self._master = master_device[0] + else: + self._master = None + _LOGGER.error("Master not found %s", master_host) + else: + if self._master is not None: + self._master = None + slaves = self._sync_status.get('slave', None) + self._is_master = slaves is not None if on_updated_cb: on_updated_cb() @@ -223,7 +290,7 @@ class BluesoundPlayer(MediaPlayerDevice): self._retry_remove() self._retry_remove = None - yield from self._internal_update_sync_status( + yield from self.force_update_sync_status( self._init_callback, True) except (asyncio.TimeoutError, ClientError): _LOGGER.info("Node %s is offline, retrying later", self.host) @@ -256,7 +323,7 @@ class BluesoundPlayer(MediaPlayerDevice): if method[0] == '/': method = method[1:] - url = "http://{}:{}/{}".format(self.host, self._port, method) + url = "http://{}:{}/{}".format(self.host, self.port, method) _LOGGER.debug("Calling URL: %s", url) response = None @@ -297,42 +364,71 @@ class BluesoundPlayer(MediaPlayerDevice): etag = self._status.get('@etag', '') if etag != '': - url = 'Status?etag={}&timeout=60.0'.format(etag) - url = "http://{}:{}/{}".format(self.host, self._port, url) + url = 'Status?etag={}&timeout=120.0'.format(etag) + url = "http://{}:{}/{}".format(self.host, self.port, url) _LOGGER.debug("Calling URL: %s", url) try: - with async_timeout.timeout(65, loop=self._hass.loop): + with async_timeout.timeout(125, loop=self._hass.loop): response = yield from self._polling_session.get( url, headers={CONNECTION: KEEP_ALIVE}) if response.status != 200: - _LOGGER.error("Error %s on %s", response.status, url) + _LOGGER.error("Error %s on %s. Trying one more time.", + response.status, url) + else: + result = yield from response.text() + self._is_online = True + self._last_status_update = dt_util.utcnow() + self._status = xmltodict.parse(result)['status'].copy() - result = yield from response.text() - self._is_online = True - self._last_status_update = dt_util.utcnow() - self._status = xmltodict.parse(result)['status'].copy() - self.schedule_update_ha_state() + group_name = self._status.get('groupName', None) + if group_name != self._group_name: + _LOGGER.debug('Group name change detected on device: %s', + self.host) + self._group_name = group_name + # the sleep is needed to make sure that the + # devices is synced + yield from asyncio.sleep(1, loop=self._hass.loop) + yield from self.async_trigger_sync_on_all() + elif self.is_grouped: + # when player is grouped we need to fetch volume from + # sync_status. We will force an update if the player is + # grouped this isn't a foolproof solution. A better + # solution would be to fetch sync_status more often when + # the device is playing. This would solve alot of + # problems. This change will be done when the + # communication is moved to a separate library + yield from self.force_update_sync_status() + + self.async_schedule_update_ha_state() except (asyncio.TimeoutError, ClientError): self._is_online = False self._last_status_update = None self._status = None - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() _LOGGER.info("Client connection error, marking %s as offline", self._name) raise + @asyncio.coroutine + def async_trigger_sync_on_all(self): + """Trigger sync status update on all devices.""" + _LOGGER.debug("Trigger sync status on all devices") + + for player in self._hass.data[DATA_BLUESOUND]: + yield from player.force_update_sync_status() + @asyncio.coroutine @Throttle(SYNC_STATUS_INTERVAL) def async_update_sync_status(self, on_updated_cb=None, raise_timeout=False): """Update sync status.""" - yield from self._internal_update_sync_status( + yield from self.force_update_sync_status( on_updated_cb, raise_timeout=False) @asyncio.coroutine @@ -433,20 +529,23 @@ class BluesoundPlayer(MediaPlayerDevice): def state(self): """Return the state of the device.""" if self._status is None: - return STATE_OFFLINE + return STATE_OFF + + if self.is_grouped and not self.is_master: + return STATE_GROUPED status = self._status.get('state', None) if status == 'pause' or status == 'stop': return STATE_PAUSED elif status == 'stream' or status == 'play': return STATE_PLAYING - else: - return STATE_IDLE + return STATE_IDLE @property def media_title(self): """Title of current playing media.""" - if self._status is None: + if (self._status is None or + (self.is_grouped and not self.is_master)): return None return self._status.get('title1', None) @@ -457,6 +556,9 @@ class BluesoundPlayer(MediaPlayerDevice): if self._status is None: return None + if self.is_grouped and not self.is_master: + return self._group_name + artist = self._status.get('artist', None) if not artist: artist = self._status.get('title2', None) @@ -465,7 +567,8 @@ class BluesoundPlayer(MediaPlayerDevice): @property def media_album_name(self): """Artist of current playing media (Music track only).""" - if self._status is None: + if (self._status is None or + (self.is_grouped and not self.is_master)): return None album = self._status.get('album', None) @@ -476,21 +579,23 @@ class BluesoundPlayer(MediaPlayerDevice): @property def media_image_url(self): """Image url of current playing media.""" - if self._status is None: + if (self._status is None or + (self.is_grouped and not self.is_master)): return None url = self._status.get('image', None) if not url: return if url[0] == '/': - url = "http://{}:{}{}".format(self.host, self._port, url) + url = "http://{}:{}{}".format(self.host, self.port, url) return url @property def media_position(self): """Position of current playing media in seconds.""" - if self._status is None: + if (self._status is None or + (self.is_grouped and not self.is_master)): return None mediastate = self.state @@ -511,7 +616,8 @@ class BluesoundPlayer(MediaPlayerDevice): @property def media_duration(self): """Duration of current playing media in seconds.""" - if self._status is None: + if (self._status is None or + (self.is_grouped and not self.is_master)): return None duration = self._status.get('totlen', None) @@ -527,10 +633,10 @@ class BluesoundPlayer(MediaPlayerDevice): @property def volume_level(self): """Volume level of the media player (0..1).""" - if self._status is None: - return None - volume = self._status.get('volume', None) + if self.is_grouped: + volume = self._sync_status.get('@volume', None) + if volume is not None: return int(volume) / 100 return None @@ -538,9 +644,6 @@ class BluesoundPlayer(MediaPlayerDevice): @property def is_volume_muted(self): """Boolean if volume is currently muted.""" - if not self._status: - return None - volume = self.volume_level if not volume: return None @@ -559,7 +662,8 @@ class BluesoundPlayer(MediaPlayerDevice): @property def source_list(self): """List of available input sources.""" - if self._status is None: + if (self._status is None or + (self.is_grouped and not self.is_master)): return None sources = [] @@ -582,7 +686,8 @@ class BluesoundPlayer(MediaPlayerDevice): """Name of the current input source.""" from urllib import parse - if self._status is None: + if (self._status is None or + (self.is_grouped and not self.is_master)): return None current_service = self._status.get('service', '') @@ -595,7 +700,7 @@ class BluesoundPlayer(MediaPlayerDevice): # But it works with radio service_items will catch playlists. items = [x for x in self._preset_items if 'url2' in x and parse.unquote(x['url2']) == stream_url] - if len(items) > 0: + if items: return items[0]['title'] # This could be a bit difficult to detect. Bluetooth could be named @@ -606,11 +711,11 @@ class BluesoundPlayer(MediaPlayerDevice): if title == 'bluetooth' or stream_url == 'Capture:hw:2,0/44100/16/2': items = [x for x in self._capture_items if x['url'] == "Capture%3Abluez%3Abluetooth"] - if len(items) > 0: + if items: return items[0]['title'] items = [x for x in self._capture_items if x['url'] == stream_url] - if len(items) > 0: + if items: return items[0]['title'] if stream_url[:8] == 'Capture:': @@ -631,12 +736,12 @@ class BluesoundPlayer(MediaPlayerDevice): items = [x for x in self._capture_items if x['name'] == current_service] - if len(items) > 0: + if items: return items[0]['title'] items = [x for x in self._services_items if x['name'] == current_service] - if len(items) > 0: + if items: return items[0]['title'] if self._status.get('streamUrl', '') != '': @@ -650,12 +755,17 @@ class BluesoundPlayer(MediaPlayerDevice): if self._status is None: return None + if self.is_grouped and not self.is_master: + return SUPPORT_VOLUME_STEP | SUPPORT_VOLUME_SET | \ + SUPPORT_VOLUME_MUTE + supported = SUPPORT_CLEAR_PLAYLIST if self._status.get('indexing', '0') == '0': supported = supported | SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | \ SUPPORT_NEXT_TRACK | SUPPORT_PLAY_MEDIA | \ - SUPPORT_STOP | SUPPORT_PLAY | SUPPORT_SELECT_SOURCE + SUPPORT_STOP | SUPPORT_PLAY | SUPPORT_SELECT_SOURCE | \ + SUPPORT_SHUFFLE_SET current_vol = self.volume_level if current_vol is not None and current_vol >= 0: @@ -668,17 +778,87 @@ class BluesoundPlayer(MediaPlayerDevice): return supported @property - def device_state_attributes(self): - """Return the state attributes.""" - return { - ATTR_MODEL: self._model, - ATTR_MODEL_NAME: self._model_name, - ATTR_BRAND: self._brand, - } + def is_master(self): + """Return true if player is a coordinator.""" + return self._is_master + + @property + def is_grouped(self): + """Return true if player is a coordinator.""" + return self._master is not None or self._is_master + + @property + def shuffle(self): + """Return true if shuffle is active.""" + return True if self._status.get('shuffle', '0') == '1' else False + + @asyncio.coroutine + def async_join(self, master): + """Join the player to a group.""" + master_device = [device for device in self.hass.data[DATA_BLUESOUND] + if device.entity_id == master] + + if master_device: + _LOGGER.debug("Trying to join player: %s to master: %s", + self.host, master_device[0].host) + + yield from master_device[0].async_add_slave(self) + else: + _LOGGER.error("Master not found %s", master_device) + + @asyncio.coroutine + def async_unjoin(self): + """Unjoin the player from a group.""" + if self._master is None: + return + + _LOGGER.debug("Trying to unjoin player: %s", self.host) + yield from self._master.async_remove_slave(self) + + @asyncio.coroutine + def async_add_slave(self, slave_device): + """Add slave to master.""" + return self.send_bluesound_command('/AddSlave?slave={}&port={}' + .format(slave_device.host, + slave_device.port)) + + @asyncio.coroutine + def async_remove_slave(self, slave_device): + """Remove slave to master.""" + return self.send_bluesound_command('/RemoveSlave?slave={}&port={}' + .format(slave_device.host, + slave_device.port)) + + @asyncio.coroutine + def async_increase_timer(self): + """Increase sleep time on player.""" + sleep_time = yield from self.send_bluesound_command('/Sleep') + if sleep_time is None: + _LOGGER.error('Error while increasing sleep time on player: %s', + self.host) + return 0 + + return int(sleep_time.get('sleep', '0')) + + @asyncio.coroutine + def async_clear_timer(self): + """Clear sleep timer on player.""" + sleep = 1 + while sleep > 0: + sleep = yield from self.async_increase_timer() + + @asyncio.coroutine + def async_set_shuffle(self, shuffle): + """Enable or disable shuffle mode.""" + return self.send_bluesound_command('/Shuffle?state={}' + .format('1' if shuffle else '0')) @asyncio.coroutine def async_select_source(self, source): """Select input source.""" + if self.is_grouped and not self.is_master: + return + items = [x for x in self._preset_items if x['title'] == source] if len(items) < 1: @@ -701,11 +881,17 @@ class BluesoundPlayer(MediaPlayerDevice): @asyncio.coroutine def async_clear_playlist(self): """Clear players playlist.""" + if self.is_grouped and not self.is_master: + return + return self.send_bluesound_command('Clear') @asyncio.coroutine def async_media_next_track(self): """Send media_next command to media player.""" + if self.is_grouped and not self.is_master: + return + cmd = 'Skip' if self._status and 'actions' in self._status: for action in self._status['actions']['action']: @@ -718,6 +904,9 @@ class BluesoundPlayer(MediaPlayerDevice): @asyncio.coroutine def async_media_previous_track(self): """Send media_previous command to media player.""" + if self.is_grouped and not self.is_master: + return + cmd = 'Back' if self._status and 'actions' in self._status: for action in self._status['actions']['action']: @@ -730,23 +919,52 @@ class BluesoundPlayer(MediaPlayerDevice): @asyncio.coroutine def async_media_play(self): """Send media_play command to media player.""" + if self.is_grouped and not self.is_master: + return + return self.send_bluesound_command('Play') @asyncio.coroutine def async_media_pause(self): """Send media_pause command to media player.""" + if self.is_grouped and not self.is_master: + return + return self.send_bluesound_command('Pause') @asyncio.coroutine def async_media_stop(self): """Send stop command.""" + if self.is_grouped and not self.is_master: + return + return self.send_bluesound_command('Pause') @asyncio.coroutine def async_media_seek(self, position): """Send media_seek command to media player.""" + if self.is_grouped and not self.is_master: + return + return self.send_bluesound_command('Play?seek=' + str(float(position))) + @asyncio.coroutine + def async_play_media(self, media_type, media_id, **kwargs): + """ + Send the play_media command to the media player. + + If ATTR_MEDIA_ENQUEUE is True, add `media_id` to the queue. + """ + if self.is_grouped and not self.is_master: + return + + url = 'Play?url={}'.format(media_id) + + if kwargs.get(ATTR_MEDIA_ENQUEUE): + return self.send_bluesound_command(url) + + return self.send_bluesound_command(url) + @asyncio.coroutine def async_volume_up(self): """Volume up the media player.""" diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index 928062cb2dc..40e09ea328c 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -5,10 +5,16 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.cast/ """ # pylint: disable=import-error +import asyncio import logging +import threading import voluptuous as vol +from homeassistant.helpers.typing import HomeAssistantType, ConfigType +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import (dispatcher_send, + async_dispatcher_connect) from homeassistant.components.media_player import ( MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, @@ -16,11 +22,11 @@ from homeassistant.components.media_player import ( SUPPORT_STOP, SUPPORT_PLAY, MediaPlayerDevice, PLATFORM_SCHEMA) from homeassistant.const import ( CONF_HOST, STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING, - STATE_UNKNOWN) + STATE_UNKNOWN, EVENT_HOMEASSISTANT_STOP) import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -REQUIREMENTS = ['pychromecast==1.0.3'] +REQUIREMENTS = ['pychromecast==2.0.0'] _LOGGER = logging.getLogger(__name__) @@ -33,7 +39,13 @@ SUPPORT_CAST = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PREVIOUS_TRACK | \ SUPPORT_NEXT_TRACK | SUPPORT_PLAY_MEDIA | SUPPORT_STOP | SUPPORT_PLAY -KNOWN_HOSTS_KEY = 'cast_known_hosts' +INTERNAL_DISCOVERY_RUNNING_KEY = 'cast_discovery_running' +# UUID -> CastDevice mapping; cast devices without UUID are not stored +ADDED_CAST_DEVICES_KEY = 'cast_added_cast_devices' +# Stores every discovered (host, port, uuid) +KNOWN_CHROMECASTS_KEY = 'cast_all_chromecasts' + +SIGNAL_CAST_DISCOVERED = 'cast_discovered' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOST): cv.string, @@ -41,67 +53,145 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -# pylint: disable=unused-argument -def setup_platform(hass, config, add_devices, discovery_info=None): +def _setup_internal_discovery(hass: HomeAssistantType) -> None: + """Set up the pychromecast internal discovery.""" + hass.data.setdefault(INTERNAL_DISCOVERY_RUNNING_KEY, threading.Lock()) + if not hass.data[INTERNAL_DISCOVERY_RUNNING_KEY].acquire(blocking=False): + # Internal discovery is already running + return + + import pychromecast + + def internal_callback(name): + """Called when zeroconf has discovered a new chromecast.""" + mdns = listener.services[name] + ip_address, port, uuid, _, _ = mdns + key = (ip_address, port, uuid) + + if key in hass.data[KNOWN_CHROMECASTS_KEY]: + _LOGGER.debug("Discovered previous chromecast %s", mdns) + return + + _LOGGER.debug("Discovered new chromecast %s", mdns) + try: + # pylint: disable=protected-access + chromecast = pychromecast._get_chromecast_from_host( + mdns, blocking=True) + except pychromecast.ChromecastConnectionError: + _LOGGER.debug("Can't set up cast with mDNS info %s. " + "Assuming it's not a Chromecast", mdns) + return + hass.data[KNOWN_CHROMECASTS_KEY][key] = chromecast + dispatcher_send(hass, SIGNAL_CAST_DISCOVERED, chromecast) + + _LOGGER.debug("Starting internal pychromecast discovery.") + listener, browser = pychromecast.start_discovery(internal_callback) + + def stop_discovery(event): + """Stop discovery of new chromecasts.""" + pychromecast.stop_discovery(browser) + hass.data[INTERNAL_DISCOVERY_RUNNING_KEY].release() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_discovery) + + +@callback +def _async_create_cast_device(hass, chromecast): + """Create a CastDevice Entity from the chromecast object. + + Returns None if the cast device has already been added. Additionally, + automatically updates existing chromecast entities. + """ + if chromecast.uuid is None: + # Found a cast without UUID, we don't store it because we won't be able + # to update it anyway. + return CastDevice(chromecast) + + # Found a cast with UUID + added_casts = hass.data[ADDED_CAST_DEVICES_KEY] + old_cast_device = added_casts.get(chromecast.uuid) + if old_cast_device is None: + # -> New cast device + cast_device = CastDevice(chromecast) + added_casts[chromecast.uuid] = cast_device + return cast_device + + old_key = (old_cast_device.cast.host, + old_cast_device.cast.port, + old_cast_device.cast.uuid) + new_key = (chromecast.host, chromecast.port, chromecast.uuid) + + if old_key == new_key: + # Re-discovered with same data, ignore + return None + + # -> Cast device changed host + # Remove old pychromecast.Chromecast from global list, because it isn't + # valid anymore + old_cast_device.async_set_chromecast(chromecast) + return None + + +@asyncio.coroutine +def async_setup_platform(hass: HomeAssistantType, config: ConfigType, + async_add_devices, discovery_info=None): """Set up the cast platform.""" import pychromecast # Import CEC IGNORE attributes pychromecast.IGNORE_CEC += config.get(CONF_IGNORE_CEC, []) + hass.data.setdefault(ADDED_CAST_DEVICES_KEY, {}) + hass.data.setdefault(KNOWN_CHROMECASTS_KEY, {}) - known_hosts = hass.data.get(KNOWN_HOSTS_KEY) - if known_hosts is None: - known_hosts = hass.data[KNOWN_HOSTS_KEY] = [] - + # None -> use discovery; (host, port) -> manually specify chromecast. + want_host = None if discovery_info: - host = (discovery_info.get('host'), discovery_info.get('port')) - - if host in known_hosts: - return - - hosts = [host] - + want_host = (discovery_info.get('host'), discovery_info.get('port')) elif CONF_HOST in config: - host = (config.get(CONF_HOST), DEFAULT_PORT) + want_host = (config.get(CONF_HOST), DEFAULT_PORT) - if host in known_hosts: - return + enable_discovery = False + if want_host is None: + # We were explicitly told to enable pychromecast discovery. + enable_discovery = True + elif want_host[1] != DEFAULT_PORT: + # We're trying to add a group, so we have to use pychromecast's + # discovery to get the correct friendly name. + enable_discovery = True - hosts = [host] + if enable_discovery: + @callback + def async_cast_discovered(chromecast): + """Callback for when a new chromecast is discovered.""" + if want_host is not None and \ + (chromecast.host, chromecast.port) != want_host: + return # for groups, only add requested device + cast_device = _async_create_cast_device(hass, chromecast) + if cast_device is not None: + async_add_devices([cast_device]) + + async_dispatcher_connect(hass, SIGNAL_CAST_DISCOVERED, + async_cast_discovered) + # Re-play the callback for all past chromecasts, store the objects in + # a list to avoid concurrent modification resulting in exception. + for chromecast in list(hass.data[KNOWN_CHROMECASTS_KEY].values()): + async_cast_discovered(chromecast) + + hass.async_add_job(_setup_internal_discovery, hass) else: - hosts = [tuple(dev[:2]) for dev in pychromecast.discover_chromecasts() - if tuple(dev[:2]) not in known_hosts] - - casts = [] - - # get_chromecasts() returns Chromecast objects with the correct friendly - # name for grouped devices - all_chromecasts = pychromecast.get_chromecasts() - - for host in hosts: - (_, port) = host - found = [device for device in all_chromecasts - if (device.host, device.port) == host] - if found: - try: - casts.append(CastDevice(found[0])) - known_hosts.append(host) - except pychromecast.ChromecastConnectionError: - pass - - # do not add groups using pychromecast.Chromecast as it leads to names - # collision since pychromecast.Chromecast will get device name instead - # of group name - elif port == DEFAULT_PORT: - try: - # add the device anyway, get_chromecasts couldn't find it - casts.append(CastDevice(pychromecast.Chromecast(*host))) - known_hosts.append(host) - except pychromecast.ChromecastConnectionError: - pass - - add_devices(casts) + # Manually add a "normal" Chromecast, we can do that without discovery. + try: + chromecast = yield from hass.async_add_job( + pychromecast.Chromecast, *want_host) + except pychromecast.ChromecastConnectionError: + _LOGGER.warning("Can't set up chromecast on %s", want_host[0]) + raise + key = (chromecast.host, chromecast.port, chromecast.uuid) + cast_device = _async_create_cast_device(hass, chromecast) + if cast_device is not None: + hass.data[KNOWN_CHROMECASTS_KEY][key] = chromecast + async_add_devices([cast_device]) class CastDevice(MediaPlayerDevice): @@ -109,16 +199,13 @@ class CastDevice(MediaPlayerDevice): def __init__(self, chromecast): """Initialize the Cast device.""" - self.cast = chromecast - - self.cast.socket_client.receiver_controller.register_status_listener( - self) - self.cast.socket_client.media_controller.register_status_listener(self) - - self.cast_status = self.cast.status - self.media_status = self.cast.media_controller.status + self.cast = None # type: pychromecast.Chromecast + self.cast_status = None + self.media_status = None self.media_status_received = None + self.async_set_chromecast(chromecast) + @property def should_poll(self): """No polling needed.""" @@ -325,3 +412,39 @@ class CastDevice(MediaPlayerDevice): self.media_status = status self.media_status_received = dt_util.utcnow() self.schedule_update_ha_state() + + @property + def unique_id(self) -> str: + """Return an unique ID.""" + if self.cast.uuid is not None: + return str(self.cast.uuid) + return None + + @callback + def async_set_chromecast(self, chromecast): + """Set the internal Chromecast object and disconnect the previous.""" + self._async_disconnect() + + self.cast = chromecast + + self.cast.socket_client.receiver_controller.register_status_listener( + self) + self.cast.socket_client.media_controller.register_status_listener(self) + + self.cast_status = self.cast.status + self.media_status = self.cast.media_controller.status + + @asyncio.coroutine + def async_will_remove_from_hass(self): + """Disconnect Chromecast object when removed.""" + self._async_disconnect() + + @callback + def _async_disconnect(self): + """Disconnect Chromecast object if it is set.""" + if self.cast is None: + return + _LOGGER.debug("Disconnecting existing chromecast object") + old_key = (self.cast.host, self.cast.port, self.cast.uuid) + self.hass.data[KNOWN_CHROMECASTS_KEY].pop(old_key) + self.cast.disconnect(blocking=False) diff --git a/homeassistant/components/media_player/clementine.py b/homeassistant/components/media_player/clementine.py index 057a23579ca..6847b87e54f 100644 --- a/homeassistant/components/media_player/clementine.py +++ b/homeassistant/components/media_player/clementine.py @@ -37,7 +37,7 @@ SUPPORT_CLEMENTINE = SUPPORT_PAUSE | SUPPORT_VOLUME_STEP | \ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_ACCESS_TOKEN, default=None): cv.positive_int, + vol.Optional(CONF_ACCESS_TOKEN): cv.positive_int, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, }) diff --git a/homeassistant/components/media_player/denonavr.py b/homeassistant/components/media_player/denonavr.py index 0a03af0e1bf..5bc16d11d64 100644 --- a/homeassistant/components/media_player/denonavr.py +++ b/homeassistant/components/media_player/denonavr.py @@ -20,7 +20,7 @@ from homeassistant.const import ( CONF_NAME, STATE_ON, CONF_ZONE, CONF_TIMEOUT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['denonavr==0.5.5'] +REQUIREMENTS = ['denonavr==0.6.0'] _LOGGER = logging.getLogger(__name__) @@ -43,12 +43,12 @@ SUPPORT_MEDIA_MODES = SUPPORT_PLAY_MEDIA | \ DENON_ZONE_SCHEMA = vol.Schema({ vol.Required(CONF_ZONE): vol.In(CONF_VALID_ZONES, CONF_INVALID_ZONES_ERR), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_NAME): cv.string, }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_SHOW_ALL_SOURCES, default=DEFAULT_SHOW_SOURCES): cv.boolean, vol.Optional(CONF_ZONES): @@ -80,7 +80,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if zones is not None: add_zones = {} for entry in zones: - add_zones[entry[CONF_ZONE]] = entry[CONF_NAME] + add_zones[entry[CONF_ZONE]] = entry.get(CONF_NAME) else: add_zones = None diff --git a/homeassistant/components/media_player/emby.py b/homeassistant/components/media_player/emby.py index a3fe62c5a42..e363ab12f92 100644 --- a/homeassistant/components/media_player/emby.py +++ b/homeassistant/components/media_player/emby.py @@ -45,7 +45,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, vol.Required(CONF_API_KEY): cv.string, - vol.Optional(CONF_PORT, default=None): cv.port, + vol.Optional(CONF_PORT): cv.port, vol.Optional(CONF_AUTO_HIDE, default=DEFAULT_AUTO_HIDE): cv.boolean, }) diff --git a/homeassistant/components/media_player/frontier_silicon.py b/homeassistant/components/media_player/frontier_silicon.py index f46d0657604..6d95ea675fb 100644 --- a/homeassistant/components/media_player/frontier_silicon.py +++ b/homeassistant/components/media_player/frontier_silicon.py @@ -4,6 +4,7 @@ Support for Frontier Silicon Devices (Medion, Hama, Auna,...). For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.frontier_silicon/ """ +import asyncio import logging import voluptuous as vol @@ -19,7 +20,7 @@ from homeassistant.const import ( CONF_HOST, CONF_PORT, CONF_PASSWORD) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['fsapi==0.0.7'] +REQUIREMENTS = ['afsapi==0.0.3'] _LOGGER = logging.getLogger(__name__) @@ -41,14 +42,15 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices, discovery_info=None): +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Frontier Silicon platform.""" import requests if discovery_info is not None: - add_devices( - [FSAPIDevice(discovery_info['ssdp_description'], - DEFAULT_PASSWORD)], + async_add_devices( + [AFSAPIDevice(discovery_info['ssdp_description'], + DEFAULT_PASSWORD)], update_before_add=True) return True @@ -57,8 +59,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): password = config.get(CONF_PASSWORD) try: - add_devices( - [FSAPIDevice(DEVICE_URL.format(host, port), password)], + async_add_devices( + [AFSAPIDevice(DEVICE_URL.format(host, port), password)], update_before_add=True) _LOGGER.debug("FSAPI device %s:%s -> %s", host, port, password) return True @@ -69,7 +71,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): return False -class FSAPIDevice(MediaPlayerDevice): +class AFSAPIDevice(MediaPlayerDevice): """Representation of a Frontier Silicon device on the network.""" def __init__(self, device_url, password): @@ -97,9 +99,9 @@ class FSAPIDevice(MediaPlayerDevice): connected to the device in between the updates and invalidated the existing session (i.e UNDOK). """ - from fsapi import FSAPI + from afsapi import AFSAPI - return FSAPI(self._device_url, self._password) + return AFSAPI(self._device_url, self._password) @property def should_poll(self): @@ -157,17 +159,18 @@ class FSAPIDevice(MediaPlayerDevice): """Image url of current playing media.""" return self._media_image_url - def update(self): + @asyncio.coroutine + def async_update(self): """Get the latest date and update device state.""" fs_device = self.fs_device if not self._name: - self._name = fs_device.friendly_name + self._name = yield from fs_device.get_friendly_name() if not self._source_list: - self._source_list = fs_device.mode_list + self._source_list = yield from fs_device.get_mode_list() - status = fs_device.play_status + status = yield from fs_device.get_play_status() self._state = { 'playing': STATE_PLAYING, 'paused': STATE_PAUSED, @@ -176,54 +179,70 @@ class FSAPIDevice(MediaPlayerDevice): None: STATE_OFF, }.get(status, STATE_UNKNOWN) - info_name = fs_device.play_info_name - info_text = fs_device.play_info_text + if self._state != STATE_OFF: + info_name = yield from fs_device.get_play_name() + info_text = yield from fs_device.get_play_text() - self._title = ' - '.join(filter(None, [info_name, info_text])) - self._artist = fs_device.play_info_artist - self._album_name = fs_device.play_info_album + self._title = ' - '.join(filter(None, [info_name, info_text])) + self._artist = yield from fs_device.get_play_artist() + self._album_name = yield from fs_device.get_play_album() - self._source = fs_device.mode - self._mute = fs_device.mute - self._media_image_url = fs_device.play_info_graphics + self._source = yield from fs_device.get_mode() + self._mute = yield from fs_device.get_mute() + self._media_image_url = yield from fs_device.get_play_graphic() + else: + self._title = None + self._artist = None + self._album_name = None + + self._source = None + self._mute = None + self._media_image_url = None # Management actions - # power control - def turn_on(self): + @asyncio.coroutine + def async_turn_on(self): """Turn on the device.""" - self.fs_device.power = True + yield from self.fs_device.set_power(True) - def turn_off(self): + @asyncio.coroutine + def async_turn_off(self): """Turn off the device.""" - self.fs_device.power = False + yield from self.fs_device.set_power(False) - def media_play(self): + @asyncio.coroutine + def async_media_play(self): """Send play command.""" - self.fs_device.play() + yield from self.fs_device.play() - def media_pause(self): + @asyncio.coroutine + def async_media_pause(self): """Send pause command.""" - self.fs_device.pause() + yield from self.fs_device.pause() - def media_play_pause(self): + @asyncio.coroutine + def async_media_play_pause(self): """Send play/pause command.""" if 'playing' in self._state: - self.fs_device.pause() + yield from self.fs_device.pause() else: - self.fs_device.play() + yield from self.fs_device.play() - def media_stop(self): + @asyncio.coroutine + def async_media_stop(self): """Send play/pause command.""" - self.fs_device.pause() + yield from self.fs_device.pause() - def media_previous_track(self): + @asyncio.coroutine + def async_media_previous_track(self): """Send previous track command (results in rewind).""" - self.fs_device.prev() + yield from self.fs_device.rewind() - def media_next_track(self): + @asyncio.coroutine + def async_media_next_track(self): """Send next track command (results in fast-forward).""" - self.fs_device.next() + yield from self.fs_device.forward() # mute @property @@ -231,23 +250,30 @@ class FSAPIDevice(MediaPlayerDevice): """Boolean if volume is currently muted.""" return self._mute - def mute_volume(self, mute): + @asyncio.coroutine + def async_mute_volume(self, mute): """Send mute command.""" - self.fs_device.mute = mute + yield from self.fs_device.set_mute(mute) # volume - def volume_up(self): + @asyncio.coroutine + def async_volume_up(self): """Send volume up command.""" - self.fs_device.volume += 1 + volume = yield from self.fs_device.get_volume() + yield from self.fs_device.set_volume(volume+1) - def volume_down(self): + @asyncio.coroutine + def async_volume_down(self): """Send volume down command.""" - self.fs_device.volume -= 1 + volume = yield from self.fs_device.get_volume() + yield from self.fs_device.set_volume(volume-1) - def set_volume_level(self, volume): + @asyncio.coroutine + def async_set_volume_level(self, volume): """Set volume command.""" - self.fs_device.volume = volume + yield from self.fs_device.set_volume(volume) - def select_source(self, source): + @asyncio.coroutine + def async_select_source(self, source): """Select input source.""" - self.fs_device.mode = source + yield from self.fs_device.set_mode(source) diff --git a/homeassistant/components/media_player/hdmi_cec.py b/homeassistant/components/media_player/hdmi_cec.py index e1fffefed18..f5b4cbd4854 100644 --- a/homeassistant/components/media_player/hdmi_cec.py +++ b/homeassistant/components/media_player/hdmi_cec.py @@ -87,7 +87,7 @@ class CecPlayerDevice(CecDevice, MediaPlayerDevice): self.send_keypress(KEY_STOP) self._state = STATE_IDLE - def play_media(self, media_type, media_id): + def play_media(self, media_type, media_id, **kwargs): """Not supported.""" raise NotImplementedError() diff --git a/homeassistant/components/media_player/kodi.py b/homeassistant/components/media_player/kodi.py index 2c428c6b833..d14bf0fadaf 100644 --- a/homeassistant/components/media_player/kodi.py +++ b/homeassistant/components/media_player/kodi.py @@ -86,7 +86,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_TCP_PORT, default=DEFAULT_TCP_PORT): cv.port, vol.Optional(CONF_PROXY_SSL, default=DEFAULT_PROXY_SSL): cv.boolean, - vol.Optional(CONF_TURN_ON_ACTION, default=None): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_TURN_ON_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_TURN_OFF_ACTION): vol.Any(cv.SCRIPT_SCHEMA, vol.In(DEPRECATED_TURN_OFF_ACTIONS)), vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, diff --git a/homeassistant/components/media_player/lg_netcast.py b/homeassistant/components/media_player/lg_netcast.py index e657e1ce80d..edbd6546cca 100644 --- a/homeassistant/components/media_player/lg_netcast.py +++ b/homeassistant/components/media_player/lg_netcast.py @@ -38,7 +38,7 @@ SUPPORT_LGTV = SUPPORT_PAUSE | SUPPORT_VOLUME_STEP | \ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_ACCESS_TOKEN, default=None): + vol.Optional(CONF_ACCESS_TOKEN): vol.All(cv.string, vol.Length(max=6)), }) diff --git a/homeassistant/components/media_player/mpd.py b/homeassistant/components/media_player/mpd.py index 4307b68e709..81a18ab93c5 100644 --- a/homeassistant/components/media_player/mpd.py +++ b/homeassistant/components/media_player/mpd.py @@ -182,8 +182,7 @@ class MpdDevice(MediaPlayerDevice): if name is None and title is None: if file_name is None: return "None" - else: - return os.path.basename(file_name) + return os.path.basename(file_name) elif name is None: return title elif title is None: diff --git a/homeassistant/components/media_player/panasonic_viera.py b/homeassistant/components/media_player/panasonic_viera.py index 21a897f4d35..39e5f81b71d 100644 --- a/homeassistant/components/media_player/panasonic_viera.py +++ b/homeassistant/components/media_player/panasonic_viera.py @@ -18,7 +18,7 @@ from homeassistant.const import ( CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON, STATE_UNKNOWN, CONF_PORT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['panasonic_viera==0.3', +REQUIREMENTS = ['panasonic_viera==0.3.1', 'wakeonlan==1.0.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index 3e5ee57cb2f..7ac250b1d30 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -323,7 +323,6 @@ squeezebox_call_method: yamaha_enable_output: description: Enable or disable an output port - fields: entity_id: description: Name(s) of entites to enable/disable port on. @@ -334,3 +333,34 @@ yamaha_enable_output: enabled: description: Boolean indicating if port should be enabled or not. example: true + +bluesound_join: + description: Group player together. + fields: + master: + description: Entity ID of the player that should become the master of the group. + example: 'media_player.bluesound_livingroom' + entity_id: + description: Name(s) of entities that will coordinate the grouping. Platform dependent. + example: 'media_player.bluesound_livingroom' + +bluesound_unjoin: + description: Unjoin the player from a group. + fields: + entity_id: + description: Name(s) of entities that will be unjoined from their group. Platform dependent. + example: 'media_player.bluesound_livingroom' + +bluesound_set_sleep_timer: + description: "Set a Bluesound timer. It will increase timer in steps: 15, 30, 45, 60, 90, 0" + fields: + entity_id: + description: Name(s) of entities that will have a timer set. + example: 'media_player.bluesound_livingroom' + +bluesound_clear_sleep_timer: + description: Clear a Bluesound timer. + fields: + entity_id: + description: Name(s) of entities that will have the timer cleared. + example: 'media_player.bluesound_livingroom' diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index d4a7fd3adb5..d9236ae9a54 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -10,6 +10,7 @@ import functools as ft import logging import socket import urllib +import threading import voluptuous as vol @@ -25,23 +26,17 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.util.dt import utcnow -REQUIREMENTS = ['SoCo==0.13'] +REQUIREMENTS = ['SoCo==0.14'] _LOGGER = logging.getLogger(__name__) -# The soco library is excessively chatty when it comes to logging and -# causes a LOT of spam in the logs due to making a http connection to each -# speaker every 10 seconds. Quiet it down a bit to just actual problems. -_SOCO_LOGGER = logging.getLogger('soco') -_SOCO_LOGGER.setLevel(logging.ERROR) +# Quiet down soco logging to just actual problems. +logging.getLogger('soco').setLevel(logging.WARNING) _SOCO_SERVICES_LOGGER = logging.getLogger('soco.services') -_REQUESTS_LOGGER = logging.getLogger('requests') -_REQUESTS_LOGGER.setLevel(logging.ERROR) -SUPPORT_SONOS = SUPPORT_STOP | SUPPORT_PAUSE | SUPPORT_VOLUME_SET |\ - SUPPORT_VOLUME_MUTE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK |\ +SUPPORT_SONOS = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE |\ SUPPORT_PLAY_MEDIA | SUPPORT_SEEK | SUPPORT_CLEAR_PLAYLIST |\ - SUPPORT_SELECT_SOURCE | SUPPORT_PLAY | SUPPORT_SHUFFLE_SET + SUPPORT_SELECT_SOURCE | SUPPORT_PLAY | SUPPORT_STOP SERVICE_JOIN = 'sonos_join' SERVICE_UNJOIN = 'sonos_unjoin' @@ -54,8 +49,8 @@ SERVICE_SET_OPTION = 'sonos_set_option' DATA_SONOS = 'sonos' -SUPPORT_SOURCE_LINEIN = 'Line-in' -SUPPORT_SOURCE_TV = 'TV' +SOURCE_LINEIN = 'Line-in' +SOURCE_TV = 'TV' CONF_ADVERTISE_ADDR = 'advertise_addr' CONF_INTERFACE_ADDR = 'interface_addr' @@ -112,12 +107,21 @@ SONOS_SET_OPTION_SCHEMA = SONOS_SCHEMA.extend({ }) +class SonosData: + """Storage class for platform global data.""" + + def __init__(self): + """Initialize the data.""" + self.devices = [] + self.topology_lock = threading.Lock() + + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Sonos platform.""" import soco if DATA_SONOS not in hass.data: - hass.data[DATA_SONOS] = [] + hass.data[DATA_SONOS] = SonosData() advertise_addr = config.get(CONF_ADVERTISE_ADDR, None) if advertise_addr: @@ -127,14 +131,14 @@ def setup_platform(hass, config, add_devices, discovery_info=None): player = soco.SoCo(discovery_info.get('host')) # If device already exists by config - if player.uid in [x.unique_id for x in hass.data[DATA_SONOS]]: + if player.uid in [x.unique_id for x in hass.data[DATA_SONOS].devices]: return if player.is_visible: device = SonosDevice(player) - add_devices([device], True) - hass.data[DATA_SONOS].append(device) - if len(hass.data[DATA_SONOS]) > 1: + hass.data[DATA_SONOS].devices.append(device) + add_devices([device]) + if len(hass.data[DATA_SONOS].devices) > 1: return else: players = None @@ -159,14 +163,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.warning("No Sonos speakers found") return - # Add coordinators first so they can be queried by slaves - coordinators = [SonosDevice(p) for p in players if p.is_coordinator] - slaves = [SonosDevice(p) for p in players if not p.is_coordinator] - hass.data[DATA_SONOS] = coordinators + slaves - if coordinators: - add_devices(coordinators, True) - if slaves: - add_devices(slaves, True) + hass.data[DATA_SONOS].devices = [SonosDevice(p) for p in players] + add_devices(hass.data[DATA_SONOS].devices) _LOGGER.debug("Added %s Sonos speakers", len(players)) def service_handle(service): @@ -174,16 +172,20 @@ def setup_platform(hass, config, add_devices, discovery_info=None): entity_ids = service.data.get('entity_id') if entity_ids: - devices = [device for device in hass.data[DATA_SONOS] + devices = [device for device in hass.data[DATA_SONOS].devices if device.entity_id in entity_ids] else: - devices = hass.data[DATA_SONOS] + devices = hass.data[DATA_SONOS].devices + + if service.service == SERVICE_JOIN: + master = [device for device in hass.data[DATA_SONOS].devices + if device.entity_id == service.data[ATTR_MASTER]] + if master: + master[0].join(devices) + return for device in devices: - if service.service == SERVICE_JOIN: - if device.entity_id != service.data[ATTR_MASTER]: - device.join(service.data[ATTR_MASTER]) - elif service.service == SERVICE_UNJOIN: + if service.service == SERVICE_UNJOIN: device.unjoin() elif service.service == SERVICE_SNAPSHOT: device.snapshot(service.data[ATTR_WITH_GROUP]) @@ -233,35 +235,24 @@ def setup_platform(hass, config, add_devices, discovery_info=None): schema=SONOS_SET_OPTION_SCHEMA) -def _parse_timespan(timespan): - """Parse a time-span into number of seconds.""" - if timespan in ('', 'NOT_IMPLEMENTED', None): - return None - - return sum(60 ** x[0] * int(x[1]) for x in enumerate( - reversed(timespan.split(':')))) - - -class _ProcessSonosEventQueue(object): +class _ProcessSonosEventQueue: """Queue like object for dispatching sonos events.""" - def __init__(self, sonos_device): + def __init__(self, handler): """Initialize Sonos event queue.""" - self._sonos_device = sonos_device + self._handler = handler def put(self, item, block=True, timeout=None): - """Queue up event for processing.""" - # Instead of putting events on a queue, dispatch them to the event - # processing method. - self._sonos_device.process_sonos_event(item) + """Process event.""" + self._handler(item) -def _get_entity_from_soco(hass, soco): - """Return SonosDevice from SoCo.""" - for device in hass.data[DATA_SONOS]: - if soco == device.soco: - return device - raise ValueError("No entity for SoCo device") +def _get_entity_from_soco_uid(hass, uid): + """Return SonosDevice from SoCo uid.""" + for entity in hass.data[DATA_SONOS].devices: + if uid == entity.soco.uid: + return entity + return None def soco_error(errorcodes=None): @@ -280,7 +271,7 @@ def soco_error(errorcodes=None): try: return funct(*args, **kwargs) except SoCoUPnPException as err: - if err.error_code in errorcodes: + if errorcodes and err.error_code in errorcodes: pass else: _LOGGER.error("Error on %s with %s", funct.__name__, err) @@ -305,21 +296,37 @@ def soco_coordinator(funct): return wrapper +def _timespan_secs(timespan): + """Parse a time-span into number of seconds.""" + if timespan in ('', 'NOT_IMPLEMENTED', None): + return None + + return sum(60 ** x[0] * int(x[1]) for x in enumerate( + reversed(timespan.split(':')))) + + +def _is_radio_uri(uri): + """Return whether the URI is a radio stream.""" + return uri.startswith('x-rincon-mp3radio:') or \ + uri.startswith('x-sonosapi-stream:') + + class SonosDevice(MediaPlayerDevice): """Representation of a Sonos device.""" def __init__(self, player): """Initialize the Sonos device.""" - self.volume_increment = 5 + self._volume_increment = 5 self._unique_id = player.uid self._player = player + self._model = None self._player_volume = None self._player_volume_muted = None - self._speaker_info = None + self._play_mode = None self._name = None - self._status = None self._coordinator = None - self._media_content_id = None + self._status = None + self._extra_features = 0 self._media_duration = None self._media_position = None self._media_position_updated_at = None @@ -327,37 +334,21 @@ class SonosDevice(MediaPlayerDevice): self._media_artist = None self._media_album_name = None self._media_title = None - self._media_radio_show = None - self._available = True - self._support_previous_track = False - self._support_next_track = False - self._support_play = False - self._support_shuffle_set = True - self._support_stop = False - self._support_pause = False self._night_sound = None self._speech_enhance = None - self._current_track_uri = None - self._current_track_is_radio_stream = False - self._queue = None - self._last_avtransport_event = None - self._is_playing_line_in = None - self._is_playing_tv = None - self._favorite_sources = None self._source_name = None + self._available = True + self._favorites = None self._soco_snapshot = None self._snapshot_group = None + self._set_basic_information() + @asyncio.coroutine def async_added_to_hass(self): """Subscribe sonos events.""" self.hass.async_add_job(self._subscribe_to_player_events) - @property - def should_poll(self): - """Return the polling state.""" - return True - @property def unique_id(self): """Return an unique ID.""" @@ -369,10 +360,9 @@ class SonosDevice(MediaPlayerDevice): return self._name @property + @soco_coordinator def state(self): """Return the state of the device.""" - if self._coordinator: - return self._coordinator.state if self._status in ('PAUSED_PLAYBACK', 'STOPPED'): return STATE_PAUSED if self._status in ('PLAYING', 'TRANSITIONING'): @@ -401,260 +391,285 @@ class SonosDevice(MediaPlayerDevice): """Return True if entity is available.""" return self._available - def _is_available(self): + def _check_available(self): + """Check that we can still connect to the player.""" try: sock = socket.create_connection( - address=(self._player.ip_address, 1443), timeout=3) + address=(self.soco.ip_address, 1443), timeout=3) sock.close() return True except socket.error: return False - # pylint: disable=invalid-name + def _set_basic_information(self): + """Set initial device information.""" + speaker_info = self.soco.get_speaker_info(True) + self._name = speaker_info['zone_name'] + self._model = speaker_info['model_name'] + self._player_volume = self.soco.volume + self._player_volume_muted = self.soco.mute + self._play_mode = self.soco.play_mode + self._night_sound = self.soco.night_mode + self._speech_enhance = self.soco.dialog_mode + self._favorites = self.soco.music_library.get_sonos_favorites() + def _subscribe_to_player_events(self): - if self._queue is None: - self._queue = _ProcessSonosEventQueue(self) - self._player.avTransport.subscribe( - auto_renew=True, - event_queue=self._queue) - self._player.renderingControl.subscribe( - auto_renew=True, - event_queue=self._queue) + """Add event subscriptions.""" + player = self.soco + + queue = _ProcessSonosEventQueue(self.process_avtransport_event) + player.avTransport.subscribe(auto_renew=True, event_queue=queue) + + queue = _ProcessSonosEventQueue(self.process_rendering_event) + player.renderingControl.subscribe(auto_renew=True, event_queue=queue) + + queue = _ProcessSonosEventQueue(self.process_zonegrouptopology_event) + player.zoneGroupTopology.subscribe(auto_renew=True, event_queue=queue) def update(self): """Retrieve latest state.""" - if self._speaker_info is None: - self._speaker_info = self._player.get_speaker_info(True) - self._name = self._speaker_info['zone_name'].replace( - ' (R)', '').replace(' (L)', '') - self._favorite_sources = \ - self._player.get_sonos_favorites()['favorites'] - - if self._last_avtransport_event: - self._available = True - else: - self._available = self._is_available() - - if not self._available: - self._player_volume = None - self._player_volume_muted = None - self._status = 'OFF' - self._coordinator = None - self._media_content_id = None - self._media_duration = None - self._media_position = None - self._media_position_updated_at = None - self._media_image_url = None - self._media_artist = None - self._media_album_name = None - self._media_title = None - self._media_radio_show = None - self._current_track_uri = None - self._current_track_is_radio_stream = False - self._support_previous_track = False - self._support_next_track = False - self._support_play = False - self._support_shuffle_set = False - self._support_stop = False - self._support_pause = False - self._night_sound = None - self._speech_enhance = None - self._is_playing_tv = False - self._is_playing_line_in = False - self._source_name = None - self._last_avtransport_event = None - return - - # set group coordinator - if self._player.is_coordinator: - self._coordinator = None - else: - try: - self._coordinator = _get_entity_from_soco( - self.hass, self._player.group.coordinator) - - # protect for loop - if not self._coordinator.is_coordinator: - # pylint: disable=protected-access - self._coordinator._coordinator = None - except ValueError: + available = self._check_available() + if self._available != available: + self._available = available + if available: + self._set_basic_information() + self._subscribe_to_player_events() + else: + self._player_volume = None + self._player_volume_muted = None + self._status = 'OFF' self._coordinator = None + self._media_duration = None + self._media_position = None + self._media_position_updated_at = None + self._media_image_url = None + self._media_artist = None + self._media_album_name = None + self._media_title = None + self._extra_features = 0 + self._source_name = None - track_info = None - if self._last_avtransport_event: - variables = self._last_avtransport_event.variables - current_track_metadata = variables.get( - 'current_track_meta_data', {} - ) + def process_avtransport_event(self, event): + """Process a track change event coming from a coordinator.""" + variables = event.variables - self._status = variables.get('transport_state') - - if current_track_metadata: - # no need to ask speaker for information we already have - current_track_metadata = current_track_metadata.__dict__ - - track_info = { - 'uri': variables.get('current_track_uri'), - 'artist': current_track_metadata.get('creator'), - 'album': current_track_metadata.get('album'), - 'title': current_track_metadata.get('title'), - 'playlist_position': variables.get('current_track'), - 'duration': variables.get('current_track_duration') - } - else: - self._player_volume = self._player.volume - self._player_volume_muted = self._player.mute - transport_info = self._player.get_current_transport_info() - self._status = transport_info.get('current_transport_state') - - if not track_info: - track_info = self._player.get_current_track_info() - - if self._coordinator: - self._last_avtransport_event = None + # Ignore transitions, we should get the target state soon + new_status = variables.get('transport_state') + if new_status == 'TRANSITIONING': return - is_playing_tv = self._player.is_playing_tv - is_playing_line_in = self._player.is_playing_line_in - - media_info = self._player.avTransport.GetMediaInfo( - [('InstanceID', 0)] - ) - - current_media_uri = media_info['CurrentURI'] - media_artist = track_info.get('artist') - media_album_name = track_info.get('album') - media_title = track_info.get('title') - media_image_url = track_info.get('album_art', None) - - media_position = None - media_position_updated_at = None - source_name = None - - night_sound = self._player.night_mode - speech_enhance = self._player.dialog_mode - - is_radio_stream = \ - current_media_uri.startswith('x-sonosapi-stream:') or \ - current_media_uri.startswith('x-rincon-mp3radio:') - - if is_playing_tv or is_playing_line_in: - # playing from line-in/tv. - - support_previous_track = False - support_next_track = False - support_play = False - support_stop = True - support_pause = False - support_shuffle_set = False - - if is_playing_tv: - media_artist = SUPPORT_SOURCE_TV - else: - media_artist = SUPPORT_SOURCE_LINEIN - - source_name = media_artist - - media_album_name = None - media_title = None - media_image_url = None - - elif is_radio_stream: - media_image_url = self._format_media_image_url( - media_image_url, - current_media_uri - ) - support_previous_track = False - support_next_track = False - support_play = True - support_stop = True - support_pause = False - support_shuffle_set = False - - source_name = 'Radio' - # Check if currently playing radio station is in favorites - favc = [fav for fav in self._favorite_sources - if fav['uri'] == current_media_uri] - if len(favc) == 1: - src = favc.pop() - source_name = src['title'] - - # for radio streams we set the radio station name as the - # title. - if media_artist and media_title: - # artist and album name are in the data, concatenate - # that do display as artist. - # "Information" field in the sonos pc app - - media_artist = '{artist} - {title}'.format( - artist=media_artist, - title=media_title - ) - else: - # "On Now" field in the sonos pc app - media_artist = self._media_radio_show - - current_uri_metadata = media_info["CurrentURIMetaData"] - if current_uri_metadata not in ('', 'NOT_IMPLEMENTED', None): - - # currently soco does not have an API for this - import soco - current_uri_metadata = soco.xml.XML.fromstring( - soco.utils.really_utf8(current_uri_metadata)) - - md_title = current_uri_metadata.findtext( - './/{http://purl.org/dc/elements/1.1/}title') - - if md_title not in ('', 'NOT_IMPLEMENTED', None): - media_title = md_title - - if media_artist and media_title: - # some radio stations put their name into the artist - # name, e.g.: - # media_title = "Station" - # media_artist = "Station - Artist - Title" - # detect this case and trim from the front of - # media_artist for cosmetics - str_to_trim = '{title} - '.format( - title=media_title - ) - chars = min(len(media_artist), len(str_to_trim)) - - if media_artist[:chars].upper() == str_to_trim[:chars].upper(): - media_artist = media_artist[chars:] + self._play_mode = variables.get('current_play_mode', self._play_mode) + if self.soco.is_playing_tv: + self._refresh_linein(SOURCE_TV) + elif self.soco.is_playing_line_in: + self._refresh_linein(SOURCE_LINEIN) else: - # not a radio stream - media_image_url = self._format_media_image_url( - media_image_url, - track_info['uri'] - ) - support_previous_track = True - support_next_track = True - support_play = True - support_stop = True - support_pause = True - support_shuffle_set = True + track_info = self.soco.get_current_track_info() - position_info = self._player.avTransport.GetPositionInfo( - [('InstanceID', 0), - ('Channel', 'Master')] - ) - rel_time = _parse_timespan( - position_info.get("RelTime") + media_info = self.soco.avTransport.GetMediaInfo( + [('InstanceID', 0)] ) - # player no longer reports position? - update_media_position = rel_time is None and \ - self._media_position is not None + if _is_radio_uri(track_info['uri']): + self._refresh_radio(variables, media_info, track_info) + else: + self._refresh_music(variables, media_info, track_info) - # player started reporting position? - update_media_position |= rel_time is not None and \ - self._media_position is None + if new_status: + self._status = new_status - # position changed? + self.schedule_update_ha_state() + + # Also update slaves + for entity in self.hass.data[DATA_SONOS].devices: + coordinator = entity.coordinator + if coordinator and coordinator.unique_id == self.unique_id: + entity.schedule_update_ha_state() + + def process_rendering_event(self, event): + """Process a volume change event coming from a player.""" + variables = event.variables + + if 'volume' in variables: + self._player_volume = int(variables['volume']['Master']) + + if 'mute' in variables: + self._player_volume_muted = (variables['mute']['Master'] == '1') + + if 'night_mode' in variables: + self._night_sound = (variables['night_mode'] == '1') + + if 'dialog_level' in variables: + self._speech_enhance = (variables['dialog_level'] == '1') + + self.schedule_update_ha_state() + + def process_zonegrouptopology_event(self, event): + """Process a zone group topology event coming from a player.""" + if not hasattr(event, 'zone_player_uui_ds_in_group'): + return + + with self.hass.data[DATA_SONOS].topology_lock: + group = event.zone_player_uui_ds_in_group + if group: + # New group information is pushed + coordinator_uid, *slave_uids = group.split(',') + else: + # Use SoCo cache for existing topology + coordinator_uid = self.soco.group.coordinator.uid + slave_uids = [p.uid for p in self.soco.group.members + if p.uid != coordinator_uid] + + if self.unique_id == coordinator_uid: + self._coordinator = None + self.schedule_update_ha_state() + + for slave_uid in slave_uids: + slave = _get_entity_from_soco_uid(self.hass, slave_uid) + if slave: + # pylint: disable=protected-access + slave._coordinator = self + slave.schedule_update_ha_state() + + def _radio_artwork(self, url): + """Return the private URL with artwork for a radio stream.""" + if url not in ('', 'NOT_IMPLEMENTED', None): + if url.find('tts_proxy') > 0: + # If the content is a tts don't try to fetch an image from it. + return None + url = 'http://{host}:{port}/getaa?s=1&u={uri}'.format( + host=self.soco.ip_address, + port=1400, + uri=urllib.parse.quote(url, safe='') + ) + return url + + def _refresh_linein(self, source): + """Update state when playing from line-in/tv.""" + self._extra_features = 0 + + self._media_duration = None + self._media_position = None + self._media_position_updated_at = None + + self._media_image_url = None + + self._media_artist = source + self._media_album_name = None + self._media_title = None + + self._source_name = source + + def _refresh_radio(self, variables, media_info, track_info): + """Update state when streaming radio.""" + self._extra_features = 0 + + self._media_duration = None + self._media_position = None + self._media_position_updated_at = None + + self._media_image_url = self._radio_artwork(media_info['CurrentURI']) + + self._media_artist = track_info.get('artist') + self._media_album_name = None + self._media_title = track_info.get('title') + + if self._media_artist and self._media_title: + # artist and album name are in the data, concatenate + # that do display as artist. + # "Information" field in the sonos pc app + self._media_artist = '{artist} - {title}'.format( + artist=self._media_artist, + title=self._media_title + ) + else: + # "On Now" field in the sonos pc app + current_track_metadata = variables.get( + 'current_track_meta_data' + ) + if current_track_metadata: + self._media_artist = \ + current_track_metadata.radio_show.split(',')[0] + + # For radio streams we set the radio station name as the title. + current_uri_metadata = media_info["CurrentURIMetaData"] + if current_uri_metadata not in ('', 'NOT_IMPLEMENTED', None): + # currently soco does not have an API for this + import soco + current_uri_metadata = soco.xml.XML.fromstring( + soco.utils.really_utf8(current_uri_metadata)) + + md_title = current_uri_metadata.findtext( + './/{http://purl.org/dc/elements/1.1/}title') + + if md_title not in ('', 'NOT_IMPLEMENTED', None): + self._media_title = md_title + + if self._media_artist and self._media_title: + # some radio stations put their name into the artist + # name, e.g.: + # media_title = "Station" + # media_artist = "Station - Artist - Title" + # detect this case and trim from the front of + # media_artist for cosmetics + trim = '{title} - '.format(title=self._media_title) + chars = min(len(self._media_artist), len(trim)) + + if self._media_artist[:chars].upper() == trim[:chars].upper(): + self._media_artist = self._media_artist[chars:] + + # Check if currently playing radio station is in favorites + self._source_name = None + for fav in self._favorites: + if fav.reference.get_uri() == media_info['CurrentURI']: + self._source_name = fav.title + + def _refresh_music(self, variables, media_info, track_info): + """Update state when playing music tracks.""" + self._extra_features = SUPPORT_PAUSE | SUPPORT_SHUFFLE_SET |\ + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK + + playlist_position = track_info.get('playlist_position') + if playlist_position in ('', 'NOT_IMPLEMENTED', None): + playlist_position = None + else: + playlist_position = int(playlist_position) + + playlist_size = media_info.get('NrTracks') + if playlist_size in ('', 'NOT_IMPLEMENTED', None): + playlist_size = None + else: + playlist_size = int(playlist_size) + + if playlist_position is not None and playlist_size is not None: + if playlist_position <= 1: + self._extra_features &= ~SUPPORT_PREVIOUS_TRACK + + if playlist_position == playlist_size: + self._extra_features &= ~SUPPORT_NEXT_TRACK + + self._media_duration = _timespan_secs(track_info.get('duration')) + + position_info = self.soco.avTransport.GetPositionInfo( + [('InstanceID', 0), + ('Channel', 'Master')] + ) + rel_time = _timespan_secs(position_info.get("RelTime")) + + # player no longer reports position? + update_media_position = rel_time is None and \ + self._media_position is not None + + # player started reporting position? + update_media_position |= rel_time is not None and \ + self._media_position is None + + if self._status != variables.get('transport_state'): + update_media_position = True + else: + # position jumped? if rel_time is not None and self._media_position is not None: - time_diff = utcnow() - self._media_position_updated_at time_diff = time_diff.total_seconds() @@ -663,115 +678,22 @@ class SonosDevice(MediaPlayerDevice): update_media_position = \ abs(calculated_position - rel_time) > 1.5 - if update_media_position and self.state == STATE_PLAYING: - media_position = rel_time - media_position_updated_at = utcnow() - else: - # don't update media_position (don't want unneeded - # state transitions) - media_position = self._media_position - media_position_updated_at = self._media_position_updated_at + if update_media_position: + self._media_position = rel_time + self._media_position_updated_at = utcnow() - playlist_position = track_info.get('playlist_position') - if playlist_position in ('', 'NOT_IMPLEMENTED', None): - playlist_position = None - else: - playlist_position = int(playlist_position) + self._media_image_url = track_info.get('album_art') - playlist_size = media_info.get('NrTracks') - if playlist_size in ('', 'NOT_IMPLEMENTED', None): - playlist_size = None - else: - playlist_size = int(playlist_size) + self._media_artist = track_info.get('artist') + self._media_album_name = track_info.get('album') + self._media_title = track_info.get('title') - if playlist_position is not None and playlist_size is not None: - - if playlist_position <= 1: - support_previous_track = False - - if playlist_position == playlist_size: - support_next_track = False - - self._media_content_id = track_info.get('title') - self._media_duration = _parse_timespan( - track_info.get('duration') - ) - self._media_position = media_position - self._media_position_updated_at = media_position_updated_at - self._media_image_url = media_image_url - self._media_artist = media_artist - self._media_album_name = media_album_name - self._media_title = media_title - self._current_track_uri = track_info['uri'] - self._current_track_is_radio_stream = is_radio_stream - self._support_previous_track = support_previous_track - self._support_next_track = support_next_track - self._support_play = support_play - self._support_shuffle_set = support_shuffle_set - self._support_stop = support_stop - self._support_pause = support_pause - self._night_sound = night_sound - self._speech_enhance = speech_enhance - self._is_playing_tv = is_playing_tv - self._is_playing_line_in = is_playing_line_in - self._source_name = source_name - self._last_avtransport_event = None - - def _format_media_image_url(self, url, fallback_uri): - if url in ('', 'NOT_IMPLEMENTED', None): - if fallback_uri in ('', 'NOT_IMPLEMENTED', None): - return None - if fallback_uri.find('tts_proxy') > 0: - # If the content is a tts don't try to fetch an image from it. - return None - return 'http://{host}:{port}/getaa?s=1&u={uri}'.format( - host=self._player.ip_address, - port=1400, - uri=urllib.parse.quote(fallback_uri) - ) - return url - - def process_sonos_event(self, event): - """Process a service event coming from the speaker.""" - next_track_image_url = None - if event.service == self._player.avTransport: - self._last_avtransport_event = event - - self._media_radio_show = None - if self._current_track_is_radio_stream: - current_track_metadata = event.variables.get( - 'current_track_meta_data' - ) - if current_track_metadata: - self._media_radio_show = \ - current_track_metadata.radio_show.split(',')[0] - - next_track_uri = event.variables.get('next_track_uri') - if next_track_uri: - next_track_image_url = self._format_media_image_url( - None, - next_track_uri - ) - - elif event.service == self._player.renderingControl: - if 'volume' in event.variables: - self._player_volume = int( - event.variables['volume'].get('Master') - ) - - if 'mute' in event.variables: - self._player_volume_muted = \ - event.variables['mute'].get('Master') == '1' - - self.schedule_update_ha_state(True) - - if next_track_image_url: - self.preload_media_image_url(next_track_image_url) + self._source_name = None @property def volume_level(self): """Volume level of the media player (0..1).""" - return self._player_volume / 100.0 + return self._player_volume / 100 @property def is_volume_muted(self): @@ -779,17 +701,10 @@ class SonosDevice(MediaPlayerDevice): return self._player_volume_muted @property + @soco_coordinator def shuffle(self): """Shuffling state.""" - return True if self._player.play_mode == 'SHUFFLE' else False - - @property - def media_content_id(self): - """Content ID of current playing media.""" - if self._coordinator: - return self._coordinator.media_content_id - - return self._media_content_id + return 'SHUFFLE' in self._play_mode @property def media_content_type(self): @@ -797,260 +712,170 @@ class SonosDevice(MediaPlayerDevice): return MEDIA_TYPE_MUSIC @property + @soco_coordinator def media_duration(self): """Duration of current playing media in seconds.""" - if self._coordinator: - return self._coordinator.media_duration - return self._media_duration @property + @soco_coordinator def media_position(self): """Position of current playing media in seconds.""" - if self._coordinator: - return self._coordinator.media_position - return self._media_position @property + @soco_coordinator def media_position_updated_at(self): - """When was the position of the current playing media valid. - - Returns value from homeassistant.util.dt.utcnow(). - """ - if self._coordinator: - return self._coordinator.media_position_updated_at - + """When was the position of the current playing media valid.""" return self._media_position_updated_at @property + @soco_coordinator def media_image_url(self): """Image url of current playing media.""" - if self._coordinator: - return self._coordinator.media_image_url - - return self._media_image_url + return self._media_image_url or None @property + @soco_coordinator def media_artist(self): """Artist of current playing media, music track only.""" - if self._coordinator: - return self._coordinator.media_artist - return self._media_artist @property + @soco_coordinator def media_album_name(self): """Album name of current playing media, music track only.""" - if self._coordinator: - return self._coordinator.media_album_name - return self._media_album_name @property + @soco_coordinator def media_title(self): """Title of current playing media.""" - if self._coordinator: - return self._coordinator.media_title - return self._media_title @property - def night_sound(self): - """Get status of Night Sound.""" - return self._night_sound - - @property - def speech_enhance(self): - """Get status of Speech Enhancement.""" - return self._speech_enhance + @soco_coordinator + def source(self): + """Name of the current input source.""" + return self._source_name @property + @soco_coordinator def supported_features(self): """Flag media player features that are supported.""" - if self._coordinator: - return self._coordinator.supported_features - - supported = SUPPORT_SONOS - - if not self._support_previous_track: - supported = supported ^ SUPPORT_PREVIOUS_TRACK - - if not self._support_next_track: - supported = supported ^ SUPPORT_NEXT_TRACK - - if not self._support_play: - supported = supported ^ SUPPORT_PLAY - if not self._support_shuffle_set: - supported = supported ^ SUPPORT_SHUFFLE_SET - if not self._support_stop: - supported = supported ^ SUPPORT_STOP - - if not self._support_pause: - supported = supported ^ SUPPORT_PAUSE - - return supported + return SUPPORT_SONOS | self._extra_features @soco_error() def volume_up(self): """Volume up media player.""" - self._player.volume += self.volume_increment + self._player.volume += self._volume_increment @soco_error() def volume_down(self): """Volume down media player.""" - self._player.volume -= self.volume_increment + self._player.volume -= self._volume_increment @soco_error() def set_volume_level(self, volume): """Set volume level, range 0..1.""" - self._player.volume = str(int(volume * 100)) + self.soco.volume = str(int(volume * 100)) @soco_error() + @soco_coordinator def set_shuffle(self, shuffle): """Enable/Disable shuffle mode.""" - self._player.play_mode = 'SHUFFLE' if shuffle else 'NORMAL' + self.soco.play_mode = 'SHUFFLE_NOREPEAT' if shuffle else 'NORMAL' @soco_error() def mute_volume(self, mute): """Mute (true) or unmute (false) media player.""" - self._player.mute = mute + self.soco.mute = mute @soco_error() @soco_coordinator def select_source(self, source): """Select input source.""" - if source == SUPPORT_SOURCE_LINEIN: - self._source_name = SUPPORT_SOURCE_LINEIN - self._player.switch_to_line_in() - elif source == SUPPORT_SOURCE_TV: - self._source_name = SUPPORT_SOURCE_TV - self._player.switch_to_tv() + if source == SOURCE_LINEIN: + self.soco.switch_to_line_in() + elif source == SOURCE_TV: + self.soco.switch_to_tv() else: - fav = [fav for fav in self._favorite_sources - if fav['title'] == source] + fav = [fav for fav in self._favorites + if fav.title == source] if len(fav) == 1: src = fav.pop() - self._source_name = src['title'] - - if ('object.container.playlistContainer' in src['meta'] or - 'object.container.album.musicAlbum' in src['meta']): - self._replace_queue_with_playlist(src) - self._player.play_from_queue(0) + uri = src.reference.get_uri() + if _is_radio_uri(uri): + self.soco.play_uri(uri, title=source) else: - self._player.play_uri(src['uri'], src['meta'], - src['title']) - - def _replace_queue_with_playlist(self, src): - """Replace queue with playlist represented by src. - - Playlists can't be played directly with the self._player.play_uri - API as they are actually composed of multiple URLs. Until soco has - support for playing a playlist, we'll need to parse the playlist item - and replace the current queue in order to play it. - """ - import soco - import xml.etree.ElementTree as ET - - root = ET.fromstring(src['meta']) - namespaces = {'item': - 'urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/', - 'desc': 'urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/'} - desc = root.find('item:item', namespaces).find('desc:desc', - namespaces).text - - res = [soco.data_structures.DidlResource(uri=src['uri'], - protocol_info="DUMMY")] - didl = soco.data_structures.DidlItem(title="DUMMY", - parent_id="DUMMY", - item_id=src['uri'], - desc=desc, - resources=res) - - self._player.stop() - self._player.clear_queue() - self._player.add_to_queue(didl) + self.soco.clear_queue() + self.soco.add_to_queue(src.reference) + self.soco.play_from_queue(0) @property + @soco_coordinator def source_list(self): """List of available input sources.""" - if self._coordinator: - return self._coordinator.source_list + sources = [fav.title for fav in self._favorites] - model_name = self._speaker_info['model_name'] - sources = [] + if 'PLAY:5' in self._model or 'CONNECT' in self._model: + sources += [SOURCE_LINEIN] + elif 'PLAYBAR' in self._model: + sources += [SOURCE_LINEIN, SOURCE_TV] - if self._favorite_sources: - for fav in self._favorite_sources: - sources.append(fav['title']) - - if 'PLAY:5' in model_name: - sources += [SUPPORT_SOURCE_LINEIN] - elif 'PLAYBAR' in model_name: - sources += [SUPPORT_SOURCE_LINEIN, SUPPORT_SOURCE_TV] return sources - @property - def source(self): - """Name of the current input source.""" - if self._coordinator: - return self._coordinator.source - - return self._source_name + @soco_error() + def turn_on(self): + """Turn the media player on.""" + self.media_play() @soco_error() def turn_off(self): """Turn off media player.""" - if self._support_stop: - self.media_stop() + self.media_stop() @soco_error(UPNP_ERRORS_TO_IGNORE) @soco_coordinator def media_play(self): """Send play command.""" - self._player.play() + self.soco.play() @soco_error(UPNP_ERRORS_TO_IGNORE) @soco_coordinator def media_stop(self): """Send stop command.""" - self._player.stop() + self.soco.stop() @soco_error(UPNP_ERRORS_TO_IGNORE) @soco_coordinator def media_pause(self): """Send pause command.""" - self._player.pause() + self.soco.pause() @soco_error() @soco_coordinator def media_next_track(self): """Send next track command.""" - self._player.next() + self.soco.next() @soco_error() @soco_coordinator def media_previous_track(self): """Send next track command.""" - self._player.previous() + self.soco.previous() @soco_error() @soco_coordinator def media_seek(self, position): """Send seek command.""" - self._player.seek(str(datetime.timedelta(seconds=int(position)))) + self.soco.seek(str(datetime.timedelta(seconds=int(position)))) @soco_error() @soco_coordinator def clear_playlist(self): """Clear players playlist.""" - self._player.clear_queue() - - @soco_error() - def turn_on(self): - """Turn the media player on.""" - if self.support_play: - self.media_play() + self.soco.clear_queue() @soco_error() @soco_coordinator @@ -1063,45 +888,38 @@ class SonosDevice(MediaPlayerDevice): if kwargs.get(ATTR_MEDIA_ENQUEUE): from soco.exceptions import SoCoUPnPException try: - self._player.add_uri_to_queue(media_id) + self.soco.add_uri_to_queue(media_id) except SoCoUPnPException: _LOGGER.error('Error parsing media uri "%s", ' "please check it's a valid media resource " 'supported by Sonos', media_id) else: - self._player.play_uri(media_id) + self.soco.play_uri(media_id) @soco_error() - def join(self, master): - """Join the player to a group.""" - coord = [device for device in self.hass.data[DATA_SONOS] - if device.entity_id == master] + def join(self, slaves): + """Form a group with other players.""" + if self._coordinator: + self.soco.unjoin() - if coord and master != self.entity_id: - coord = coord[0] - if coord.soco.group.coordinator != coord.soco: - coord.soco.unjoin() - self._player.join(coord.soco) - self._coordinator = coord - else: - _LOGGER.error("Master not found %s", master) + for slave in slaves: + slave.soco.join(self.soco) @soco_error() def unjoin(self): """Unjoin the player from a group.""" - self._player.unjoin() - self._coordinator = None + self.soco.unjoin() @soco_error() def snapshot(self, with_group=True): """Snapshot the player.""" from soco.snapshot import Snapshot - self._soco_snapshot = Snapshot(self._player) + self._soco_snapshot = Snapshot(self.soco) self._soco_snapshot.snapshot() if with_group: - self._snapshot_group = self._player.group + self._snapshot_group = self.soco.group if self._coordinator: self._coordinator.snapshot(False) else: @@ -1121,12 +939,12 @@ class SonosDevice(MediaPlayerDevice): # restore groups if with_group and self._snapshot_group: old = self._snapshot_group - actual = self._player.group + actual = self.soco.group ## # Master have not change, update group if old.coordinator == actual.coordinator: - if self._player is not old.coordinator: + if self.soco is not old.coordinator: # restore state of the groups self._coordinator.restore(False) remove = actual.members - old.members @@ -1144,13 +962,14 @@ class SonosDevice(MediaPlayerDevice): ## # old is already master, rejoin if old.coordinator.group.coordinator == old.coordinator: - self._player.join(old.coordinator) + self.soco.join(old.coordinator) return ## # restore old master, update group old.coordinator.unjoin() - coordinator = _get_entity_from_soco(self.hass, old.coordinator) + coordinator = _get_entity_from_soco_uid( + self.hass, old.coordinator.uid) coordinator.restore(False) for s_dev in list(old.members): @@ -1161,45 +980,45 @@ class SonosDevice(MediaPlayerDevice): @soco_coordinator def set_sleep_timer(self, sleep_time): """Set the timer on the player.""" - self._player.set_sleep_timer(sleep_time) + self.soco.set_sleep_timer(sleep_time) @soco_error() @soco_coordinator def clear_sleep_timer(self): """Clear the timer on the player.""" - self._player.set_sleep_timer(None) + self.soco.set_sleep_timer(None) @soco_error() @soco_coordinator def update_alarm(self, **data): """Set the alarm clock on the player.""" from soco import alarms - a = None - for alarm in alarms.get_alarms(self.soco): + alarm = None + for one_alarm in alarms.get_alarms(self.soco): # pylint: disable=protected-access - if alarm._alarm_id == str(data[ATTR_ALARM_ID]): - a = alarm - if a is None: + if one_alarm._alarm_id == str(data[ATTR_ALARM_ID]): + alarm = one_alarm + if alarm is None: _LOGGER.warning("did not find alarm with id %s", data[ATTR_ALARM_ID]) return if ATTR_TIME in data: - a.start_time = data[ATTR_TIME] + alarm.start_time = data[ATTR_TIME] if ATTR_VOLUME in data: - a.volume = int(data[ATTR_VOLUME] * 100) + alarm.volume = int(data[ATTR_VOLUME] * 100) if ATTR_ENABLED in data: - a.enabled = data[ATTR_ENABLED] + alarm.enabled = data[ATTR_ENABLED] if ATTR_INCLUDE_LINKED_ZONES in data: - a.include_linked_zones = data[ATTR_INCLUDE_LINKED_ZONES] - a.save() + alarm.include_linked_zones = data[ATTR_INCLUDE_LINKED_ZONES] + alarm.save() @soco_error() def update_option(self, **data): """Modify playback options.""" - if ATTR_NIGHT_SOUND in data and self.night_sound is not None: + if ATTR_NIGHT_SOUND in data and self._night_sound is not None: self.soco.night_mode = data[ATTR_NIGHT_SOUND] - if ATTR_SPEECH_ENHANCE in data and self.speech_enhance is not None: + if ATTR_SPEECH_ENHANCE in data and self._speech_enhance is not None: self.soco.dialog_mode = data[ATTR_SPEECH_ENHANCE] @property @@ -1207,10 +1026,10 @@ class SonosDevice(MediaPlayerDevice): """Return device specific state attributes.""" attributes = {ATTR_IS_COORDINATOR: self.is_coordinator} - if self.night_sound is not None: - attributes[ATTR_NIGHT_SOUND] = self.night_sound + if self._night_sound is not None: + attributes[ATTR_NIGHT_SOUND] = self._night_sound - if self.speech_enhance is not None: - attributes[ATTR_SPEECH_ENHANCE] = self.speech_enhance + if self._speech_enhance is not None: + attributes[ATTR_SPEECH_ENHANCE] = self._speech_enhance return attributes diff --git a/homeassistant/components/media_player/soundtouch.py b/homeassistant/components/media_player/soundtouch.py index e4c3fa623c9..9c4a0e9fa17 100644 --- a/homeassistant/components/media_player/soundtouch.py +++ b/homeassistant/components/media_player/soundtouch.py @@ -296,7 +296,7 @@ class SoundTouchDevice(MediaPlayerDevice): def play_media(self, media_type, media_id, **kwargs): """Play a piece of media.""" - _LOGGER.debug("Starting media with media_id: " + str(media_id)) + _LOGGER.debug("Starting media with media_id: %s", media_id) if re.match(r'http://', str(media_id)): # URL _LOGGER.debug("Playing URL %s", str(media_id)) @@ -307,11 +307,10 @@ class SoundTouchDevice(MediaPlayerDevice): preset = next([preset for preset in presets if preset.preset_id == str(media_id)].__iter__(), None) if preset is not None: - _LOGGER.debug("Playing preset: " + preset.name) + _LOGGER.debug("Playing preset: %s", preset.name) self._device.select_preset(preset) else: - _LOGGER.warning( - "Unable to find preset with id " + str(media_id)) + _LOGGER.warning("Unable to find preset with id %s", media_id) def create_zone(self, slaves): """ @@ -323,8 +322,8 @@ class SoundTouchDevice(MediaPlayerDevice): if not slaves: _LOGGER.warning("Unable to create zone without slaves") else: - _LOGGER.info( - "Creating zone with master " + str(self.device.config.name)) + _LOGGER.info("Creating zone with master %s", + self.device.config.name) self.device.create_zone([slave.device for slave in slaves]) def remove_zone_slave(self, slaves): @@ -341,8 +340,8 @@ class SoundTouchDevice(MediaPlayerDevice): if not slaves: _LOGGER.warning("Unable to find slaves to remove") else: - _LOGGER.info("Removing slaves from zone with master " + - str(self.device.config.name)) + _LOGGER.info("Removing slaves from zone with master %s", + self.device.config.name) self.device.remove_zone_slave([slave.device for slave in slaves]) def add_zone_slave(self, slaves): @@ -357,7 +356,6 @@ class SoundTouchDevice(MediaPlayerDevice): if not slaves: _LOGGER.warning("Unable to find slaves to add") else: - _LOGGER.info( - "Adding slaves to zone with master " + str( - self.device.config.name)) + _LOGGER.info("Adding slaves to zone with master %s", + self.device.config.name) self.device.add_zone_slave([slave.device for slave in slaves]) diff --git a/homeassistant/components/media_player/webostv.py b/homeassistant/components/media_player/webostv.py index 3ccd3c7dbe9..acd1ffad6eb 100644 --- a/homeassistant/components/media_player/webostv.py +++ b/homeassistant/components/media_player/webostv.py @@ -270,8 +270,7 @@ class LgWebOSDevice(MediaPlayerDevice): """Title of current playing media.""" if (self._channel is not None) and ('channelName' in self._channel): return self._channel['channelName'] - else: - return None + return None @property def media_image_url(self): diff --git a/homeassistant/components/media_player/xiaomi_tv.py b/homeassistant/components/media_player/xiaomi_tv.py new file mode 100644 index 00000000000..be40bf7d010 --- /dev/null +++ b/homeassistant/components/media_player/xiaomi_tv.py @@ -0,0 +1,112 @@ +""" +Add support for the Xiaomi TVs. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/xiaomi_tv/ +""" + +import logging +import voluptuous as vol +import homeassistant.helpers.config_validation as cv +from homeassistant.const import (CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON) +from homeassistant.components.media_player import ( + SUPPORT_TURN_ON, SUPPORT_TURN_OFF, MediaPlayerDevice, PLATFORM_SCHEMA, + SUPPORT_VOLUME_STEP) + +REQUIREMENTS = ['pymitv==1.0.0'] + +DEFAULT_NAME = "Xiaomi TV" + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_XIAOMI_TV = SUPPORT_VOLUME_STEP | SUPPORT_TURN_ON | \ + SUPPORT_TURN_OFF + +# No host is needed for configuration, however it can be set. +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Xiaomi TV platform.""" + from pymitv import Discover + + # If a hostname is set. Discovery is skipped. + host = config.get(CONF_HOST) + name = config.get(CONF_NAME) + + if host is not None: + # Check if there's a valid TV at the IP address. + if not Discover().checkIp(host): + _LOGGER.error( + "Could not find Xiaomi TV with specified IP: %s", host + ) + else: + # Register TV with Home Assistant. + add_devices([XiaomiTV(host, name)]) + else: + # Otherwise, discover TVs on network. + add_devices(XiaomiTV(tv, DEFAULT_NAME) for tv in Discover().scan()) + + +class XiaomiTV(MediaPlayerDevice): + """Represent the Xiaomi TV for Home Assistant.""" + + def __init__(self, ip, name): + """Receive IP address and name to construct class.""" + # Import pymitv library. + from pymitv import TV + + # Initialize the Xiaomi TV. + self._tv = TV(ip) + # Default name value, only to be overridden by user. + self._name = name + self._state = STATE_OFF + + @property + def name(self): + """Return the display name of this TV.""" + return self._name + + @property + def state(self): + """Return _state variable, containing the appropriate constant.""" + return self._state + + @property + def assumed_state(self): + """Indicate that state is assumed.""" + return True + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return SUPPORT_XIAOMI_TV + + def turn_off(self): + """ + Instruct the TV to turn sleep. + + This is done instead of turning off, + because the TV won't accept any input when turned off. Thus, the user + would be unable to turn the TV back on, unless it's done manually. + """ + self._tv.sleep() + + self._state = STATE_OFF + + def turn_on(self): + """Wake the TV back up from sleep.""" + self._tv.wake() + + self._state = STATE_ON + + def volume_up(self): + """Increase volume by one.""" + self._tv.volume_up() + + def volume_down(self): + """Decrease volume by one.""" + self._tv.volume_down() diff --git a/homeassistant/components/melissa.py b/homeassistant/components/melissa.py index ae82b96222e..f5a757dbcf3 100644 --- a/homeassistant/components/melissa.py +++ b/homeassistant/components/melissa.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_USERNAME, CONF_PASSWORD from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import load_platform -REQUIREMENTS = ["py-melissa-climate==1.0.1"] +REQUIREMENTS = ["py-melissa-climate==1.0.6"] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index cdf59b92606..0485d82a274 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -5,6 +5,10 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/mqtt/ """ import asyncio +from collections import namedtuple +from itertools import groupby +from typing import Optional +from operator import attrgetter import logging import os import socket @@ -15,13 +19,12 @@ import requests.certs import voluptuous as vol +from homeassistant.helpers.typing import HomeAssistantType from homeassistant.core import callback from homeassistant.setup import async_prepare_setup_platform from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import bind_hass -from homeassistant.helpers import template, config_validation as cv -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, dispatcher_send) +from homeassistant.helpers import template, ConfigType, config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util.async import ( run_coroutine_threadsafe, run_callback_threadsafe) @@ -39,7 +42,6 @@ DOMAIN = 'mqtt' DATA_MQTT = 'mqtt' SERVICE_PUBLISH = 'publish' -SIGNAL_MQTT_MESSAGE_RECEIVED = 'mqtt_message_received' CONF_EMBEDDED = 'embedded' CONF_BROKER = 'broker' @@ -173,7 +175,6 @@ MQTT_RW_PLATFORM_SCHEMA = MQTT_BASE_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_VALUE_TEMPLATE): cv.template, }) - # Service call validation schema MQTT_PUBLISH_SCHEMA = vol.Schema({ vol.Required(ATTR_TOPIC): valid_publish_topic, @@ -221,32 +222,13 @@ def publish_template(hass, topic, payload_template, qos=None, retain=None): @bind_hass def async_subscribe(hass, topic, msg_callback, qos=DEFAULT_QOS, encoding='utf-8'): - """Subscribe to an MQTT topic.""" - @callback - def async_mqtt_topic_subscriber(dp_topic, dp_payload, dp_qos): - """Match subscribed MQTT topic.""" - if not _match_topic(topic, dp_topic): - return + """Subscribe to an MQTT topic. - if encoding is not None: - try: - payload = dp_payload.decode(encoding) - _LOGGER.debug("Received message on %s: %s", dp_topic, payload) - except (AttributeError, UnicodeDecodeError): - _LOGGER.error("Illegal payload encoding %s from " - "MQTT topic: %s, Payload: %s", - encoding, dp_topic, dp_payload) - return - else: - _LOGGER.debug("Received binary message on %s", dp_topic) - payload = dp_payload - - hass.async_run_job(msg_callback, dp_topic, payload, dp_qos) - - async_remove = async_dispatcher_connect( - hass, SIGNAL_MQTT_MESSAGE_RECEIVED, async_mqtt_topic_subscriber) - - yield from hass.data[DATA_MQTT].async_subscribe(topic, qos) + Call the return value to unsubscribe. + """ + async_remove = \ + yield from hass.data[DATA_MQTT].async_subscribe(topic, msg_callback, + qos, encoding) return async_remove @@ -308,7 +290,7 @@ def _async_setup_discovery(hass, config): @asyncio.coroutine -def async_setup(hass, config): +def async_setup(hass: HomeAssistantType, config: ConfigType): """Start the MQTT protocol service.""" conf = config.get(DOMAIN) @@ -351,8 +333,8 @@ def async_setup(hass, config): return False # For cloudmqtt.com, secured connection, auto fill in certificate - if certificate is None and 19999 < port < 30000 and \ - broker.endswith('.cloudmqtt.com'): + if (certificate is None and 19999 < port < 30000 and + broker.endswith('.cloudmqtt.com')): certificate = os.path.join(os.path.dirname(__file__), 'addtrustexternalcaroot.crt') @@ -360,8 +342,12 @@ def async_setup(hass, config): if certificate == 'auto': certificate = requests.certs.where() - will_message = conf.get(CONF_WILL_MESSAGE) - birth_message = conf.get(CONF_BIRTH_MESSAGE) + will_message = None + if conf.get(CONF_WILL_MESSAGE) is not None: + will_message = Message(**conf.get(CONF_WILL_MESSAGE)) + birth_message = None + if conf.get(CONF_BIRTH_MESSAGE) is not None: + birth_message = Message(**conf.get(CONF_BIRTH_MESSAGE)) # Be able to override versions other than TLSv1.0 under Python3.6 conf_tls_version = conf.get(CONF_TLS_VERSION) @@ -414,8 +400,8 @@ def async_setup(hass, config): template.Template(payload_template, hass).async_render() except template.jinja2.TemplateError as exc: _LOGGER.error( - "Unable to publish to '%s': rendering payload template of " - "'%s' failed because %s", + "Unable to publish to %s: rendering payload template of " + "%s failed because %s", msg_topic, payload_template, exc) return @@ -432,13 +418,21 @@ def async_setup(hass, config): return True +Subscription = namedtuple('Subscription', + ['topic', 'callback', 'qos', 'encoding']) +Subscription.__new__.__defaults__ = (0, 'utf-8') + +Message = namedtuple('Message', ['topic', 'payload', 'qos', 'retain']) +Message.__new__.__defaults__ = (0, False) + + class MQTT(object): """Home Assistant MQTT client.""" def __init__(self, hass, broker, port, client_id, keepalive, username, password, certificate, client_key, client_cert, - tls_insecure, protocol, will_message, birth_message, - tls_version): + tls_insecure, protocol, will_message: Optional[Message], + birth_message: Optional[Message], tls_version): """Initialize Home Assistant MQTT client.""" import paho.mqtt.client as mqtt @@ -446,9 +440,7 @@ class MQTT(object): self.broker = broker self.port = port self.keepalive = keepalive - self.wanted_topics = {} - self.subscribed_topics = {} - self.progress = {} + self.subscriptions = [] self.birth_message = birth_message self._mqttc = None self._paho_lock = asyncio.Lock(loop=hass.loop) @@ -474,17 +466,12 @@ class MQTT(object): if tls_insecure is not None: self._mqttc.tls_insecure_set(tls_insecure) - self._mqttc.on_subscribe = self._mqtt_on_subscribe - self._mqttc.on_unsubscribe = self._mqtt_on_unsubscribe self._mqttc.on_connect = self._mqtt_on_connect self._mqttc.on_disconnect = self._mqtt_on_disconnect self._mqttc.on_message = self._mqtt_on_message if will_message: - self._mqttc.will_set(will_message.get(ATTR_TOPIC), - will_message.get(ATTR_PAYLOAD), - will_message.get(ATTR_QOS), - will_message.get(ATTR_RETAIN)) + self._mqttc.will_set(*will_message) @asyncio.coroutine def async_publish(self, topic, payload, qos, retain): @@ -526,36 +513,53 @@ class MQTT(object): return self.hass.async_add_job(stop) @asyncio.coroutine - def async_subscribe(self, topic, qos): - """Subscribe to a topic. + def async_subscribe(self, topic, msg_callback, qos, encoding): + """Set up a subscription to a topic with the provided qos. This method is a coroutine. """ if not isinstance(topic, str): - raise HomeAssistantError("topic need to be a string!") + raise HomeAssistantError("topic needs to be a string!") - with (yield from self._paho_lock): - if topic in self.subscribed_topics: + subscription = Subscription(topic, msg_callback, qos, encoding) + self.subscriptions.append(subscription) + + yield from self._async_perform_subscription(topic, qos) + + @callback + def async_remove(): + """Remove subscription.""" + if subscription not in self.subscriptions: + raise HomeAssistantError("Can't remove subscription twice") + self.subscriptions.remove(subscription) + + if any(other.topic == topic for other in self.subscriptions): + # Other subscriptions on topic remaining - don't unsubscribe. return - self.wanted_topics[topic] = qos - result, mid = yield from self.hass.async_add_job( - self._mqttc.subscribe, topic, qos) + self.hass.async_add_job(self._async_unsubscribe(topic)) - _raise_on_error(result) - self.progress[mid] = topic + return async_remove @asyncio.coroutine - def async_unsubscribe(self, topic): - """Unsubscribe from topic. + def _async_unsubscribe(self, topic): + """Unsubscribe from a topic. This method is a coroutine. """ - self.wanted_topics.pop(topic, None) - result, mid = yield from self.hass.async_add_job( - self._mqttc.unsubscribe, topic) + with (yield from self._paho_lock): + result, _ = yield from self.hass.async_add_job( + self._mqttc.unsubscribe, topic) + _raise_on_error(result) - _raise_on_error(result) - self.progress[mid] = topic + @asyncio.coroutine + def _async_perform_subscription(self, topic, qos): + """Perform a paho-mqtt subscription.""" + _LOGGER.debug("Subscribing to %s", topic) + + with (yield from self._paho_lock): + result, _ = yield from self.hass.async_add_job( + self._mqttc.subscribe, topic, qos) + _raise_on_error(result) def _mqtt_on_connect(self, _mqttc, _userdata, _flags, result_code): """On connect callback. @@ -571,50 +575,50 @@ class MQTT(object): self._mqttc.disconnect() return - self.progress = {} - self.subscribed_topics = {} - for topic, qos in self.wanted_topics.items(): - self.hass.add_job(self.async_subscribe, topic, qos) + # Group subscriptions to only re-subscribe once for each topic. + keyfunc = attrgetter('topic') + for topic, subs in groupby(sorted(self.subscriptions, key=keyfunc), + keyfunc): + # Re-subscribe with the highest requested qos + max_qos = max(subscription.qos for subscription in subs) + self.hass.add_job(self._async_perform_subscription, topic, max_qos) if self.birth_message: - self.hass.add_job(self.async_publish( - self.birth_message.get(ATTR_TOPIC), - self.birth_message.get(ATTR_PAYLOAD), - self.birth_message.get(ATTR_QOS), - self.birth_message.get(ATTR_RETAIN))) - - def _mqtt_on_subscribe(self, _mqttc, _userdata, mid, granted_qos): - """Subscribe successful callback.""" - topic = self.progress.pop(mid, None) - if topic is None: - return - self.subscribed_topics[topic] = granted_qos[0] + self.hass.add_job(self.async_publish(*self.birth_message)) def _mqtt_on_message(self, _mqttc, _userdata, msg): """Message received callback.""" - dispatcher_send( - self.hass, SIGNAL_MQTT_MESSAGE_RECEIVED, msg.topic, msg.payload, - msg.qos - ) + self.hass.async_add_job(self._mqtt_handle_message, msg) - def _mqtt_on_unsubscribe(self, _mqttc, _userdata, mid, granted_qos): - """Unsubscribe successful callback.""" - topic = self.progress.pop(mid, None) - if topic is None: - return - self.subscribed_topics.pop(topic, None) + @callback + def _mqtt_handle_message(self, msg): + _LOGGER.debug("Received message on %s: %s", msg.topic, msg.payload) + + for subscription in self.subscriptions: + if not _match_topic(subscription.topic, msg.topic): + continue + + payload = msg.payload + if subscription.encoding is not None: + try: + payload = msg.payload.decode(subscription.encoding) + except (AttributeError, UnicodeDecodeError): + _LOGGER.warning("Can't decode payload %s on %s " + "with encoding %s", + msg.payload, msg.topic, + subscription.encoding) + continue + + self.hass.async_run_job(subscription.callback, + msg.topic, payload, msg.qos) def _mqtt_on_disconnect(self, _mqttc, _userdata, result_code): """Disconnected callback.""" - self.progress = {} - self.subscribed_topics = {} - # When disconnected because of calling disconnect() if result_code == 0: return tries = 0 - wait_time = 0 while True: try: @@ -693,7 +697,7 @@ class MqttAvailability(Entity): if self._availability_topic is not None: yield from async_subscribe( self.hass, self._availability_topic, - availability_message_received, self. _availability_qos) + availability_message_received, self._availability_qos) @property def available(self) -> bool: diff --git a/homeassistant/components/mqtt_eventstream.py b/homeassistant/components/mqtt_eventstream.py index 40a752807ed..6f6cb312f2b 100644 --- a/homeassistant/components/mqtt_eventstream.py +++ b/homeassistant/components/mqtt_eventstream.py @@ -26,6 +26,7 @@ DEPENDENCIES = ['mqtt'] CONF_PUBLISH_TOPIC = 'publish_topic' CONF_SUBSCRIBE_TOPIC = 'subscribe_topic' CONF_PUBLISH_EVENTSTREAM_RECEIVED = 'publish_eventstream_received' +CONF_IGNORE_EVENT = 'ignore_event' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ @@ -33,6 +34,7 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_SUBSCRIBE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_PUBLISH_EVENTSTREAM_RECEIVED, default=False): cv.boolean, + vol.Optional(CONF_IGNORE_EVENT, default=[]): cv.ensure_list }), }, extra=vol.ALLOW_EXTRA) @@ -44,6 +46,7 @@ def async_setup(hass, config): conf = config.get(DOMAIN, {}) pub_topic = conf.get(CONF_PUBLISH_TOPIC) sub_topic = conf.get(CONF_SUBSCRIBE_TOPIC) + ignore_event = conf.get(CONF_IGNORE_EVENT) @callback def _event_publisher(event): @@ -53,6 +56,10 @@ def async_setup(hass, config): if event.event_type == EVENT_TIME_CHANGED: return + # User-defined events to ignore + if event.event_type in ignore_event: + return + # Filter out the events that were triggered by publishing # to the MQTT topic, or you will end up in an infinite loop. if event.event_type == EVENT_CALL_SERVICE: diff --git a/homeassistant/components/notify/apns.py b/homeassistant/components/notify/apns.py index e7a727bc5e2..dcbd1ce1317 100644 --- a/homeassistant/components/notify/apns.py +++ b/homeassistant/components/notify/apns.py @@ -39,7 +39,7 @@ PLATFORM_SCHEMA = vol.Schema({ REGISTER_SERVICE_SCHEMA = vol.Schema({ vol.Required(ATTR_PUSH_ID): cv.string, - vol.Optional(ATTR_NAME, default=None): cv.string, + vol.Optional(ATTR_NAME): cv.string, }) diff --git a/homeassistant/components/notify/html5.py b/homeassistant/components/notify/html5.py index f2611cf65d3..45439dbfbfe 100644 --- a/homeassistant/components/notify/html5.py +++ b/homeassistant/components/notify/html5.py @@ -136,7 +136,7 @@ def _load_config(filename): class JSONBytesDecoder(json.JSONEncoder): """JSONEncoder to decode bytes objects to unicode.""" - # pylint: disable=method-hidden + # pylint: disable=method-hidden, arguments-differ def default(self, obj): """Decode object if it's a bytes object, else defer to base class.""" if isinstance(obj, bytes): @@ -255,12 +255,12 @@ class HTML5PushCallbackView(HomeAssistantView): # 2a. If decode is successful, return the payload. # 2b. If decode is unsuccessful, return a 401. - target_check = jwt.decode(token, options={'verify_signature': False}) + target_check = jwt.decode(token, verify=False) if target_check[ATTR_TARGET] in self.registrations: possible_target = self.registrations[target_check[ATTR_TARGET]] key = possible_target[ATTR_SUBSCRIPTION][ATTR_KEYS][ATTR_AUTH] try: - return jwt.decode(token, key) + return jwt.decode(token, key, algorithms=["ES256", "HS256"]) except jwt.exceptions.DecodeError: pass diff --git a/homeassistant/components/notify/knx.py b/homeassistant/components/notify/knx.py index d14d8dcf8ad..e6bb400d421 100644 --- a/homeassistant/components/notify/knx.py +++ b/homeassistant/components/notify/knx.py @@ -27,10 +27,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine def async_get_service(hass, config, discovery_info=None): """Get the KNX notification service.""" - if DATA_KNX not in hass.data \ - or not hass.data[DATA_KNX].initialized: - return False - return async_get_service_discovery(hass, discovery_info) \ if discovery_info is not None else \ async_get_service_config(hass, config) @@ -44,7 +40,7 @@ def async_get_service_discovery(hass, discovery_info): device = hass.data[DATA_KNX].xknx.devices[device_name] notification_devices.append(device) return \ - KNXNotificationService(hass, notification_devices) \ + KNXNotificationService(notification_devices) \ if notification_devices else \ None @@ -58,15 +54,14 @@ def async_get_service_config(hass, config): name=config.get(CONF_NAME), group_address=config.get(CONF_ADDRESS)) hass.data[DATA_KNX].xknx.devices.add(notification) - return KNXNotificationService(hass, [notification, ]) + return KNXNotificationService([notification, ]) class KNXNotificationService(BaseNotificationService): """Implement demo notification service.""" - def __init__(self, hass, devices): + def __init__(self, devices): """Initialize the service.""" - self.hass = hass self.devices = devices @property diff --git a/homeassistant/components/notify/lametric.py b/homeassistant/components/notify/lametric.py index 2f967dcdda4..f4c9c391408 100644 --- a/homeassistant/components/notify/lametric.py +++ b/homeassistant/components/notify/lametric.py @@ -93,6 +93,11 @@ class LaMetricNotificationService(BaseNotificationService): devices = lmn.get_devices() for dev in devices: if targets is None or dev["name"] in targets: - lmn.set_device(dev) - lmn.send_notification(model, lifetime=self._lifetime) - _LOGGER.debug("Sent notification to LaMetric %s", dev["name"]) + try: + lmn.set_device(dev) + lmn.send_notification(model, lifetime=self._lifetime) + _LOGGER.debug("Sent notification to LaMetric %s", + dev["name"]) + except OSError: + _LOGGER.warning("Cannot connect to LaMetric %s", + dev["name"]) diff --git a/homeassistant/components/notify/llamalab_automate.py b/homeassistant/components/notify/llamalab_automate.py index 606c0fafc8b..0ddcb450bcf 100644 --- a/homeassistant/components/notify/llamalab_automate.py +++ b/homeassistant/components/notify/llamalab_automate.py @@ -56,4 +56,4 @@ class AutomateNotificationService(BaseNotificationService): response = requests.post(_RESOURCE, json=data) if response.status_code != 200: - _LOGGER.error("Error sending message: " + str(response)) + _LOGGER.error("Error sending message: %s", response) diff --git a/homeassistant/components/notify/rest.py b/homeassistant/components/notify/rest.py index 19339a2c7ec..73618c19502 100644 --- a/homeassistant/components/notify/rest.py +++ b/homeassistant/components/notify/rest.py @@ -22,8 +22,6 @@ CONF_TARGET_PARAMETER_NAME = 'target_param_name' CONF_TITLE_PARAMETER_NAME = 'title_param_name' DEFAULT_MESSAGE_PARAM_NAME = 'message' DEFAULT_METHOD = 'GET' -DEFAULT_TARGET_PARAM_NAME = None -DEFAULT_TITLE_PARAM_NAME = None PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_RESOURCE): cv.url, @@ -32,14 +30,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): vol.In(['POST', 'GET', 'POST_JSON']), vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_TARGET_PARAMETER_NAME, - default=DEFAULT_TARGET_PARAM_NAME): cv.string, - vol.Optional(CONF_TITLE_PARAMETER_NAME, - default=DEFAULT_TITLE_PARAM_NAME): cv.string, - vol.Optional(CONF_DATA, - default=None): dict, - vol.Optional(CONF_DATA_TEMPLATE, - default=None): {cv.match_all: cv.template_complex} + vol.Optional(CONF_TARGET_PARAMETER_NAME): cv.string, + vol.Optional(CONF_TITLE_PARAMETER_NAME): cv.string, + vol.Optional(CONF_DATA): dict, + vol.Optional(CONF_DATA_TEMPLATE): {cv.match_all: cv.template_complex} }) _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/panel_iframe.py b/homeassistant/components/panel_iframe.py index 6ddf00cf7d4..4574437bac9 100644 --- a/homeassistant/components/panel_iframe.py +++ b/homeassistant/components/panel_iframe.py @@ -23,13 +23,14 @@ CONF_RELATIVE_URL_REGEX = r'\A/' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ cv.slug: { + # pylint: disable=no-value-for-parameter vol.Optional(CONF_TITLE): cv.string, vol.Optional(CONF_ICON): cv.icon, vol.Required(CONF_URL): vol.Any( vol.Match( CONF_RELATIVE_URL_REGEX, msg=CONF_RELATIVE_URL_ERROR_MSG), - cv.url), + vol.Url()), }})}, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/components/plant.py b/homeassistant/components/plant.py index 24b8c682d02..048851e97f5 100644 --- a/homeassistant/components/plant.py +++ b/homeassistant/components/plant.py @@ -9,6 +9,7 @@ from datetime import datetime, timedelta from collections import deque import voluptuous as vol +from homeassistant.exceptions import HomeAssistantError from homeassistant.const import ( STATE_OK, STATE_PROBLEM, STATE_UNKNOWN, TEMP_CELSIUS, ATTR_TEMPERATURE, CONF_SENSORS, ATTR_UNIT_OF_MEASUREMENT) @@ -198,8 +199,8 @@ class Plant(Entity): self._brightness_history.add_measurement(self._brightness, new_state.last_updated) else: - raise _LOGGER.error("Unknown reading from sensor %s: %s", - entity_id, value) + raise HomeAssistantError( + "Unknown reading from sensor {}: {}".format(entity_id, value)) if ATTR_UNIT_OF_MEASUREMENT in new_state.attributes: self._unit_of_measurement[reading] = \ new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) diff --git a/homeassistant/components/python_script.py b/homeassistant/components/python_script.py index b49b280791a..dedc39ef3a2 100644 --- a/homeassistant/components/python_script.py +++ b/homeassistant/components/python_script.py @@ -208,5 +208,4 @@ class TimeWrapper: """Wrap to return callable method if callable.""" return attribute(*args, **kw) return wrapper - else: - return attribute + return attribute diff --git a/homeassistant/components/raspihats.py b/homeassistant/components/raspihats.py index e3c1ab8ff88..3bc45eab34e 100644 --- a/homeassistant/components/raspihats.py +++ b/homeassistant/components/raspihats.py @@ -4,6 +4,7 @@ Support for controlling raspihats boards. For more details about this component, please refer to the documentation at https://home-assistant.io/components/raspihats/ """ +# pylint: disable=import-error,no-name-in-module import logging import threading import time @@ -143,7 +144,6 @@ class I2CHatsManager(threading.Thread): def run(self): """Keep alive for I2C-HATs.""" - # pylint: disable=import-error from raspihats.i2c_hats import ResponseException _LOGGER.info(log_message(self, "starting")) @@ -206,7 +206,6 @@ class I2CHatsManager(threading.Thread): def read_di(self, address, channel): """Read a value from a I2C-HAT digital input.""" - # pylint: disable=import-error from raspihats.i2c_hats import ResponseException with self._lock: @@ -219,7 +218,6 @@ class I2CHatsManager(threading.Thread): def write_dq(self, address, channel, value): """Write a value to a I2C-HAT digital output.""" - # pylint: disable=import-error from raspihats.i2c_hats import ResponseException with self._lock: @@ -231,7 +229,6 @@ class I2CHatsManager(threading.Thread): def read_dq(self, address, channel): """Read a value from a I2C-HAT digital output.""" - # pylint: disable=import-error from raspihats.i2c_hats import ResponseException with self._lock: diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index b2628f954fc..bffe29ec59b 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -43,10 +43,12 @@ DOMAIN = 'recorder' SERVICE_PURGE = 'purge' ATTR_KEEP_DAYS = 'keep_days' +ATTR_REPACK = 'repack' SERVICE_PURGE_SCHEMA = vol.Schema({ - vol.Required(ATTR_KEEP_DAYS): - vol.All(vol.Coerce(int), vol.Range(min=0)) + vol.Optional(ATTR_KEEP_DAYS): + vol.All(vol.Coerce(int), vol.Range(min=0)), + vol.Optional(ATTR_REPACK, default=False): cv.boolean }) DEFAULT_URL = 'sqlite:///{hass_config_path}' @@ -61,22 +63,22 @@ CONNECT_RETRY_WAIT = 3 FILTER_SCHEMA = vol.Schema({ vol.Optional(CONF_EXCLUDE, default={}): vol.Schema({ - vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids, - vol.Optional(CONF_DOMAINS, default=[]): + vol.Optional(CONF_ENTITIES): cv.entity_ids, + vol.Optional(CONF_DOMAINS): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_EVENT_TYPES, default=[]): + vol.Optional(CONF_EVENT_TYPES): vol.All(cv.ensure_list, [cv.string]) }), vol.Optional(CONF_INCLUDE, default={}): vol.Schema({ - vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids, - vol.Optional(CONF_DOMAINS, default=[]): + vol.Optional(CONF_ENTITIES): cv.entity_ids, + vol.Optional(CONF_DOMAINS): vol.All(cv.ensure_list, [cv.string]) }) }) CONFIG_SCHEMA = vol.Schema({ DOMAIN: FILTER_SCHEMA.extend({ - vol.Optional(CONF_PURGE_KEEP_DAYS): + vol.Optional(CONF_PURGE_KEEP_DAYS, default=10): vol.All(vol.Coerce(int), vol.Range(min=1)), vol.Optional(CONF_PURGE_INTERVAL, default=1): vol.All(vol.Coerce(int), vol.Range(min=0)), @@ -85,16 +87,17 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) +@asyncio.coroutine def wait_connection_ready(hass): """ Wait till the connection is ready. Returns a coroutine object. """ - return hass.data[DATA_INSTANCE].async_db_ready + return (yield from hass.data[DATA_INSTANCE].async_db_ready) -def run_information(hass, point_in_time: Optional[datetime]=None): +def run_information(hass, point_in_time: Optional[datetime] = None): """Return information about current run. There is also the run that covers point_in_time. @@ -122,12 +125,6 @@ def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: keep_days = conf.get(CONF_PURGE_KEEP_DAYS) purge_interval = conf.get(CONF_PURGE_INTERVAL) - if keep_days is None and purge_interval != 0: - _LOGGER.warning( - "From version 0.64.0 the 'recorder' component will by default " - "purge data older than 10 days. To keep data longer you must " - "configure 'purge_keep_days' or 'purge_interval'.") - db_url = conf.get(CONF_DB_URL, None) if not db_url: db_url = DEFAULT_URL.format( @@ -144,7 +141,7 @@ def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @asyncio.coroutine def async_handle_purge_service(service): """Handle calls to the purge service.""" - instance.do_adhoc_purge(service.data[ATTR_KEEP_DAYS]) + instance.do_adhoc_purge(**service.data) hass.services.async_register( DOMAIN, SERVICE_PURGE, async_handle_purge_service, @@ -153,7 +150,7 @@ def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return (yield from instance.async_db_ready) -PurgeTask = namedtuple('PurgeTask', ['keep_days']) +PurgeTask = namedtuple('PurgeTask', ['keep_days', 'repack']) class Recorder(threading.Thread): @@ -188,10 +185,12 @@ class Recorder(threading.Thread): """Initialize the recorder.""" self.hass.bus.async_listen(MATCH_ALL, self.event_listener) - def do_adhoc_purge(self, keep_days): + def do_adhoc_purge(self, **kwargs): """Trigger an adhoc purge retaining keep_days worth of data.""" - if keep_days is not None: - self.queue.put(PurgeTask(keep_days)) + keep_days = kwargs.get(ATTR_KEEP_DAYS, self.keep_days) + repack = kwargs.get(ATTR_REPACK) + + self.queue.put(PurgeTask(keep_days, repack)) def run(self): """Start processing events to save.""" @@ -257,27 +256,6 @@ class Recorder(threading.Thread): self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_START, notify_hass_started) - if self.keep_days and self.purge_interval: - @callback - def async_purge(now): - """Trigger the purge and schedule the next run.""" - self.queue.put(PurgeTask(self.keep_days)) - self.hass.helpers.event.async_track_point_in_time( - async_purge, now + timedelta(days=self.purge_interval)) - - earliest = dt_util.utcnow() + timedelta(minutes=30) - run = latest = dt_util.utcnow() + \ - timedelta(days=self.purge_interval) - with session_scope(session=self.get_session()) as session: - event = session.query(Events).first() - if event is not None: - session.expunge(event) - run = dt_util.as_utc(event.time_fired) + \ - timedelta(days=self.keep_days+self.purge_interval) - run = min(latest, max(run, earliest)) - self.hass.helpers.event.async_track_point_in_time( - async_purge, run) - self.hass.add_job(register) result = hass_started.result() @@ -285,6 +263,29 @@ class Recorder(threading.Thread): if result is shutdown_task: return + # Start periodic purge + if self.keep_days and self.purge_interval: + @callback + def async_purge(now): + """Trigger the purge and schedule the next run.""" + self.queue.put( + PurgeTask(self.keep_days, repack=not self.did_vacuum)) + self.hass.helpers.event.async_track_point_in_time( + async_purge, now + timedelta(days=self.purge_interval)) + + earliest = dt_util.utcnow() + timedelta(minutes=30) + run = latest = dt_util.utcnow() + \ + timedelta(days=self.purge_interval) + with session_scope(session=self.get_session()) as session: + event = session.query(Events).first() + if event is not None: + session.expunge(event) + run = dt_util.as_utc(event.time_fired) + timedelta( + days=self.keep_days+self.purge_interval) + run = min(latest, max(run, earliest)) + + self.hass.helpers.event.track_point_in_time(async_purge, run) + while True: event = self.queue.get() @@ -294,7 +295,7 @@ class Recorder(threading.Thread): self.queue.task_done() return elif isinstance(event, PurgeTask): - purge.purge_old_data(self, event.keep_days) + purge.purge_old_data(self, event.keep_days, event.repack) self.queue.task_done() continue elif event.event_type == EVENT_TIME_CHANGED: @@ -319,6 +320,7 @@ class Recorder(threading.Thread): with session_scope(session=self.get_session()) as session: dbevent = Events.from_event(event) session.add(dbevent) + session.flush() if event.event_type == EVENT_STATE_CHANGED: dbstate = States.from_event(event) diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 3fffb521d5a..d2afb6076e3 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -9,54 +9,61 @@ from .util import session_scope _LOGGER = logging.getLogger(__name__) -def purge_old_data(instance, purge_days): +def purge_old_data(instance, purge_days, repack): """Purge events and states older than purge_days ago.""" from .models import States, Events from sqlalchemy import func purge_before = dt_util.utcnow() - timedelta(days=purge_days) + _LOGGER.debug("Purging events before %s", purge_before) with session_scope(session=instance.get_session()) as session: + delete_states = session.query(States) \ + .filter((States.last_updated < purge_before)) + # For each entity, the most recent state is protected from deletion # s.t. we can properly restore state even if the entity has not been # updated in a long time protected_states = session.query(func.max(States.state_id)) \ .group_by(States.entity_id).all() - protected_state_ids = tuple((state[0] for state in protected_states)) + protected_state_ids = tuple(state[0] for state in protected_states) - deleted_rows = session.query(States) \ - .filter((States.last_updated < purge_before)) \ - .filter(~States.state_id.in_( - protected_state_ids)) \ - .delete(synchronize_session=False) + if protected_state_ids: + delete_states = delete_states \ + .filter(~States.state_id.in_(protected_state_ids)) + + deleted_rows = delete_states.delete(synchronize_session=False) _LOGGER.debug("Deleted %s states", deleted_rows) + delete_events = session.query(Events) \ + .filter((Events.time_fired < purge_before)) + # We also need to protect the events belonging to the protected states. # Otherwise, if the SQL server has "ON DELETE CASCADE" as default, it # will delete the protected state when deleting its associated # event. Also, we would be producing NULLed foreign keys otherwise. - protected_events = session.query(States.event_id) \ - .filter(States.state_id.in_(protected_state_ids)) \ - .filter(States.event_id.isnot(None)) \ - .all() + if protected_state_ids: + protected_events = session.query(States.event_id) \ + .filter(States.state_id.in_(protected_state_ids)) \ + .filter(States.event_id.isnot(None)) \ + .all() - protected_event_ids = tuple((state[0] for state in protected_events)) + protected_event_ids = tuple(state[0] for state in protected_events) - deleted_rows = session.query(Events) \ - .filter((Events.time_fired < purge_before)) \ - .filter(~Events.event_id.in_( - protected_event_ids - )) \ - .delete(synchronize_session=False) + if protected_event_ids: + delete_events = delete_events \ + .filter(~Events.event_id.in_(protected_event_ids)) + + deleted_rows = delete_events.delete(synchronize_session=False) _LOGGER.debug("Deleted %s events", deleted_rows) # Execute sqlite vacuum command to free up space on disk _LOGGER.debug("DB engine driver: %s", instance.engine.driver) - if instance.engine.driver == 'pysqlite' and not instance.did_vacuum: + if repack and instance.engine.driver == 'pysqlite': from sqlalchemy import exc - _LOGGER.info("Vacuuming SQLite to free space") + _LOGGER.debug("Vacuuming SQLite to free space") try: instance.engine.execute("VACUUM") instance.did_vacuum = True diff --git a/homeassistant/components/recorder/services.yaml b/homeassistant/components/recorder/services.yaml index a2a8c9eab8d..512807c9f69 100644 --- a/homeassistant/components/recorder/services.yaml +++ b/homeassistant/components/recorder/services.yaml @@ -6,3 +6,6 @@ purge: keep_days: description: Number of history days to keep in database after purge. Value >= 0. example: 2 + repack: + description: Attempt to save disk space by rewriting the entire database file. + example: true diff --git a/homeassistant/components/remote/harmony.py b/homeassistant/components/remote/harmony.py index 39f09ea66a2..25a1a684d3c 100644 --- a/homeassistant/components/remote/harmony.py +++ b/homeassistant/components/remote/harmony.py @@ -31,7 +31,7 @@ CONF_DEVICE_CACHE = 'harmony_device_cache' SERVICE_SYNC = 'harmony_sync' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(ATTR_ACTIVITY, default=None): cv.string, + vol.Required(ATTR_ACTIVITY): cv.string, vol.Required(CONF_NAME): cv.string, vol.Optional(ATTR_DELAY_SECS, default=DEFAULT_DELAY_SECS): vol.Coerce(float), @@ -207,6 +207,7 @@ class HarmonyRemote(remote.RemoteDevice): """Start the PowerOff activity.""" self._client.power_off() + # pylint: disable=arguments-differ def send_command(self, commands, **kwargs): """Send a list of commands to one device.""" device = kwargs.get(ATTR_DEVICE) diff --git a/homeassistant/components/remote/xiaomi_miio.py b/homeassistant/components/remote/xiaomi_miio.py index aa9f0c95a7c..a44934d0a39 100644 --- a/homeassistant/components/remote/xiaomi_miio.py +++ b/homeassistant/components/remote/xiaomi_miio.py @@ -21,7 +21,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.util.dt import utcnow -REQUIREMENTS = ['python-miio==0.3.6'] +REQUIREMENTS = ['python-miio==0.3.7'] _LOGGER = logging.getLogger(__name__) @@ -210,8 +210,7 @@ class XiaomiMiioRemote(RemoteDevice): """Hide remote by default.""" if self._is_hidden: return {'hidden': 'true'} - else: - return + return # pylint: disable=R0201 @asyncio.coroutine diff --git a/homeassistant/components/rflink.py b/homeassistant/components/rflink.py index d97d4f38f02..439f938beb3 100644 --- a/homeassistant/components/rflink.py +++ b/homeassistant/components/rflink.py @@ -8,9 +8,10 @@ import asyncio from collections import defaultdict import functools as ft import logging - import async_timeout +import voluptuous as vol + from homeassistant.const import ( ATTR_ENTITY_ID, CONF_COMMAND, CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, STATE_UNKNOWN) @@ -19,7 +20,6 @@ from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.deprecation import get_deprecated from homeassistant.helpers.entity import Entity -import voluptuous as vol REQUIREMENTS = ['rflink==0.0.34'] @@ -74,7 +74,7 @@ DEVICE_DEFAULTS_SCHEMA = vol.Schema({ CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_PORT): vol.Any(cv.port, cv.string), - vol.Optional(CONF_HOST, default=None): cv.string, + vol.Optional(CONF_HOST): cv.string, vol.Optional(CONF_WAIT_FOR_ACK, default=True): cv.boolean, vol.Optional(CONF_RECONNECT_INTERVAL, default=DEFAULT_RECONNECT_INTERVAL): int, @@ -175,7 +175,7 @@ def async_setup(hass, config): hass.data[DATA_DEVICE_REGISTER][event_type], event) # When connecting to tcp host instead of serial port (optional) - host = config[DOMAIN][CONF_HOST] + host = config[DOMAIN].get(CONF_HOST) # TCP port when host configured, otherwise serial port port = config[DOMAIN][CONF_PORT] diff --git a/homeassistant/components/rfxtrx.py b/homeassistant/components/rfxtrx.py index de8a0c00d80..e7301836d7e 100644 --- a/homeassistant/components/rfxtrx.py +++ b/homeassistant/components/rfxtrx.py @@ -188,8 +188,8 @@ def find_possible_pt2262_device(device_id): for dev_id, device in RFX_DEVICES.items(): if hasattr(device, 'is_lighting4') and len(dev_id) == len(device_id): size = None - for i in range(0, len(dev_id)): - if dev_id[i] != device_id[i]: + for i, (char1, char2) in enumerate(zip(dev_id, device_id)): + if char1 != char2: break size = i diff --git a/homeassistant/components/ring.py b/homeassistant/components/ring.py index 62bd07d2c27..6e70ddb244d 100644 --- a/homeassistant/components/ring.py +++ b/homeassistant/components/ring.py @@ -5,13 +5,13 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/ring/ """ import logging -import voluptuous as vol -import homeassistant.helpers.config_validation as cv - -from homeassistant.const import CONF_USERNAME, CONF_PASSWORD - from requests.exceptions import HTTPError, ConnectTimeout +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD + REQUIREMENTS = ['ring_doorbell==0.1.8'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/scene/deconz.py b/homeassistant/components/scene/deconz.py index 067db1f93a3..db81d84c2b7 100644 --- a/homeassistant/components/scene/deconz.py +++ b/homeassistant/components/scene/deconz.py @@ -6,7 +6,8 @@ https://home-assistant.io/components/scene.deconz/ """ import asyncio -from homeassistant.components.deconz import DOMAIN as DECONZ_DATA +from homeassistant.components.deconz import ( + DOMAIN as DATA_DECONZ, DATA_DECONZ_ID) from homeassistant.components.scene import Scene DEPENDENCIES = ['deconz'] @@ -18,7 +19,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if discovery_info is None: return - scenes = hass.data[DECONZ_DATA].scenes + scenes = hass.data[DATA_DECONZ].scenes entities = [] for scene in scenes.values(): @@ -34,7 +35,12 @@ class DeconzScene(Scene): self._scene = scene @asyncio.coroutine - def async_activate(self, **kwargs): + def async_added_to_hass(self): + """Subscribe to sensors events.""" + self.hass.data[DATA_DECONZ_ID][self.entity_id] = self._scene.deconz_id + + @asyncio.coroutine + def async_activate(self): """Activate the scene.""" yield from self._scene.async_set_state({}) diff --git a/homeassistant/components/scene/litejet.py b/homeassistant/components/scene/litejet.py index 432ce060774..37fb58d8dc7 100644 --- a/homeassistant/components/scene/litejet.py +++ b/homeassistant/components/scene/litejet.py @@ -42,11 +42,6 @@ class LiteJetScene(Scene): """Return the name of the scene.""" return self._name - @property - def should_poll(self): - """Return that polling is not necessary.""" - return False - @property def device_state_attributes(self): """Return the device-specific state attributes.""" @@ -54,6 +49,6 @@ class LiteJetScene(Scene): ATTR_NUMBER: self._index } - def activate(self, **kwargs): + def activate(self): """Activate the scene.""" self._lj.activate_scene(self._index) diff --git a/homeassistant/components/scene/lutron_caseta.py b/homeassistant/components/scene/lutron_caseta.py index 53df0da7617..0d9024d194e 100644 --- a/homeassistant/components/scene/lutron_caseta.py +++ b/homeassistant/components/scene/lutron_caseta.py @@ -42,17 +42,7 @@ class LutronCasetaScene(Scene): """Return the name of the scene.""" return self._scene_name - @property - def should_poll(self): - """Return that polling is not necessary.""" - return False - - @property - def is_on(self): - """There is no way of detecting if a scene is active (yet).""" - return False - @asyncio.coroutine - def async_activate(self, **kwargs): + def async_activate(self): """Activate the scene.""" self._bridge.activate_scene(self._scene_id) diff --git a/homeassistant/components/scene/tahoma.py b/homeassistant/components/scene/tahoma.py new file mode 100644 index 00000000000..39206623901 --- /dev/null +++ b/homeassistant/components/scene/tahoma.py @@ -0,0 +1,48 @@ +""" +Support for Tahoma scenes. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/scene.tahoma/ +""" +import logging + +from homeassistant.components.scene import Scene +from homeassistant.components.tahoma import ( + DOMAIN as TAHOMA_DOMAIN) + +DEPENDENCIES = ['tahoma'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Tahoma scenes.""" + controller = hass.data[TAHOMA_DOMAIN]['controller'] + scenes = [] + for scene in hass.data[TAHOMA_DOMAIN]['scenes']: + scenes.append(TahomaScene(scene, controller)) + add_devices(scenes, True) + + +class TahomaScene(Scene): + """Representation of a Tahoma scene entity.""" + + def __init__(self, tahoma_scene, controller): + """Initialize the scene.""" + self.tahoma_scene = tahoma_scene + self.controller = controller + self._name = self.tahoma_scene.name + + def activate(self): + """Activate the scene.""" + self.controller.launch_action_group(self.tahoma_scene.oid) + + @property + def name(self): + """Return the name of the scene.""" + return self._name + + @property + def device_state_attributes(self): + """Return the state attributes of the scene.""" + return {'tahoma_scene_oid': self.tahoma_scene.oid} diff --git a/homeassistant/components/scene/velux.py b/homeassistant/components/scene/velux.py index 9da7a662117..86d71153a2b 100644 --- a/homeassistant/components/scene/velux.py +++ b/homeassistant/components/scene/velux.py @@ -4,6 +4,8 @@ Support for VELUX scenes. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/scene.velux/ """ +import asyncio + from homeassistant.components.scene import Scene from homeassistant.components.velux import _LOGGER, DATA_VELUX @@ -11,26 +13,22 @@ from homeassistant.components.velux import _LOGGER, DATA_VELUX DEPENDENCIES = ['velux'] -def setup_platform(hass, config, add_devices, discovery_info=None): +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up the scenes for velux platform.""" - if DATA_VELUX not in hass.data \ - or not hass.data[DATA_VELUX].initialized: - return False - entities = [] for scene in hass.data[DATA_VELUX].pyvlx.scenes: - entities.append(VeluxScene(hass, scene)) - add_devices(entities) - return True + entities.append(VeluxScene(scene)) + async_add_devices(entities) class VeluxScene(Scene): """Representation of a velux scene.""" - def __init__(self, hass, scene): + def __init__(self, scene): """Init velux scene.""" _LOGGER.info("Adding VELUX scene: %s", scene) - self.hass = hass self.scene = scene @property @@ -38,16 +36,7 @@ class VeluxScene(Scene): """Return the name of the scene.""" return self.scene.name - @property - def should_poll(self): - """Return that polling is not necessary.""" - return False - - @property - def is_on(self): - """There is no way of detecting if a scene is active (yet).""" - return False - - def activate(self, **kwargs): + @asyncio.coroutine + def async_activate(self): """Activate the scene.""" - self.hass.async_add_job(self.scene.run()) + yield from self.scene.run() diff --git a/homeassistant/components/scene/vera.py b/homeassistant/components/scene/vera.py index 3dbb68d214f..4f580356fbb 100644 --- a/homeassistant/components/scene/vera.py +++ b/homeassistant/components/scene/vera.py @@ -40,7 +40,7 @@ class VeraScene(Scene): """Update the scene status.""" self.vera_scene.refresh() - def activate(self, **kwargs): + def activate(self): """Activate the scene.""" self.vera_scene.activate() @@ -53,8 +53,3 @@ class VeraScene(Scene): def device_state_attributes(self): """Return the state attributes of the scene.""" return {'vera_scene_id': self.vera_scene.vera_scene_id} - - @property - def should_poll(self): - """Return that polling is not necessary.""" - return False diff --git a/homeassistant/components/scene/wink.py b/homeassistant/components/scene/wink.py index 2d4a6d0621c..5bd053bdd39 100644 --- a/homeassistant/components/scene/wink.py +++ b/homeassistant/components/scene/wink.py @@ -38,11 +38,6 @@ class WinkScene(WinkDevice, Scene): """Call when entity is added to hass.""" self.hass.data[DOMAIN]['entities']['scene'].append(self) - @property - def is_on(self): - """Python-wink will always return False.""" - return self.wink.state() - - def activate(self, **kwargs): + def activate(self): """Activate the scene.""" self.wink.activate() diff --git a/homeassistant/components/sensor/airvisual.py b/homeassistant/components/sensor/airvisual.py index 5ea24dab823..d67415fc65e 100644 --- a/homeassistant/components/sensor/airvisual.py +++ b/homeassistant/components/sensor/airvisual.py @@ -19,7 +19,6 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle REQUIREMENTS = ['pyairvisual==1.0.0'] - _LOGGER = getLogger(__name__) ATTR_CITY = 'city' @@ -27,7 +26,6 @@ ATTR_COUNTRY = 'country' ATTR_POLLUTANT_SYMBOL = 'pollutant_symbol' ATTR_POLLUTANT_UNIT = 'pollutant_unit' ATTR_REGION = 'region' -ATTR_TIMESTAMP = 'timestamp' CONF_CITY = 'city' CONF_COUNTRY = 'country' @@ -39,6 +37,12 @@ VOLUME_MICROGRAMS_PER_CUBIC_METER = 'µg/m3' MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) +SENSOR_TYPES = [ + ('AirPollutionLevelSensor', 'Air Pollution Level', 'mdi:scale'), + ('AirQualityIndexSensor', 'Air Quality Index', 'mdi:format-list-numbers'), + ('MainPollutantSensor', 'Main Pollutant', 'mdi:chemical-weapon'), +] + POLLUTANT_LEVEL_MAPPING = [ {'label': 'Good', 'minimum': 0, 'maximum': 50}, {'label': 'Moderate', 'minimum': 51, 'maximum': 100}, @@ -58,11 +62,6 @@ POLLUTANT_MAPPING = { } SENSOR_LOCALES = {'cn': 'Chinese', 'us': 'U.S.'} -SENSOR_TYPES = [ - ('AirPollutionLevelSensor', 'Air Pollution Level', 'mdi:scale'), - ('AirQualityIndexSensor', 'Air Quality Index', 'mdi:format-list-numbers'), - ('MainPollutantSensor', 'Main Pollutant', 'mdi:chemical-weapon'), -] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_API_KEY): cv.string, @@ -80,7 +79,13 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Configure the platform and add the sensors.""" - import pyairvisual as pav + from pyairvisual import Client + + classes = { + 'AirPollutionLevelSensor': AirPollutionLevelSensor, + 'AirQualityIndexSensor': AirQualityIndexSensor, + 'MainPollutantSensor': MainPollutantSensor + } api_key = config.get(CONF_API_KEY) monitored_locales = config.get(CONF_MONITORED_CONDITIONS) @@ -95,60 +100,63 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if city and state and country: _LOGGER.debug( "Using city, state, and country: %s, %s, %s", city, state, country) + location_id = ','.join((city, state, country)) data = AirVisualData( - pav.Client(api_key), city=city, state=state, country=country, + Client(api_key), city=city, state=state, country=country, show_on_map=show_on_map) else: _LOGGER.debug( "Using latitude and longitude: %s, %s", latitude, longitude) + location_id = ','.join((str(latitude), str(longitude))) data = AirVisualData( - pav.Client(api_key), latitude=latitude, longitude=longitude, + Client(api_key), latitude=latitude, longitude=longitude, radius=radius, show_on_map=show_on_map) data.update() + sensors = [] for locale in monitored_locales: for sensor_class, name, icon in SENSOR_TYPES: - sensors.append(globals()[sensor_class](data, name, icon, locale)) + sensors.append(classes[sensor_class]( + data, + name, + icon, + locale, + location_id + )) add_devices(sensors, True) -def merge_two_dicts(dict1, dict2): - """Merge two dicts into a new dict as a shallow copy.""" - final = dict1.copy() - final.update(dict2) - return final - - class AirVisualBaseSensor(Entity): """Define a base class for all of our sensors.""" - def __init__(self, data, name, icon, locale): + def __init__(self, data, name, icon, locale, entity_id): """Initialize the sensor.""" self.data = data + self._attrs = {} self._icon = icon self._locale = locale self._name = name self._state = None + self._entity_id = entity_id self._unit = None @property def device_state_attributes(self): """Return the device state attributes.""" - attrs = merge_two_dicts({ + self._attrs.update({ ATTR_ATTRIBUTION: CONF_ATTRIBUTION, - ATTR_TIMESTAMP: self.data.pollution_info.get('ts') - }, self.data.attrs) + }) if self.data.show_on_map: - attrs[ATTR_LATITUDE] = self.data.latitude - attrs[ATTR_LONGITUDE] = self.data.longitude + self._attrs[ATTR_LATITUDE] = self.data.latitude + self._attrs[ATTR_LONGITUDE] = self.data.longitude else: - attrs['lati'] = self.data.latitude - attrs['long'] = self.data.longitude + self._attrs['lati'] = self.data.latitude + self._attrs['long'] = self.data.longitude - return attrs + return self._attrs @property def icon(self): @@ -169,6 +177,11 @@ class AirVisualBaseSensor(Entity): class AirPollutionLevelSensor(AirVisualBaseSensor): """Define a sensor to measure air pollution level.""" + @property + def unique_id(self): + """Return a unique, HASS-friendly identifier for this entity.""" + return '{0}_pollution_level'.format(self._entity_id) + def update(self): """Update the status of the sensor.""" self.data.update() @@ -189,6 +202,11 @@ class AirPollutionLevelSensor(AirVisualBaseSensor): class AirQualityIndexSensor(AirVisualBaseSensor): """Define a sensor to measure AQI.""" + @property + def unique_id(self): + """Return a unique, HASS-friendly identifier for this entity.""" + return '{0}_aqi'.format(self._entity_id) + @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" @@ -205,19 +223,16 @@ class AirQualityIndexSensor(AirVisualBaseSensor): class MainPollutantSensor(AirVisualBaseSensor): """Define a sensor to the main pollutant of an area.""" - def __init__(self, data, name, icon, locale): + def __init__(self, data, name, icon, locale, entity_id): """Initialize the sensor.""" - super().__init__(data, name, icon, locale) + super().__init__(data, name, icon, locale, entity_id) self._symbol = None self._unit = None @property - def device_state_attributes(self): - """Return the device state attributes.""" - return merge_two_dicts(super().device_state_attributes, { - ATTR_POLLUTANT_SYMBOL: self._symbol, - ATTR_POLLUTANT_UNIT: self._unit - }) + def unique_id(self): + """Return a unique, HASS-friendly identifier for this entity.""" + return '{0}_main_pollutant'.format(self._entity_id) def update(self): """Update the status of the sensor.""" @@ -229,6 +244,11 @@ class MainPollutantSensor(AirVisualBaseSensor): self._unit = pollution_info.get('unit') self._symbol = symbol + self._attrs.update({ + ATTR_POLLUTANT_SYMBOL: self._symbol, + ATTR_POLLUTANT_UNIT: self._unit + }) + class AirVisualData(object): """Define an object to hold sensor data.""" @@ -252,7 +272,7 @@ class AirVisualData(object): @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Update with new AirVisual data.""" - import pyairvisual.exceptions as exceptions + from pyairvisual.exceptions import HTTPError try: if self.city and self.state and self.country: @@ -272,7 +292,7 @@ class AirVisualData(object): ATTR_REGION: resp.get('state'), ATTR_COUNTRY: resp.get('country') } - except exceptions.HTTPError as exc_info: + except HTTPError as exc_info: _LOGGER.error("Unable to retrieve data on this location: %s", self.__dict__) _LOGGER.debug(exc_info) diff --git a/homeassistant/components/sensor/alpha_vantage.py b/homeassistant/components/sensor/alpha_vantage.py index 6b224492ffb..81c84a7f918 100644 --- a/homeassistant/components/sensor/alpha_vantage.py +++ b/homeassistant/components/sensor/alpha_vantage.py @@ -15,7 +15,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['alpha_vantage==1.8.0'] +REQUIREMENTS = ['alpha_vantage==1.9.0'] _LOGGER = logging.getLogger(__name__) @@ -31,25 +31,13 @@ CONF_SYMBOL = 'symbol' CONF_SYMBOLS = 'symbols' CONF_TO = 'to' -DEFAULT_SYMBOL = { - CONF_CURRENCY: 'USD', - CONF_NAME: 'Google', - CONF_SYMBOL: 'GOOGL', -} - -DEFAULT_CURRENCY = { - CONF_FROM: 'BTC', - CONF_NAME: 'Bitcon', - CONF_TO: 'USD', -} - ICONS = { 'BTC': 'mdi:currency-btc', 'EUR': 'mdi:currency-eur', 'GBP': 'mdi:currency-gbp', 'INR': 'mdi:currency-inr', 'RUB': 'mdi:currency-rub', - 'TRY': 'mdi: currency-try', + 'TRY': 'mdi:currency-try', 'USD': 'mdi:currency-usd', } @@ -69,9 +57,9 @@ CURRENCY_SCHEMA = vol.Schema({ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_API_KEY): cv.string, - vol.Optional(CONF_FOREIGN_EXCHANGE, default=[DEFAULT_CURRENCY]): + vol.Optional(CONF_FOREIGN_EXCHANGE): vol.All(cv.ensure_list, [CURRENCY_SCHEMA]), - vol.Optional(CONF_SYMBOLS, default=[DEFAULT_SYMBOL]): + vol.Optional(CONF_SYMBOLS): vol.All(cv.ensure_list, [SYMBOL_SCHEMA]), }) @@ -83,6 +71,14 @@ def setup_platform(hass, config, add_devices, discovery_info=None): api_key = config.get(CONF_API_KEY) symbols = config.get(CONF_SYMBOLS) + conversions = config.get(CONF_FOREIGN_EXCHANGE) + + if not symbols and not conversions: + msg = 'Warning: No symbols or currencies configured.' + hass.components.persistent_notification.create( + msg, 'Sensor alpha_vantage') + _LOGGER.warning(msg) + return timeseries = TimeSeries(key=api_key) @@ -98,12 +94,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): dev.append(AlphaVantageSensor(timeseries, symbol)) forex = ForeignExchange(key=api_key) - for conversion in config.get(CONF_FOREIGN_EXCHANGE): + for conversion in conversions: from_cur = conversion.get(CONF_FROM) to_cur = conversion.get(CONF_TO) try: - _LOGGER.debug("Configuring forex %s - %s", - from_cur, to_cur) + _LOGGER.debug("Configuring forex %s - %s", from_cur, to_cur) forex.get_currency_exchange_rate( from_currency=from_cur, to_currency=to_cur) except ValueError as error: @@ -218,10 +213,8 @@ class AlphaVantageForeignExchange(Entity): def update(self): """Get the latest data and updates the states.""" _LOGGER.debug("Requesting new data for forex %s - %s", - self._from_currency, - self._to_currency) + self._from_currency, self._to_currency) self.values, _ = self._foreign_exchange.get_currency_exchange_rate( from_currency=self._from_currency, to_currency=self._to_currency) _LOGGER.debug("Received new data for forex %s - %s", - self._from_currency, - self._to_currency) + self._from_currency, self._to_currency) diff --git a/homeassistant/components/sensor/bme680.py b/homeassistant/components/sensor/bme680.py index 470d7749ea2..2dbda26ac32 100644 --- a/homeassistant/components/sensor/bme680.py +++ b/homeassistant/components/sensor/bme680.py @@ -116,7 +116,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): return -# pylint: disable=import-error +# pylint: disable=import-error, no-member def _setup_bme680(config): """Set up and configure the BME680 sensor.""" from smbus import SMBus diff --git a/homeassistant/components/sensor/bmw_connected_drive.py b/homeassistant/components/sensor/bmw_connected_drive.py new file mode 100644 index 00000000000..26bfd19e6fc --- /dev/null +++ b/homeassistant/components/sensor/bmw_connected_drive.py @@ -0,0 +1,99 @@ +""" +Reads vehicle status from BMW connected drive portal. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.bmw_connected_drive/ +""" +import logging +import asyncio + +from homeassistant.components.bmw_connected_drive import DOMAIN as BMW_DOMAIN +from homeassistant.helpers.entity import Entity + +DEPENDENCIES = ['bmw_connected_drive'] + +_LOGGER = logging.getLogger(__name__) + +LENGTH_ATTRIBUTES = [ + 'remaining_range_fuel', + 'mileage', + ] + +VALID_ATTRIBUTES = LENGTH_ATTRIBUTES + [ + 'remaining_fuel', +] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the BMW sensors.""" + accounts = hass.data[BMW_DOMAIN] + _LOGGER.debug('Found BMW accounts: %s', + ', '.join([a.name for a in accounts])) + devices = [] + for account in accounts: + for vehicle in account.account.vehicles: + for sensor in VALID_ATTRIBUTES: + device = BMWConnectedDriveSensor(account, vehicle, sensor) + devices.append(device) + add_devices(devices) + + +class BMWConnectedDriveSensor(Entity): + """Representation of a BMW vehicle sensor.""" + + def __init__(self, account, vehicle, attribute: str): + """Constructor.""" + self._vehicle = vehicle + self._account = account + self._attribute = attribute + self._state = None + self._unit_of_measurement = None + self._name = '{} {}'.format(self._vehicle.modelName, self._attribute) + + @property + def should_poll(self) -> bool: + """Data update is triggered from BMWConnectedDriveEntity.""" + return False + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor. + + The return type of this call depends on the attribute that + is configured. + """ + return self._state + + @property + def unit_of_measurement(self) -> str: + """Get the unit of measurement.""" + return self._unit_of_measurement + + def update(self) -> None: + """Read new state data from the library.""" + _LOGGER.debug('Updating %s', self.entity_id) + vehicle_state = self._vehicle.state + self._state = getattr(vehicle_state, self._attribute) + + if self._attribute in LENGTH_ATTRIBUTES: + self._unit_of_measurement = vehicle_state.unit_of_length + elif self._attribute == 'remaining_fuel': + self._unit_of_measurement = vehicle_state.unit_of_volume + else: + self._unit_of_measurement = None + + self.schedule_update_ha_state() + + @asyncio.coroutine + def async_added_to_hass(self): + """Add callback after being added to hass. + + Show latest data after startup. + """ + self._account.add_update_listener(self.update) + yield from self.hass.async_add_job(self.update) diff --git a/homeassistant/components/sensor/bom.py b/homeassistant/components/sensor/bom.py index 545bef12d83..272d5d1e0b8 100644 --- a/homeassistant/components/sensor/bom.py +++ b/homeassistant/components/sensor/bom.py @@ -88,7 +88,7 @@ def validate_station(station): PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Inclusive(CONF_ZONE_ID, 'Deprecated partial station ID'): cv.string, vol.Inclusive(CONF_WMO_ID, 'Deprecated partial station ID'): cv.string, - vol.Optional(CONF_NAME, default=None): cv.string, + vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_STATION): validate_station, vol.Required(CONF_MONITORED_CONDITIONS, default=[]): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), diff --git a/homeassistant/components/sensor/buienradar.py b/homeassistant/components/sensor/buienradar.py index 1b5cfc4b491..5d74f038eaa 100644 --- a/homeassistant/components/sensor/buienradar.py +++ b/homeassistant/components/sensor/buienradar.py @@ -23,7 +23,7 @@ from homeassistant.helpers.event import ( async_track_point_in_utc_time) from homeassistant.util import dt as dt_util -REQUIREMENTS = ['buienradar==0.9'] +REQUIREMENTS = ['buienradar==0.91'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/cups.py b/homeassistant/components/sensor/cups.py index f4d826c250d..7c1d9fc3d49 100644 --- a/homeassistant/components/sensor/cups.py +++ b/homeassistant/components/sensor/cups.py @@ -128,7 +128,7 @@ class CupsSensor(Entity): self._printer = self.data.printers.get(self._name) -# pylint: disable=import-error +# pylint: disable=import-error, no-name-in-module class CupsData(object): """Get the latest data from CUPS and update the state.""" diff --git a/homeassistant/components/sensor/daikin.py b/homeassistant/components/sensor/daikin.py index 0b2f6495b45..e045043e09c 100644 --- a/homeassistant/components/sensor/daikin.py +++ b/homeassistant/components/sensor/daikin.py @@ -23,8 +23,8 @@ from homeassistant.util.unit_system import UnitSystem PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_NAME, default=None): cv.string, - vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_TYPES.keys()): + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), }) diff --git a/homeassistant/components/sensor/deconz.py b/homeassistant/components/sensor/deconz.py index b3adaa412ff..b60df1c6ac9 100644 --- a/homeassistant/components/sensor/deconz.py +++ b/homeassistant/components/sensor/deconz.py @@ -6,7 +6,8 @@ https://home-assistant.io/components/sensor.deconz/ """ import asyncio -from homeassistant.components.deconz import DOMAIN as DECONZ_DATA +from homeassistant.components.deconz import ( + DOMAIN as DATA_DECONZ, DATA_DECONZ_ID) from homeassistant.const import ATTR_BATTERY_LEVEL, CONF_EVENT, CONF_ID from homeassistant.core import EventOrigin, callback from homeassistant.helpers.entity import Entity @@ -25,7 +26,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): return from pydeconz.sensor import DECONZ_SENSOR, SWITCH as DECONZ_REMOTE - sensors = hass.data[DECONZ_DATA].sensors + sensors = hass.data[DATA_DECONZ].sensors entities = [] for key in sorted(sensors.keys(), key=int): @@ -51,6 +52,7 @@ class DeconzSensor(Entity): def async_added_to_hass(self): """Subscribe to sensors events.""" self._sensor.register_async_callback(self.async_update_callback) + self.hass.data[DATA_DECONZ_ID][self.entity_id] = self._sensor.deconz_id @callback def async_update_callback(self, reason): @@ -127,6 +129,7 @@ class DeconzBattery(Entity): def async_added_to_hass(self): """Subscribe to sensors events.""" self._device.register_async_callback(self.async_update_callback) + self.hass.data[DATA_DECONZ_ID][self.entity_id] = self._device.deconz_id @callback def async_update_callback(self, reason): diff --git a/homeassistant/components/sensor/dovado.py b/homeassistant/components/sensor/dovado.py index eba6596efc4..ee2292d4122 100644 --- a/homeassistant/components/sensor/dovado.py +++ b/homeassistant/components/sensor/dovado.py @@ -79,7 +79,7 @@ class Dovado: def send_sms(service): """Send SMS through the router.""" - number = service.data.get('number'), + number = service.data.get('number') message = service.data.get('message') _LOGGER.debug("message for %s: %s", number, message) self._dovado.send_sms(number, message) diff --git a/homeassistant/components/sensor/dsmr.py b/homeassistant/components/sensor/dsmr.py index 32c888bad3b..cea29d437ae 100644 --- a/homeassistant/components/sensor/dsmr.py +++ b/homeassistant/components/sensor/dsmr.py @@ -9,13 +9,14 @@ from datetime import timedelta from functools import partial import logging +import voluptuous as vol + from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, STATE_UNKNOWN) from homeassistant.core import CoreState import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -import voluptuous as vol _LOGGER = logging.getLogger(__name__) @@ -38,7 +39,7 @@ RECONNECT_INTERVAL = 5 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.string, - vol.Optional(CONF_HOST, default=None): cv.string, + vol.Optional(CONF_HOST): cv.string, vol.Optional(CONF_DSMR_VERSION, default=DEFAULT_DSMR_VERSION): vol.All( cv.string, vol.In(['5', '4', '2.2'])), vol.Optional(CONF_RECONNECT_INTERVAL, default=30): int, @@ -95,7 +96,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): # Creates an asyncio.Protocol factory for reading DSMR telegrams from # serial and calls update_entities_telegram to update entities on arrival - if config[CONF_HOST]: + if CONF_HOST in config: reader_factory = partial( create_tcp_dsmr_reader, config[CONF_HOST], config[CONF_PORT], config[CONF_DSMR_VERSION], update_entities_telegram, diff --git a/homeassistant/components/sensor/dwd_weather_warnings.py b/homeassistant/components/sensor/dwd_weather_warnings.py index 0eeaa9424e8..9105e30eb42 100644 --- a/homeassistant/components/sensor/dwd_weather_warnings.py +++ b/homeassistant/components/sensor/dwd_weather_warnings.py @@ -47,8 +47,9 @@ MONITORED_CONDITIONS = { PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_REGION_NAME): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_MONITORED_CONDITIONS, default=MONITORED_CONDITIONS): - vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]), + vol.Optional(CONF_MONITORED_CONDITIONS, + default=list(MONITORED_CONDITIONS)): + vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]), }) @@ -137,11 +138,11 @@ class DwdWeatherWarningsSensor(Entity): data['warning_{}_name'.format(i)] = event['event'] data['warning_{}_level'.format(i)] = event['level'] data['warning_{}_type'.format(i)] = event['type'] - if len(event['headline']) > 0: + if event['headline']: data['warning_{}_headline'.format(i)] = event['headline'] - if len(event['description']) > 0: + if event['description']: data['warning_{}_description'.format(i)] = event['description'] - if len(event['instruction']) > 0: + if event['instruction']: data['warning_{}_instruction'.format(i)] = event['instruction'] if event['start'] is not None: diff --git a/homeassistant/components/sensor/envirophat.py b/homeassistant/components/sensor/envirophat.py index ce5e2a81939..b11dae8e168 100644 --- a/homeassistant/components/sensor/envirophat.py +++ b/homeassistant/components/sensor/envirophat.py @@ -45,7 +45,7 @@ SENSOR_TYPES = { } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_DISPLAY_OPTIONS, default=SENSOR_TYPES): + vol.Required(CONF_DISPLAY_OPTIONS, default=list(SENSOR_TYPES)): [vol.In(SENSOR_TYPES)], vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_USE_LEDS, default=False): cv.boolean diff --git a/homeassistant/components/sensor/fail2ban.py b/homeassistant/components/sensor/fail2ban.py index a343a59c314..87c301d34f5 100644 --- a/homeassistant/components/sensor/fail2ban.py +++ b/homeassistant/components/sensor/fail2ban.py @@ -33,9 +33,8 @@ STATE_CURRENT_BANS = 'current_bans' STATE_ALL_BANS = 'total_bans' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_JAILS, default=[]): - vol.All(cv.ensure_list, vol.Length(min=1)), - vol.Optional(CONF_FILE_PATH, default=DEFAULT_LOG): cv.isfile, + vol.Required(CONF_JAILS): vol.All(cv.ensure_list, vol.Length(min=1)), + vol.Optional(CONF_FILE_PATH): cv.isfile, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) @@ -46,7 +45,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): name = config.get(CONF_NAME) jails = config.get(CONF_JAILS) scan_interval = config.get(CONF_SCAN_INTERVAL) - log_file = config.get(CONF_FILE_PATH) + log_file = config.get(CONF_FILE_PATH, DEFAULT_LOG) device_list = [] log_parser = BanLogParser(scan_interval, log_file) diff --git a/homeassistant/components/sensor/fastdotcom.py b/homeassistant/components/sensor/fastdotcom.py index 02dd32c20af..9143ccaf23f 100644 --- a/homeassistant/components/sensor/fastdotcom.py +++ b/homeassistant/components/sensor/fastdotcom.py @@ -26,6 +26,8 @@ CONF_HOUR = 'hour' CONF_DAY = 'day' CONF_MANUAL = 'manual' +ICON = 'mdi:speedometer' + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_SECOND, default=[0]): vol.All(cv.ensure_list, [vol.All(vol.Coerce(int), vol.Range(0, 59))]), @@ -94,6 +96,11 @@ class SpeedtestSensor(Entity): return self._state = state.state + @property + def icon(self): + """Return icon.""" + return ICON + class SpeedtestData(object): """Get the latest data from fast.com.""" diff --git a/homeassistant/components/sensor/fedex.py b/homeassistant/components/sensor/fedex.py index 7991a94eb05..0c42ef28496 100644 --- a/homeassistant/components/sensor/fedex.py +++ b/homeassistant/components/sensor/fedex.py @@ -19,7 +19,7 @@ from homeassistant.util import Throttle from homeassistant.util.dt import now, parse_date import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['fedexdeliverymanager==1.0.4'] +REQUIREMENTS = ['fedexdeliverymanager==1.0.5'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/filesize.py b/homeassistant/components/sensor/filesize.py new file mode 100644 index 00000000000..a5a65f9bb5e --- /dev/null +++ b/homeassistant/components/sensor/filesize.py @@ -0,0 +1,92 @@ +""" +Sensor for monitoring the size of a file. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.filesize/ +""" +import datetime +import logging +import os + +import voluptuous as vol + +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA + +_LOGGER = logging.getLogger(__name__) + + +CONF_FILE_PATHS = 'file_paths' +ICON = 'mdi:file' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_FILE_PATHS): + vol.All(cv.ensure_list, [cv.isfile]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the file size sensor.""" + sensors = [] + for path in config.get(CONF_FILE_PATHS): + if not hass.config.is_allowed_path(path): + _LOGGER.error( + "Filepath %s is not valid or allowed", path) + continue + else: + sensors.append(Filesize(path)) + + if sensors: + add_devices(sensors, True) + + +class Filesize(Entity): + """Encapsulates file size information.""" + + def __init__(self, path): + """Initialize the data object.""" + self._path = path # Need to check its a valid path + self._size = None + self._last_updated = None + self._name = path.split("/")[-1] + self._unit_of_measurement = 'MB' + + def update(self): + """Update the sensor.""" + statinfo = os.stat(self._path) + self._size = statinfo.st_size + last_updated = datetime.datetime.fromtimestamp(statinfo.st_mtime) + self._last_updated = last_updated.isoformat() + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the size of the file in MB.""" + decimals = 2 + state_mb = round(self._size/1e6, decimals) + return state_mb + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return ICON + + @property + def device_state_attributes(self): + """Return other details about the sensor state.""" + attr = { + 'path': self._path, + 'last_updated': self._last_updated, + 'bytes': self._size, + } + return attr + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement diff --git a/homeassistant/components/sensor/folder.py b/homeassistant/components/sensor/folder.py new file mode 100644 index 00000000000..bd3957a36ca --- /dev/null +++ b/homeassistant/components/sensor/folder.py @@ -0,0 +1,108 @@ +""" +Sensor for monitoring the contents of a folder. + +For more details about this platform, refer to the documentation at +https://home-assistant.io/components/sensor.folder/ +""" +from datetime import timedelta +import glob +import logging +import os + +import voluptuous as vol + +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA + +_LOGGER = logging.getLogger(__name__) + +CONF_FOLDER_PATHS = 'folder' +CONF_FILTER = 'filter' +DEFAULT_FILTER = '*' + +SCAN_INTERVAL = timedelta(seconds=1) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_FOLDER_PATHS): cv.isdir, + vol.Optional(CONF_FILTER, default=DEFAULT_FILTER): cv.string, +}) + + +def get_files_list(folder_path, filter_term): + """Return the list of files, applying filter.""" + query = folder_path + filter_term + files_list = glob.glob(query) + return files_list + + +def get_size(files_list): + """Return the sum of the size in bytes of files in the list.""" + size_list = [os.stat(f).st_size for f in files_list] + return sum(size_list) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the folder sensor.""" + path = config.get(CONF_FOLDER_PATHS) + + if not hass.config.is_allowed_path(path): + _LOGGER.error("folder %s is not valid or allowed", path) + else: + folder = Folder(path, config.get(CONF_FILTER)) + add_devices([folder], True) + + +class Folder(Entity): + """Representation of a folder.""" + + ICON = 'mdi:folder' + + def __init__(self, folder_path, filter_term): + """Initialize the data object.""" + folder_path = os.path.join(folder_path, '') # If no trailing / add it + self._folder_path = folder_path # Need to check its a valid path + self._filter_term = filter_term + self._number_of_files = None + self._size = None + self._name = os.path.split(os.path.split(folder_path)[0])[1] + self._unit_of_measurement = 'MB' + + def update(self): + """Update the sensor.""" + files_list = get_files_list(self._folder_path, self._filter_term) + self._number_of_files = len(files_list) + self._size = get_size(files_list) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + decimals = 2 + size_mb = round(self._size/1e6, decimals) + return size_mb + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self.ICON + + @property + def device_state_attributes(self): + """Return other details about the sensor state.""" + attr = { + 'path': self._folder_path, + 'filter': self._filter_term, + 'number_of_files': self._number_of_files, + 'bytes': self._size, + } + return attr + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement diff --git a/homeassistant/components/sensor/fritzbox_netmonitor.py b/homeassistant/components/sensor/fritzbox_netmonitor.py index c7486b56c25..f4f774cad1e 100644 --- a/homeassistant/components/sensor/fritzbox_netmonitor.py +++ b/homeassistant/components/sensor/fritzbox_netmonitor.py @@ -6,6 +6,7 @@ https://home-assistant.io/components/sensor.fritzbox_netmonitor/ """ import logging from datetime import timedelta +from requests.exceptions import RequestException import voluptuous as vol @@ -15,8 +16,6 @@ from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle -from requests.exceptions import RequestException - REQUIREMENTS = ['fritzconnection==0.6.5'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/google_wifi.py b/homeassistant/components/sensor/google_wifi.py index d377c03d710..c070a3e990f 100644 --- a/homeassistant/components/sensor/google_wifi.py +++ b/homeassistant/components/sensor/google_wifi.py @@ -69,8 +69,9 @@ MONITORED_CONDITIONS = { PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, - vol.Optional(CONF_MONITORED_CONDITIONS, default=MONITORED_CONDITIONS): - vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]), + vol.Optional(CONF_MONITORED_CONDITIONS, + default=list(MONITORED_CONDITIONS)): + vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) diff --git a/homeassistant/components/sensor/gtfs.py b/homeassistant/components/sensor/gtfs.py index 9aa9f14663c..616144d2bc6 100644 --- a/homeassistant/components/sensor/gtfs.py +++ b/homeassistant/components/sensor/gtfs.py @@ -39,8 +39,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_DESTINATION): cv.string, vol.Required(CONF_DATA): cv.string, vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_OFFSET, default=datetime.timedelta(0)): - cv.time_period_dict, + vol.Optional(CONF_OFFSET, default=0): cv.time_period, }) diff --git a/homeassistant/components/sensor/history_stats.py b/homeassistant/components/sensor/history_stats.py index 175bdafd4a9..de7b7ebaf9e 100644 --- a/homeassistant/components/sensor/history_stats.py +++ b/homeassistant/components/sensor/history_stats.py @@ -49,13 +49,7 @@ ATTR_VALUE = 'value' def exactly_two_period_keys(conf): """Ensure exactly 2 of CONF_PERIOD_KEYS are provided.""" - provided = 0 - - for param in CONF_PERIOD_KEYS: - if param in conf and conf[param] is not None: - provided += 1 - - if provided != 2: + if sum(param in conf for param in CONF_PERIOD_KEYS) != 2: raise vol.Invalid('You must provide exactly 2 of the following:' ' start, end, duration') return conf @@ -64,9 +58,9 @@ def exactly_two_period_keys(conf): PLATFORM_SCHEMA = vol.All(PLATFORM_SCHEMA.extend({ vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Required(CONF_STATE): cv.string, - vol.Optional(CONF_START, default=None): cv.template, - vol.Optional(CONF_END, default=None): cv.template, - vol.Optional(CONF_DURATION, default=None): cv.time_period, + vol.Optional(CONF_START): cv.template, + vol.Optional(CONF_END): cv.template, + vol.Optional(CONF_DURATION): cv.time_period, vol.Optional(CONF_TYPE, default=CONF_TYPE_TIME): vol.In(CONF_TYPE_KEYS), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }), exactly_two_period_keys) diff --git a/homeassistant/components/sensor/hp_ilo.py b/homeassistant/components/sensor/hp_ilo.py index 016d68b3b0e..922ed04a8d9 100644 --- a/homeassistant/components/sensor/hp_ilo.py +++ b/homeassistant/components/sensor/hp_ilo.py @@ -51,8 +51,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_NAME): cv.string, vol.Required(CONF_SENSOR_TYPE): vol.All(cv.string, vol.In(SENSOR_TYPES)), - vol.Optional(CONF_UNIT_OF_MEASUREMENT, default=None): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE, default=None): cv.template + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template })]), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, @@ -85,8 +85,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): sensor_name='{} {}'.format( config.get(CONF_NAME), monitored_variable[CONF_NAME]), sensor_type=monitored_variable[CONF_SENSOR_TYPE], - sensor_value_template=monitored_variable[CONF_VALUE_TEMPLATE], - unit_of_measurement=monitored_variable[CONF_UNIT_OF_MEASUREMENT]) + sensor_value_template=monitored_variable.get(CONF_VALUE_TEMPLATE), + unit_of_measurement=monitored_variable.get( + CONF_UNIT_OF_MEASUREMENT)) devices.append(new_device) add_devices(devices, True) @@ -172,4 +173,4 @@ class HpIloData(object): password=self._password, port=self._port) except (hpilo.IloError, hpilo.IloCommunicationError, hpilo.IloLoginFailed) as error: - raise ValueError("Unable to init HP ILO, %s", error) + raise ValueError("Unable to init HP ILO, {}".format(error)) diff --git a/homeassistant/components/sensor/ihc.py b/homeassistant/components/sensor/ihc.py index b6440a407a4..b30a242c17c 100644 --- a/homeassistant/components/sensor/ihc.py +++ b/homeassistant/components/sensor/ihc.py @@ -62,7 +62,7 @@ class IHCSensor(IHCDevice, Entity): """Implementation of the IHC sensor.""" def __init__(self, ihc_controller, name, ihc_id: int, info: bool, - unit, product: Element=None) -> None: + unit, product: Element = None) -> None: """Initialize the IHC sensor.""" super().__init__(ihc_controller, name, ihc_id, info, product) self._state = None diff --git a/homeassistant/components/sensor/irish_rail_transport.py b/homeassistant/components/sensor/irish_rail_transport.py index 0c34a5f6ce8..603d82359de 100644 --- a/homeassistant/components/sensor/irish_rail_transport.py +++ b/homeassistant/components/sensor/irish_rail_transport.py @@ -42,9 +42,9 @@ TIME_STR_FORMAT = '%H:%M' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_STATION): cv.string, - vol.Optional(CONF_DIRECTION, default=None): cv.string, - vol.Optional(CONF_DESTINATION, default=None): cv.string, - vol.Optional(CONF_STOPS_AT, default=None): cv.string, + vol.Optional(CONF_DIRECTION): cv.string, + vol.Optional(CONF_DESTINATION): cv.string, + vol.Optional(CONF_STOPS_AT): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string }) @@ -92,7 +92,7 @@ class IrishRailTransportSensor(Entity): @property def device_state_attributes(self): """Return the state attributes.""" - if len(self._times) > 0: + if self._times: next_up = "None" if len(self._times) > 1: next_up = self._times[1][ATTR_ORIGIN] + " to " @@ -126,7 +126,7 @@ class IrishRailTransportSensor(Entity): """Get the latest data and update the states.""" self.data.update() self._times = self.data.info - if len(self._times) > 0: + if self._times: self._state = self._times[0][ATTR_DUE_IN] else: self._state = None @@ -164,7 +164,7 @@ class IrishRailTransportData(object): ATTR_TRAIN_TYPE: train.get('type')} self.info.append(train_data) - if not self.info or len(self.info) == 0: + if not self.info or not self.info: self.info = self._empty_train_data() def _empty_train_data(self): diff --git a/homeassistant/components/sensor/isy994.py b/homeassistant/components/sensor/isy994.py index 39c9d8a3b9d..c34a4a8fca7 100644 --- a/homeassistant/components/sensor/isy994.py +++ b/homeassistant/components/sensor/isy994.py @@ -254,10 +254,6 @@ def setup_platform(hass, config: ConfigType, class ISYSensorDevice(ISYDevice): """Representation of an ISY994 sensor device.""" - def __init__(self, node) -> None: - """Initialize the ISY994 sensor device.""" - super().__init__(node) - @property def raw_unit_of_measurement(self) -> str: """Get the raw unit of measurement for the ISY994 sensor device.""" @@ -313,10 +309,6 @@ class ISYSensorDevice(ISYDevice): class ISYWeatherDevice(ISYDevice): """Representation of an ISY994 weather device.""" - def __init__(self, node) -> None: - """Initialize the ISY994 weather device.""" - super().__init__(node) - @property def raw_units(self) -> str: """Return the raw unit of measurement.""" diff --git a/homeassistant/components/sensor/knx.py b/homeassistant/components/sensor/knx.py index 70afa6fe1e1..bdceb729e89 100644 --- a/homeassistant/components/sensor/knx.py +++ b/homeassistant/components/sensor/knx.py @@ -31,9 +31,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up sensor(s) for KNX platform.""" - if DATA_KNX not in hass.data or not hass.data[DATA_KNX].initialized: - return - if discovery_info is not None: async_add_devices_discovery(hass, discovery_info, async_add_devices) else: diff --git a/homeassistant/components/sensor/lacrosse.py b/homeassistant/components/sensor/lacrosse.py index b402fc5c70f..3e0a5af283f 100644 --- a/homeassistant/components/sensor/lacrosse.py +++ b/homeassistant/components/sensor/lacrosse.py @@ -133,10 +133,6 @@ class LaCrosseSensor(Entity): """Return the name of the sensor.""" return self._name - def update(self, *args): - """Get the latest data.""" - pass - @property def device_state_attributes(self): """Return the state attributes.""" diff --git a/homeassistant/components/sensor/linux_battery.py b/homeassistant/components/sensor/linux_battery.py index 89647d258b4..3d28c44d606 100644 --- a/homeassistant/components/sensor/linux_battery.py +++ b/homeassistant/components/sensor/linux_battery.py @@ -119,24 +119,23 @@ class LinuxBatterySensor(Entity): ATTR_HEALTH: self._battery_stat.health, ATTR_STATUS: self._battery_stat.status, } - else: - return { - ATTR_NAME: self._battery_stat.name, - ATTR_PATH: self._battery_stat.path, - ATTR_ALARM: self._battery_stat.alarm, - ATTR_CAPACITY_LEVEL: self._battery_stat.capacity_level, - ATTR_CYCLE_COUNT: self._battery_stat.cycle_count, - ATTR_ENERGY_FULL: self._battery_stat.energy_full, - ATTR_ENERGY_FULL_DESIGN: self._battery_stat.energy_full_design, - ATTR_ENERGY_NOW: self._battery_stat.energy_now, - ATTR_MANUFACTURER: self._battery_stat.manufacturer, - ATTR_MODEL_NAME: self._battery_stat.model_name, - ATTR_POWER_NOW: self._battery_stat.power_now, - ATTR_SERIAL_NUMBER: self._battery_stat.serial_number, - ATTR_STATUS: self._battery_stat.status, - ATTR_VOLTAGE_MIN_DESIGN: self._battery_stat.voltage_min_design, - ATTR_VOLTAGE_NOW: self._battery_stat.voltage_now, - } + return { + ATTR_NAME: self._battery_stat.name, + ATTR_PATH: self._battery_stat.path, + ATTR_ALARM: self._battery_stat.alarm, + ATTR_CAPACITY_LEVEL: self._battery_stat.capacity_level, + ATTR_CYCLE_COUNT: self._battery_stat.cycle_count, + ATTR_ENERGY_FULL: self._battery_stat.energy_full, + ATTR_ENERGY_FULL_DESIGN: self._battery_stat.energy_full_design, + ATTR_ENERGY_NOW: self._battery_stat.energy_now, + ATTR_MANUFACTURER: self._battery_stat.manufacturer, + ATTR_MODEL_NAME: self._battery_stat.model_name, + ATTR_POWER_NOW: self._battery_stat.power_now, + ATTR_SERIAL_NUMBER: self._battery_stat.serial_number, + ATTR_STATUS: self._battery_stat.status, + ATTR_VOLTAGE_MIN_DESIGN: self._battery_stat.voltage_min_design, + ATTR_VOLTAGE_NOW: self._battery_stat.voltage_now, + } def update(self): """Get the latest data and updates the states.""" diff --git a/homeassistant/components/sensor/loopenergy.py b/homeassistant/components/sensor/loopenergy.py index a2d6b0c3a0c..5be24b1532c 100644 --- a/homeassistant/components/sensor/loopenergy.py +++ b/homeassistant/components/sensor/loopenergy.py @@ -51,10 +51,8 @@ GAS_SCHEMA = vol.Schema({ }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_ELEC): vol.All( - dict, ELEC_SCHEMA), - vol.Optional(CONF_GAS, default={}): vol.All( - dict, GAS_SCHEMA) + vol.Required(CONF_ELEC): ELEC_SCHEMA, + vol.Optional(CONF_GAS): GAS_SCHEMA }) @@ -63,7 +61,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): import pyloopenergy elec_config = config.get(CONF_ELEC) - gas_config = config.get(CONF_GAS) + gas_config = config.get(CONF_GAS, {}) # pylint: disable=too-many-function-args controller = pyloopenergy.LoopEnergy( diff --git a/homeassistant/components/sensor/lyft.py b/homeassistant/components/sensor/lyft.py index 0efc4063dc2..c2f6412049c 100644 --- a/homeassistant/components/sensor/lyft.py +++ b/homeassistant/components/sensor/lyft.py @@ -37,7 +37,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_START_LONGITUDE): cv.longitude, vol.Optional(CONF_END_LATITUDE): cv.latitude, vol.Optional(CONF_END_LONGITUDE): cv.longitude, - vol.Optional(CONF_PRODUCT_IDS, default=None): + vol.Optional(CONF_PRODUCT_IDS): vol.All(cv.ensure_list, [cv.string]), }) diff --git a/homeassistant/components/sensor/melissa.py b/homeassistant/components/sensor/melissa.py index 58313428861..f67722b0198 100644 --- a/homeassistant/components/sensor/melissa.py +++ b/homeassistant/components/sensor/melissa.py @@ -22,8 +22,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = api.fetch_devices().values() for device in devices: - sensors.append(MelissaTemperatureSensor(device, api)) - sensors.append(MelissaHumiditySensor(device, api)) + if device['type'] == 'melissa': + sensors.append(MelissaTemperatureSensor(device, api)) + sensors.append(MelissaHumiditySensor(device, api)) add_devices(sensors) diff --git a/homeassistant/components/sensor/miflora.py b/homeassistant/components/sensor/miflora.py index ec68588f241..37976151190 100644 --- a/homeassistant/components/sensor/miflora.py +++ b/homeassistant/components/sensor/miflora.py @@ -46,7 +46,7 @@ SENSOR_TYPES = { PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_MAC): cv.string, - vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_TYPES): + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_MEDIAN, default=DEFAULT_MEDIAN): cv.positive_int, diff --git a/homeassistant/components/sensor/mold_indicator.py b/homeassistant/components/sensor/mold_indicator.py index b47367cafc8..057718400c4 100644 --- a/homeassistant/components/sensor/mold_indicator.py +++ b/homeassistant/components/sensor/mold_indicator.py @@ -174,7 +174,7 @@ class MoldIndicator(Entity): self._dewpoint = \ MAGNUS_K3 * (alpha + math.log(self._indoor_hum / 100.0)) / \ (beta - math.log(self._indoor_hum / 100.0)) - _LOGGER.debug("Dewpoint: %f " + TEMP_CELSIUS, self._dewpoint) + _LOGGER.debug("Dewpoint: %f %s", self._dewpoint, TEMP_CELSIUS) def _calc_moldindicator(self): """Calculate the humidity at the (cold) calibration point.""" @@ -192,8 +192,8 @@ class MoldIndicator(Entity): self._outdoor_temp + (self._indoor_temp - self._outdoor_temp) / \ self._calib_factor - _LOGGER.debug("Estimated Critical Temperature: %f " + - TEMP_CELSIUS, self._crit_temp) + _LOGGER.debug("Estimated Critical Temperature: %f %s", + self._crit_temp, TEMP_CELSIUS) # Then calculate the humidity at this point alpha = MAGNUS_K2 * self._crit_temp / (MAGNUS_K3 + self._crit_temp) diff --git a/homeassistant/components/sensor/nederlandse_spoorwegen.py b/homeassistant/components/sensor/nederlandse_spoorwegen.py index ec534047ccc..431a44c56e3 100644 --- a/homeassistant/components/sensor/nederlandse_spoorwegen.py +++ b/homeassistant/components/sensor/nederlandse_spoorwegen.py @@ -71,7 +71,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): NSDepartureSensor( nsapi, departure.get(CONF_NAME), departure.get(CONF_FROM), departure.get(CONF_TO), departure.get(CONF_VIA))) - if len(sensors): + if sensors: add_devices(sensors, True) diff --git a/homeassistant/components/sensor/nut.py b/homeassistant/components/sensor/nut.py index a9fb3ae7a6f..e0d5b7250e9 100644 --- a/homeassistant/components/sensor/nut.py +++ b/homeassistant/components/sensor/nut.py @@ -126,9 +126,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_ALIAS, default=None): cv.string, - vol.Optional(CONF_USERNAME, default=None): cv.string, - vol.Optional(CONF_PASSWORD, default=None): cv.string, + vol.Optional(CONF_ALIAS): cv.string, + vol.Optional(CONF_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, vol.Required(CONF_RESOURCES, default=[]): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), }) diff --git a/homeassistant/components/sensor/octoprint.py b/homeassistant/components/sensor/octoprint.py index 71b72b0a671..8a800e8616c 100644 --- a/homeassistant/components/sensor/octoprint.py +++ b/homeassistant/components/sensor/octoprint.py @@ -32,7 +32,7 @@ SENSOR_TYPES = { } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_TYPES): + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) diff --git a/homeassistant/components/sensor/openweathermap.py b/homeassistant/components/sensor/openweathermap.py index 49280efe718..96db4430d32 100644 --- a/homeassistant/components/sensor/openweathermap.py +++ b/homeassistant/components/sensor/openweathermap.py @@ -47,7 +47,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_FORECAST, default=False): cv.boolean, - vol.Optional(CONF_LANGUAGE, default=None): cv.string, + vol.Optional(CONF_LANGUAGE): cv.string, }) diff --git a/homeassistant/components/sensor/pi_hole.py b/homeassistant/components/sensor/pi_hole.py index 0b2f43195a6..027c12569a6 100644 --- a/homeassistant/components/sensor/pi_hole.py +++ b/homeassistant/components/sensor/pi_hole.py @@ -59,8 +59,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, vol.Optional(CONF_LOCATION, default=DEFAULT_LOCATION): cv.string, vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, - vol.Optional(CONF_MONITORED_CONDITIONS, default=MONITORED_CONDITIONS): - vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]), + vol.Optional(CONF_MONITORED_CONDITIONS, + default=list(MONITORED_CONDITIONS)): + vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]), }) diff --git a/homeassistant/components/sensor/pilight.py b/homeassistant/components/sensor/pilight.py index 5b5385f14ef..596887998ec 100644 --- a/homeassistant/components/sensor/pilight.py +++ b/homeassistant/components/sensor/pilight.py @@ -26,7 +26,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_VARIABLE): cv.string, vol.Required(CONF_PAYLOAD): vol.Schema(dict), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_UNIT_OF_MEASUREMENT, default=None): cv.string, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, }) diff --git a/homeassistant/components/sensor/pollen.py b/homeassistant/components/sensor/pollen.py index 3998af7e32f..0771e7cbd2e 100644 --- a/homeassistant/components/sensor/pollen.py +++ b/homeassistant/components/sensor/pollen.py @@ -16,7 +16,7 @@ from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_STATE, CONF_MONITORED_CONDITIONS ) from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle +from homeassistant.util import Throttle, slugify REQUIREMENTS = ['pypollencom==1.1.1'] _LOGGER = logging.getLogger(__name__) @@ -125,6 +125,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): 'allergy_index_data': AllergyIndexData(client), 'disease_average_data': DiseaseData(client) } + classes = { + 'AllergyAverageSensor': AllergyAverageSensor, + 'AllergyIndexSensor': AllergyIndexSensor + } for data in datas.values(): data.update() @@ -132,11 +136,12 @@ def setup_platform(hass, config, add_devices, discovery_info=None): sensors = [] for condition in config[CONF_MONITORED_CONDITIONS]: name, sensor_class, data_key, params, icon = CONDITIONS[condition] - sensors.append(globals()[sensor_class]( + sensors.append(classes[sensor_class]( datas[data_key], params, name, - icon + icon, + config[CONF_ZIP_CODE] )) add_devices(sensors, True) @@ -154,7 +159,7 @@ def calculate_trend(list_of_nums): class BaseSensor(Entity): """Define a base class for all of our sensors.""" - def __init__(self, data, data_params, name, icon): + def __init__(self, data, data_params, name, icon, unique_id): """Initialize the sensor.""" self._attrs = {} self._icon = icon @@ -162,6 +167,7 @@ class BaseSensor(Entity): self._data_params = data_params self._state = None self._unit = None + self._unique_id = unique_id self.data = data @property @@ -185,6 +191,11 @@ class BaseSensor(Entity): """Return the state.""" return self._state + @property + def unique_id(self): + """Return a unique, HASS-friendly identifier for this entity.""" + return '{0}_{1}'.format(self._unique_id, slugify(self._name)) + @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" diff --git a/homeassistant/components/sensor/qnap.py b/homeassistant/components/sensor/qnap.py index 3caebad2007..09c9938f1c1 100644 --- a/homeassistant/components/sensor/qnap.py +++ b/homeassistant/components/sensor/qnap.py @@ -7,6 +7,8 @@ https://home-assistant.io/components/sensor.qnap/ import logging from datetime import timedelta +import voluptuous as vol + from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.helpers.entity import Entity from homeassistant.const import ( @@ -15,8 +17,6 @@ from homeassistant.const import ( from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -import voluptuous as vol - REQUIREMENTS = ['qnapstats==0.2.4'] _LOGGER = logging.getLogger(__name__) @@ -97,9 +97,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_MONITORED_CONDITIONS): vol.All(cv.ensure_list, [vol.In(_MONITORED_CONDITIONS)]), - vol.Optional(CONF_NICS, default=None): cv.ensure_list, - vol.Optional(CONF_DRIVES, default=None): cv.ensure_list, - vol.Optional(CONF_VOLUMES, default=None): cv.ensure_list, + vol.Optional(CONF_NICS): cv.ensure_list, + vol.Optional(CONF_DRIVES): cv.ensure_list, + vol.Optional(CONF_VOLUMES): cv.ensure_list, }) @@ -133,33 +133,21 @@ def setup_platform(hass, config, add_devices, discovery_info=None): api, variable, _MEMORY_MON_COND[variable])) # Network sensors - nics = config[CONF_NICS] - if nics is None: - nics = api.data["system_stats"]["nics"].keys() - - for nic in nics: + for nic in config.get(CONF_NICS, api.data["system_stats"]["nics"]): sensors += [QNAPNetworkSensor(api, variable, _NETWORK_MON_COND[variable], nic) for variable in config[CONF_MONITORED_CONDITIONS] if variable in _NETWORK_MON_COND] # Drive sensors - drives = config[CONF_DRIVES] - if drives is None: - drives = api.data["smart_drive_health"].keys() - - for drive in drives: + for drive in config.get(CONF_DRIVES, api.data["smart_drive_health"]): sensors += [QNAPDriveSensor(api, variable, _DRIVE_MON_COND[variable], drive) for variable in config[CONF_MONITORED_CONDITIONS] if variable in _DRIVE_MON_COND] # Volume sensors - volumes = config[CONF_VOLUMES] - if volumes is None: - volumes = api.data["volumes"].keys() - - for volume in volumes: + for volume in config.get(CONF_VOLUMES, api.data["volumes"]): sensors += [QNAPVolumeSensor(api, variable, _VOLUME_MON_COND[variable], volume) for variable in config[CONF_MONITORED_CONDITIONS] diff --git a/homeassistant/components/sensor/rflink.py b/homeassistant/components/sensor/rflink.py index 0d5fc283e32..80d77033bbb 100644 --- a/homeassistant/components/sensor/rflink.py +++ b/homeassistant/components/sensor/rflink.py @@ -35,7 +35,7 @@ PLATFORM_SCHEMA = vol.Schema({ cv.string: { vol.Optional(CONF_NAME): cv.string, vol.Required(CONF_SENSOR_TYPE): cv.string, - vol.Optional(CONF_UNIT_OF_MEASUREMENT, default=None): cv.string, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_ALIASES, default=[]): vol.All(cv.ensure_list, [cv.string]), # deprecated config options @@ -61,7 +61,7 @@ def devices_from_config(domain_config, hass=None): """Parse configuration and add Rflink sensor devices.""" devices = [] for device_id, config in domain_config[CONF_DEVICES].items(): - if not config[ATTR_UNIT_OF_MEASUREMENT]: + if ATTR_UNIT_OF_MEASUREMENT not in config: config[ATTR_UNIT_OF_MEASUREMENT] = lookup_unit_for_sensor_type( config[CONF_SENSOR_TYPE]) remove_deprecated(config) diff --git a/homeassistant/components/sensor/rfxtrx.py b/homeassistant/components/sensor/rfxtrx.py index 1696e8e3770..4a555905d50 100644 --- a/homeassistant/components/sensor/rfxtrx.py +++ b/homeassistant/components/sensor/rfxtrx.py @@ -71,14 +71,19 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if device_id in rfxtrx.RFX_DEVICES: sensors = rfxtrx.RFX_DEVICES[device_id] - for key in sensors: - sensor = sensors[key] + for data_type in sensors: + # Some multi-sensor devices send individual messages for each + # of their sensors. Update only if event contains the + # right data_type for the sensor. + if data_type not in event.values: + continue + sensor = sensors[data_type] sensor.event = event # Fire event - if sensors[key].should_fire_event: + if sensor.should_fire_event: sensor.hass.bus.fire( "signal_received", { - ATTR_ENTITY_ID: sensors[key].entity_id, + ATTR_ENTITY_ID: sensor.entity_id, } ) return diff --git a/homeassistant/components/sensor/sabnzbd.py b/homeassistant/components/sensor/sabnzbd.py index 9ce2da09451..632e1ed5c1d 100644 --- a/homeassistant/components/sensor/sabnzbd.py +++ b/homeassistant/components/sensor/sabnzbd.py @@ -4,6 +4,7 @@ Support for monitoring an SABnzbd NZB client. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.sabnzbd/ """ +import asyncio import logging from datetime import timedelta @@ -18,13 +19,10 @@ from homeassistant.util import Throttle from homeassistant.util.json import load_json, save_json import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['https://github.com/jamespcole/home-assistant-nzb-clients/' - 'archive/616cad59154092599278661af17e2a9f2cf5e2a9.zip' - '#python-sabnzbd==0.1'] +REQUIREMENTS = ['pysabnzbd==0.0.3'] _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) -_THROTTLED_REFRESH = None CONFIG_FILE = 'sabnzbd.conf' DEFAULT_NAME = 'SABnzbd' @@ -54,38 +52,42 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -def _check_sabnzbd(sab_api, base_url, api_key): +@asyncio.coroutine +def async_check_sabnzbd(sab_api, base_url, api_key): """Check if we can reach SABnzbd.""" from pysabnzbd import SabnzbdApiException sab_api = sab_api(base_url, api_key) try: - sab_api.check_available() + yield from sab_api.check_available() except SabnzbdApiException: _LOGGER.error("Connection to SABnzbd API failed") return False return True -def setup_sabnzbd(base_url, apikey, name, hass, config, add_devices, sab_api): +def setup_sabnzbd(base_url, apikey, name, config, + async_add_devices, sab_api): """Set up polling from SABnzbd and sensors.""" sab_api = sab_api(base_url, apikey) - # Add minimal info to the front end - monitored = config.get(CONF_MONITORED_VARIABLES, ['current_status']) - - # pylint: disable=global-statement - global _THROTTLED_REFRESH - _THROTTLED_REFRESH = Throttle( - MIN_TIME_BETWEEN_UPDATES)(sab_api.refresh_queue) - - devices = [] - for variable in monitored: - devices.append(SabnzbdSensor(variable, sab_api, name)) - - add_devices(devices) + monitored = config.get(CONF_MONITORED_VARIABLES) + async_add_devices([SabnzbdSensor(variable, sab_api, name) + for variable in monitored]) -def request_configuration(host, name, hass, config, add_devices, sab_api): +@asyncio.coroutine +@Throttle(MIN_TIME_BETWEEN_UPDATES) +def async_update_queue(sab_api): + """ + Throttled function to update SABnzbd queue. + + This ensures that the queue info only gets updated once for all sensors + """ + yield from sab_api.refresh_queue() + + +def request_configuration(host, name, hass, config, async_add_devices, + sab_api): """Request configuration steps from the user.""" configurator = hass.components.configurator # We got an error if this method is called while we are configuring @@ -95,12 +97,13 @@ def request_configuration(host, name, hass, config, add_devices, sab_api): return - def sabnzbd_configuration_callback(data): + @asyncio.coroutine + def async_configuration_callback(data): """Handle configuration changes.""" api_key = data.get('api_key') - if _check_sabnzbd(sab_api, host, api_key): - setup_sabnzbd(host, api_key, name, - hass, config, add_devices, sab_api) + if (yield from async_check_sabnzbd(sab_api, host, api_key)): + setup_sabnzbd(host, api_key, name, config, + async_add_devices, sab_api) def success(): """Set up was successful.""" @@ -108,23 +111,21 @@ def request_configuration(host, name, hass, config, add_devices, sab_api): conf[host] = {'api_key': api_key} save_json(hass.config.path(CONFIG_FILE), conf) req_config = _CONFIGURING.pop(host) - hass.async_add_job(configurator.request_done, req_config) + configurator.async_request_done(req_config) hass.async_add_job(success) - _CONFIGURING[host] = configurator.request_config( + _CONFIGURING[host] = configurator.async_request_config( DEFAULT_NAME, - sabnzbd_configuration_callback, - description=('Enter the API Key'), + async_configuration_callback, + description='Enter the API Key', submit_caption='Confirm', - fields=[{ - 'id': 'api_key', - 'name': 'API Key', - 'type': ''}] + fields=[{'id': 'api_key', 'name': 'API Key', 'type': ''}] ) -def setup_platform(hass, config, add_devices, discovery_info=None): +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the SABnzbd platform.""" from pysabnzbd import SabnzbdApi @@ -139,31 +140,32 @@ def setup_platform(hass, config, add_devices, discovery_info=None): name = config.get(CONF_NAME, DEFAULT_NAME) use_ssl = config.get(CONF_SSL) + api_key = config.get(CONF_API_KEY) + uri_scheme = 'https://' if use_ssl else 'http://' base_url = "{}{}:{}/".format(uri_scheme, host, port) - api_key = config.get(CONF_API_KEY) if not api_key: conf = load_json(hass.config.path(CONFIG_FILE)) if conf.get(base_url, {}).get('api_key'): api_key = conf[base_url]['api_key'] - if not _check_sabnzbd(SabnzbdApi, base_url, api_key): + if not (yield from async_check_sabnzbd(SabnzbdApi, base_url, api_key)): request_configuration(base_url, name, hass, config, - add_devices, SabnzbdApi) + async_add_devices, SabnzbdApi) return - setup_sabnzbd(base_url, api_key, name, hass, - config, add_devices, SabnzbdApi) + setup_sabnzbd(base_url, api_key, name, config, + async_add_devices, SabnzbdApi) class SabnzbdSensor(Entity): """Representation of an SABnzbd sensor.""" - def __init__(self, sensor_type, sabnzb_client, client_name): + def __init__(self, sensor_type, sabnzbd_api, client_name): """Initialize the sensor.""" self._name = SENSOR_TYPES[sensor_type][0] - self.sabnzb_client = sabnzb_client + self.sabnzbd_api = sabnzbd_api self.type = sensor_type self.client_name = client_name self._state = None @@ -184,35 +186,35 @@ class SabnzbdSensor(Entity): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement - # pylint: disable=no-self-use - def refresh_sabnzbd_data(self): + @asyncio.coroutine + def async_refresh_sabnzbd_data(self): """Call the throttled SABnzbd refresh method.""" - if _THROTTLED_REFRESH is not None: - from pysabnzbd import SabnzbdApiException - try: - _THROTTLED_REFRESH() - except SabnzbdApiException: - _LOGGER.exception("Connection to SABnzbd API failed") + from pysabnzbd import SabnzbdApiException + try: + yield from async_update_queue(self.sabnzbd_api) + except SabnzbdApiException: + _LOGGER.exception("Connection to SABnzbd API failed") - def update(self): + @asyncio.coroutine + def async_update(self): """Get the latest data and updates the states.""" - self.refresh_sabnzbd_data() + yield from self.async_refresh_sabnzbd_data() - if self.sabnzb_client.queue: + if self.sabnzbd_api.queue: if self.type == 'current_status': - self._state = self.sabnzb_client.queue.get('status') + self._state = self.sabnzbd_api.queue.get('status') elif self.type == 'speed': - mb_spd = float(self.sabnzb_client.queue.get('kbpersec')) / 1024 + mb_spd = float(self.sabnzbd_api.queue.get('kbpersec')) / 1024 self._state = round(mb_spd, 1) elif self.type == 'queue_size': - self._state = self.sabnzb_client.queue.get('mb') + self._state = self.sabnzbd_api.queue.get('mb') elif self.type == 'queue_remaining': - self._state = self.sabnzb_client.queue.get('mbleft') + self._state = self.sabnzbd_api.queue.get('mbleft') elif self.type == 'disk_size': - self._state = self.sabnzb_client.queue.get('diskspacetotal1') + self._state = self.sabnzbd_api.queue.get('diskspacetotal1') elif self.type == 'disk_free': - self._state = self.sabnzb_client.queue.get('diskspace1') + self._state = self.sabnzbd_api.queue.get('diskspace1') elif self.type == 'queue_count': - self._state = self.sabnzb_client.queue.get('noofslots_total') + self._state = self.sabnzbd_api.queue.get('noofslots_total') else: self._state = 'Unknown' diff --git a/homeassistant/components/sensor/sensehat.py b/homeassistant/components/sensor/sensehat.py index db6d931d1b2..a50f4cdfd2c 100644 --- a/homeassistant/components/sensor/sensehat.py +++ b/homeassistant/components/sensor/sensehat.py @@ -32,7 +32,7 @@ SENSOR_TYPES = { } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_DISPLAY_OPTIONS, default=SENSOR_TYPES): + vol.Required(CONF_DISPLAY_OPTIONS, default=list(SENSOR_TYPES)): [vol.In(SENSOR_TYPES)], vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_IS_HAT_ATTACHED, default=True): cv.boolean diff --git a/homeassistant/components/sensor/skybeacon.py b/homeassistant/components/sensor/skybeacon.py index 3c14625202e..eabc33312b2 100644 --- a/homeassistant/components/sensor/skybeacon.py +++ b/homeassistant/components/sensor/skybeacon.py @@ -140,7 +140,7 @@ class Monitor(threading.Thread): def run(self): """Thread that keeps connection alive.""" - # pylint: disable=import-error + # pylint: disable=import-error, no-name-in-module, no-member import pygatt from pygatt.backends import Characteristic from pygatt.exceptions import ( diff --git a/homeassistant/components/sensor/sma.py b/homeassistant/components/sensor/sma.py index 2f3a29efbc0..3451789424b 100644 --- a/homeassistant/components/sensor/sma.py +++ b/homeassistant/components/sensor/sma.py @@ -12,13 +12,14 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - EVENT_HOMEASSISTANT_STOP, CONF_HOST, CONF_PASSWORD, CONF_SCAN_INTERVAL) + EVENT_HOMEASSISTANT_STOP, CONF_HOST, CONF_PASSWORD, CONF_SCAN_INTERVAL, + CONF_SSL) from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pysma==0.1.3'] +REQUIREMENTS = ['pysma==0.2'] _LOGGER = logging.getLogger(__name__) @@ -49,6 +50,7 @@ def _check_sensor_schema(conf): PLATFORM_SCHEMA = vol.All(PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): str, + vol.Optional(CONF_SSL, default=False): cv.boolean, vol.Required(CONF_PASSWORD): str, vol.Optional(CONF_GROUP, default=GROUPS[0]): vol.In(GROUPS), vol.Required(CONF_SENSORS): vol.Schema({cv.slug: cv.ensure_list}), @@ -97,8 +99,11 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): session = async_get_clientsession(hass) grp = {GROUP_INSTALLER: pysma.GROUP_INSTALLER, GROUP_USER: pysma.GROUP_USER}[config[CONF_GROUP]] - sma = pysma.SMA(session, config[CONF_HOST], config[CONF_PASSWORD], - group=grp) + + url = "http{}://{}".format( + "s" if config[CONF_SSL] else "", config[CONF_HOST]) + + sma = pysma.SMA(session, url, config[CONF_PASSWORD], group=grp) # Ensure we logout on shutdown @asyncio.coroutine diff --git a/homeassistant/components/sensor/smappee.py b/homeassistant/components/sensor/smappee.py new file mode 100644 index 00000000000..51595d19b1a --- /dev/null +++ b/homeassistant/components/sensor/smappee.py @@ -0,0 +1,162 @@ +""" +Support for monitoring a Smappee energy sensor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.smappee/ +""" +import logging +from datetime import timedelta + +from homeassistant.components.smappee import DATA_SMAPPEE +from homeassistant.helpers.entity import Entity + +DEPENDENCIES = ['smappee'] + +_LOGGER = logging.getLogger(__name__) + +SENSOR_PREFIX = 'Smappee' +SENSOR_TYPES = { + 'solar': + ['Solar', 'mdi:white-balance-sunny', 'local', 'W', 'solar'], + 'active_power': + ['Active Power', 'mdi:power-plug', 'local', 'W', 'active_power'], + 'current': + ['Current', 'mdi:gauge', 'local', 'Amps', 'current'], + 'voltage': + ['Voltage', 'mdi:gauge', 'local', 'V', 'voltage'], + 'active_cosfi': + ['Power Factor', 'mdi:gauge', 'local', '%', 'active_cosfi'], + 'alwayson_today': + ['Always On Today', 'mdi:gauge', 'remote', 'kW', 'alwaysOn'], + 'solar_today': + ['Solar Today', 'mdi:white-balance-sunny', 'remote', 'kW', 'solar'], + 'power_today': + ['Power Today', 'mdi:power-plug', 'remote', 'kW', 'consumption'] +} + +SCAN_INTERVAL = timedelta(seconds=30) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Smappee sensor.""" + smappee = hass.data[DATA_SMAPPEE] + + dev = [] + if smappee.is_remote_active: + for sensor in SENSOR_TYPES: + if 'remote' in SENSOR_TYPES[sensor]: + for location_id in smappee.locations.keys(): + dev.append(SmappeeSensor(smappee, location_id, sensor)) + + if smappee.is_local_active: + for sensor in SENSOR_TYPES: + if 'local' in SENSOR_TYPES[sensor]: + if smappee.is_remote_active: + for location_id in smappee.locations.keys(): + dev.append(SmappeeSensor(smappee, location_id, sensor)) + else: + dev.append(SmappeeSensor(smappee, None, sensor)) + add_devices(dev, True) + + +class SmappeeSensor(Entity): + """Implementation of a Smappee sensor.""" + + def __init__(self, smappee, location_id, sensor): + """Initialize the sensor.""" + self._smappee = smappee + self._location_id = location_id + self._sensor = sensor + self.data = None + self._state = None + self._name = SENSOR_TYPES[self._sensor][0] + self._icon = SENSOR_TYPES[self._sensor][1] + self._unit_of_measurement = SENSOR_TYPES[self._sensor][3] + self._smappe_name = SENSOR_TYPES[self._sensor][4] + + @property + def name(self): + """Return the name of the sensor.""" + if self._location_id: + location_name = self._smappee.locations[self._location_id] + else: + location_name = 'Local' + + return "{} {} {}".format(SENSOR_PREFIX, + location_name, + self._name) + + @property + def icon(self): + """Icon to use in the frontend.""" + return self._icon + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + attr = {} + if self._location_id: + attr['Location Id'] = self._location_id + attr['Location Name'] = self._smappee.locations[self._location_id] + return attr + + def update(self): + """Get the latest data from Smappee and update the state.""" + self._smappee.update() + + if self._sensor in ['alwayson_today', 'solar_today', 'power_today']: + data = self._smappee.consumption[self._location_id] + if data: + consumption = data.get('consumptions')[-1] + _LOGGER.debug("%s %s", self._sensor, consumption) + value = consumption.get(self._smappe_name) + self._state = round(value / 1000, 2) + elif self._sensor == 'active_cosfi': + cosfi = self._smappee.active_cosfi() + _LOGGER.debug("%s %s", self._sensor, cosfi) + if cosfi: + self._state = round(cosfi, 2) + elif self._sensor == 'current': + current = self._smappee.active_current() + _LOGGER.debug("%s %s", self._sensor, current) + if current: + self._state = round(current, 2) + elif self._sensor == 'voltage': + voltage = self._smappee.active_voltage() + _LOGGER.debug("%s %s", self._sensor, voltage) + if voltage: + self._state = round(voltage, 3) + elif self._sensor == 'active_power': + data = self._smappee.instantaneous + _LOGGER.debug("%s %s", self._sensor, data) + if data: + value1 = [float(i['value']) for i in data + if i['key'].endswith('phase0ActivePower')] + value2 = [float(i['value']) for i in data + if i['key'].endswith('phase1ActivePower')] + value3 = [float(i['value']) for i in data + if i['key'].endswith('phase2ActivePower')] + active_power = sum(value1 + value2 + value3) / 1000 + self._state = round(active_power, 2) + elif self._sensor == 'solar': + data = self._smappee.instantaneous + _LOGGER.debug("%s %s", self._sensor, data) + if data: + value1 = [float(i['value']) for i in data + if i['key'].endswith('phase3ActivePower')] + value2 = [float(i['value']) for i in data + if i['key'].endswith('phase4ActivePower')] + value3 = [float(i['value']) for i in data + if i['key'].endswith('phase5ActivePower')] + power = sum(value1 + value2 + value3) / 1000 + self._state = round(power, 2) diff --git a/homeassistant/components/sensor/speedtest.py b/homeassistant/components/sensor/speedtest.py index c7ba61ef504..5b03be036d5 100644 --- a/homeassistant/components/sensor/speedtest.py +++ b/homeassistant/components/sensor/speedtest.py @@ -6,27 +6,30 @@ https://home-assistant.io/components/sensor.speedtest/ """ import asyncio import logging -import re -import sys -from subprocess import check_output, CalledProcessError import voluptuous as vol -import homeassistant.util.dt as dt_util +from homeassistant.components.sensor import DOMAIN, PLATFORM_SCHEMA +from homeassistant.const import ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS import homeassistant.helpers.config_validation as cv -from homeassistant.components.sensor import (DOMAIN, PLATFORM_SCHEMA) -from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import track_time_change from homeassistant.helpers.restore_state import async_get_last_state +import homeassistant.util.dt as dt_util -REQUIREMENTS = ['speedtest-cli==1.0.7'] +REQUIREMENTS = ['speedtest-cli==2.0.0'] _LOGGER = logging.getLogger(__name__) -_SPEEDTEST_REGEX = re.compile(r'Ping:\s(\d+\.\d+)\sms[\r\n]+' - r'Download:\s(\d+\.\d+)\sMbit/s[\r\n]+' - r'Upload:\s(\d+\.\d+)\sMbit/s[\r\n]+') +ATTR_BYTES_RECEIVED = 'bytes_received' +ATTR_BYTES_SENT = 'bytes_sent' +ATTR_SERVER_COUNTRY = 'server_country' +ATTR_SERVER_HOST = 'server_host' +ATTR_SERVER_ID = 'server_id' +ATTR_SERVER_LATENCY = 'latency' +ATTR_SERVER_NAME = 'server_name' + +CONF_ATTRIBUTION = "Data retrieved from Speedtest by Ookla" CONF_SECOND = 'second' CONF_MINUTE = 'minute' CONF_HOUR = 'hour' @@ -45,28 +48,26 @@ SENSOR_TYPES = { PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_MONITORED_CONDITIONS): vol.All(cv.ensure_list, [vol.In(list(SENSOR_TYPES))]), - vol.Optional(CONF_SERVER_ID): cv.positive_int, - vol.Optional(CONF_SECOND, default=[0]): - vol.All(cv.ensure_list, [vol.All(vol.Coerce(int), vol.Range(0, 59))]), - vol.Optional(CONF_MINUTE, default=[0]): - vol.All(cv.ensure_list, [vol.All(vol.Coerce(int), vol.Range(0, 59))]), - vol.Optional(CONF_HOUR): - vol.All(cv.ensure_list, [vol.All(vol.Coerce(int), vol.Range(0, 23))]), vol.Optional(CONF_DAY): vol.All(cv.ensure_list, [vol.All(vol.Coerce(int), vol.Range(1, 31))]), + vol.Optional(CONF_HOUR): + vol.All(cv.ensure_list, [vol.All(vol.Coerce(int), vol.Range(0, 23))]), vol.Optional(CONF_MANUAL, default=False): cv.boolean, + vol.Optional(CONF_MINUTE, default=[0]): + vol.All(cv.ensure_list, [vol.All(vol.Coerce(int), vol.Range(0, 59))]), + vol.Optional(CONF_SECOND, default=[0]): + vol.All(cv.ensure_list, [vol.All(vol.Coerce(int), vol.Range(0, 59))]), + vol.Optional(CONF_SERVER_ID): cv.positive_int, }) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Speedtest sensor.""" data = SpeedtestData(hass, config) + dev = [] for sensor in config[CONF_MONITORED_CONDITIONS]: - if sensor not in SENSOR_TYPES: - _LOGGER.error("Sensor type: %s does not exist", sensor) - else: - dev.append(SpeedtestSensor(data, sensor)) + dev.append(SpeedtestSensor(data, sensor)) add_devices(dev) @@ -88,6 +89,7 @@ class SpeedtestSensor(Entity): self.speedtest_client = speedtest_data self.type = sensor_type self._state = None + self._data = None self._unit_of_measurement = SENSOR_TYPES[self.type][1] @property @@ -110,18 +112,32 @@ class SpeedtestSensor(Entity): """Return icon.""" return ICON + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self._data is not None: + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + ATTR_BYTES_RECEIVED: self._data['bytes_received'], + ATTR_BYTES_SENT: self._data['bytes_sent'], + ATTR_SERVER_COUNTRY: self._data['server']['country'], + ATTR_SERVER_ID: self._data['server']['id'], + ATTR_SERVER_LATENCY: self._data['server']['latency'], + ATTR_SERVER_NAME: self._data['server']['name'], + } + def update(self): """Get the latest data and update the states.""" - data = self.speedtest_client.data - if data is None: + self._data = self.speedtest_client.data + if self._data is None: return if self.type == 'ping': - self._state = data['ping'] + self._state = self._data['ping'] elif self.type == 'download': - self._state = data['download'] + self._state = round(self._data['download'] / 10**6, 2) elif self.type == 'upload': - self._state = data['upload'] + self._state = round(self._data['upload'] / 10**6, 2) @asyncio.coroutine def async_added_to_hass(self): @@ -148,20 +164,14 @@ class SpeedtestData(object): def update(self, now): """Get the latest data from speedtest.net.""" import speedtest + _LOGGER.debug("Executing speedtest...") - _LOGGER.info("Executing speedtest...") - try: - args = [sys.executable, speedtest.__file__, '--simple'] - if self._server_id: - args = args + ['--server', str(self._server_id)] + servers = [] if self._server_id is None else [self._server_id] - re_output = _SPEEDTEST_REGEX.split( - check_output(args).decode('utf-8')) - except CalledProcessError as process_error: - _LOGGER.error("Error executing speedtest: %s", process_error) - return - self.data = { - 'ping': round(float(re_output[1]), 2), - 'download': round(float(re_output[2]), 2), - 'upload': round(float(re_output[3]), 2), - } + speed = speedtest.Speedtest() + speed.get_servers(servers) + speed.get_best_server() + speed.download() + speed.upload() + + self.data = speed.results.dict() diff --git a/homeassistant/components/sensor/spotcrime.py b/homeassistant/components/sensor/spotcrime.py new file mode 100644 index 00000000000..169bcc5f867 --- /dev/null +++ b/homeassistant/components/sensor/spotcrime.py @@ -0,0 +1,123 @@ +""" +Sensor for Spot Crime. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.spotcrime/ +""" + +from datetime import timedelta +from collections import defaultdict +import logging + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_INCLUDE, CONF_EXCLUDE, CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, + ATTR_ATTRIBUTION, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_RADIUS) +from homeassistant.helpers.entity import Entity +from homeassistant.util import slugify +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['spotcrime==1.0.2'] + +_LOGGER = logging.getLogger(__name__) + +CONF_DAYS = 'days' +DEFAULT_DAYS = 1 +NAME = 'spotcrime' + +EVENT_INCIDENT = '{}_incident'.format(NAME) + +SCAN_INTERVAL = timedelta(minutes=30) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_RADIUS): vol.Coerce(float), + vol.Inclusive(CONF_LATITUDE, 'coordinates'): cv.latitude, + vol.Inclusive(CONF_LONGITUDE, 'coordinates'): cv.longitude, + vol.Optional(CONF_DAYS, default=DEFAULT_DAYS): cv.positive_int, + vol.Optional(CONF_INCLUDE): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_EXCLUDE): vol.All(cv.ensure_list, [cv.string]) +}) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Crime Reports platform.""" + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + name = config[CONF_NAME] + radius = config[CONF_RADIUS] + days = config.get(CONF_DAYS) + include = config.get(CONF_INCLUDE) + exclude = config.get(CONF_EXCLUDE) + + add_devices([SpotCrimeSensor( + name, latitude, longitude, radius, include, + exclude, days)], True) + + +class SpotCrimeSensor(Entity): + """Representation of a Spot Crime Sensor.""" + + def __init__(self, name, latitude, longitude, radius, + include, exclude, days): + """Initialize the Spot Crime sensor.""" + import spotcrime + self._name = name + self._include = include + self._exclude = exclude + self.days = days + self._spotcrime = spotcrime.SpotCrime( + (latitude, longitude), radius, None, None, self.days) + self._attributes = None + self._state = None + self._previous_incidents = set() + self._attributes = { + ATTR_ATTRIBUTION: spotcrime.ATTRIBUTION + } + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._attributes + + def _incident_event(self, incident): + data = { + 'type': incident.get('type'), + 'timestamp': incident.get('timestamp'), + 'address': incident.get('location') + } + if incident.get('coordinates'): + data.update({ + ATTR_LATITUDE: incident.get('lat'), + ATTR_LONGITUDE: incident.get('lon') + }) + self.hass.bus.fire(EVENT_INCIDENT, data) + + def update(self): + """Update device state.""" + incident_counts = defaultdict(int) + incidents = self._spotcrime.get_incidents() + if len(incidents) < len(self._previous_incidents): + self._previous_incidents = set() + for incident in incidents: + incident_type = slugify(incident.get('type')) + incident_counts[incident_type] += 1 + if (self._previous_incidents and incident.get('id') + not in self._previous_incidents): + self._incident_event(incident) + self._previous_incidents.add(incident.get('id')) + self._attributes.update(incident_counts) + self._state = len(incidents) diff --git a/homeassistant/components/sensor/startca.py b/homeassistant/components/sensor/startca.py new file mode 100644 index 00000000000..a5908812b6c --- /dev/null +++ b/homeassistant/components/sensor/startca.py @@ -0,0 +1,186 @@ +""" +Support for Start.ca Bandwidth Monitor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.startca/ +""" +from datetime import timedelta +from xml.parsers.expat import ExpatError +import logging +import asyncio +import async_timeout + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_API_KEY, CONF_MONITORED_VARIABLES, CONF_NAME) +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +REQUIREMENTS = ['xmltodict==0.11.0'] + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Start.ca' +CONF_TOTAL_BANDWIDTH = 'total_bandwidth' + +GIGABYTES = 'GB' # type: str +PERCENT = '%' # type: str + +MIN_TIME_BETWEEN_UPDATES = timedelta(hours=1) +REQUEST_TIMEOUT = 5 # seconds + +SENSOR_TYPES = { + 'usage': ['Usage Ratio', PERCENT, 'mdi:percent'], + 'usage_gb': ['Usage', GIGABYTES, 'mdi:download'], + 'limit': ['Data limit', GIGABYTES, 'mdi:download'], + 'used_download': ['Used Download', GIGABYTES, 'mdi:download'], + 'used_upload': ['Used Upload', GIGABYTES, 'mdi:upload'], + 'used_total': ['Used Total', GIGABYTES, 'mdi:download'], + 'grace_download': ['Grace Download', GIGABYTES, 'mdi:download'], + 'grace_upload': ['Grace Upload', GIGABYTES, 'mdi:upload'], + 'grace_total': ['Grace Total', GIGABYTES, 'mdi:download'], + 'total_download': ['Total Download', GIGABYTES, 'mdi:download'], + 'total_upload': ['Total Upload', GIGABYTES, 'mdi:download'], + 'used_remaining': ['Remaining', GIGABYTES, 'mdi:download'] +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_MONITORED_VARIABLES): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_TOTAL_BANDWIDTH): cv.positive_int, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the sensor platform.""" + websession = async_get_clientsession(hass) + apikey = config.get(CONF_API_KEY) + bandwidthcap = config.get(CONF_TOTAL_BANDWIDTH) + + ts_data = StartcaData(hass.loop, websession, apikey, bandwidthcap) + ret = yield from ts_data.async_update() + if ret is False: + _LOGGER.error("Invalid Start.ca API key: %s", apikey) + return + + name = config.get(CONF_NAME) + sensors = [] + for variable in config[CONF_MONITORED_VARIABLES]: + sensors.append(StartcaSensor(ts_data, variable, name)) + async_add_devices(sensors, True) + + +class StartcaSensor(Entity): + """Representation of Start.ca Bandwidth sensor.""" + + def __init__(self, startcadata, sensor_type, name): + """Initialize the sensor.""" + self.client_name = name + self.type = sensor_type + self._name = SENSOR_TYPES[sensor_type][0] + self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self._icon = SENSOR_TYPES[sensor_type][2] + self.startcadata = startcadata + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return '{} {}'.format(self.client_name, self._name) + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._icon + + @asyncio.coroutine + def async_update(self): + """Get the latest data from Start.ca and update the state.""" + yield from self.startcadata.async_update() + if self.type in self.startcadata.data: + self._state = round(self.startcadata.data[self.type], 2) + + +class StartcaData(object): + """Get data from Start.ca API.""" + + def __init__(self, loop, websession, api_key, bandwidth_cap): + """Initialize the data object.""" + self.loop = loop + self.websession = websession + self.api_key = api_key + self.bandwidth_cap = bandwidth_cap + # Set unlimited users to infinite, otherwise the cap. + self.data = {"limit": self.bandwidth_cap} if self.bandwidth_cap > 0 \ + else {"limit": float('inf')} + + @staticmethod + def bytes_to_gb(value): + """Convert from bytes to GB. + + :param value: The value in bytes to convert to GB. + :return: Converted GB value + """ + return float(value) * 10 ** -9 + + @asyncio.coroutine + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def async_update(self): + """Get the Start.ca bandwidth data from the web service.""" + import xmltodict + _LOGGER.debug("Updating Start.ca usage data") + url = 'https://www.start.ca/support/usage/api?key=' + \ + self.api_key + with async_timeout.timeout(REQUEST_TIMEOUT, loop=self.loop): + req = yield from self.websession.get(url) + if req.status != 200: + _LOGGER.error("Request failed with status: %u", req.status) + return False + + data = yield from req.text() + try: + xml_data = xmltodict.parse(data) + except ExpatError: + return False + + used_dl = self.bytes_to_gb(xml_data['usage']['used']['download']) + used_ul = self.bytes_to_gb(xml_data['usage']['used']['upload']) + grace_dl = self.bytes_to_gb(xml_data['usage']['grace']['download']) + grace_ul = self.bytes_to_gb(xml_data['usage']['grace']['upload']) + total_dl = self.bytes_to_gb(xml_data['usage']['total']['download']) + total_ul = self.bytes_to_gb(xml_data['usage']['total']['upload']) + + limit = self.data['limit'] + if self.bandwidth_cap > 0: + self.data['usage'] = 100*used_dl/self.bandwidth_cap + else: + self.data['usage'] = 0 + self.data['usage_gb'] = used_dl + self.data['used_download'] = used_dl + self.data['used_upload'] = used_ul + self.data['used_total'] = used_dl + used_ul + self.data['grace_download'] = grace_dl + self.data['grace_upload'] = grace_ul + self.data['grace_total'] = grace_dl + grace_ul + self.data['total_download'] = total_dl + self.data['total_upload'] = total_ul + self.data['used_remaining'] = limit - used_dl + + return True diff --git a/homeassistant/components/sensor/statistics.py b/homeassistant/components/sensor/statistics.py index b26fd5cc804..7b2ae537d4b 100644 --- a/homeassistant/components/sensor/statistics.py +++ b/homeassistant/components/sensor/statistics.py @@ -173,7 +173,7 @@ class StatisticsSensor(Entity): """Remove states which are older than self._max_age.""" now = dt_util.utcnow() - while (len(self.ages) > 0) and (now - self.ages[0]) > self._max_age: + while self.ages and (now - self.ages[0]) > self._max_age: self.ages.popleft() self.states.popleft() diff --git a/homeassistant/components/sensor/synologydsm.py b/homeassistant/components/sensor/synologydsm.py index f5a41c7b8ce..a0198169b6d 100644 --- a/homeassistant/components/sensor/synologydsm.py +++ b/homeassistant/components/sensor/synologydsm.py @@ -78,8 +78,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_MONITORED_CONDITIONS): vol.All(cv.ensure_list, [vol.In(_MONITORED_CONDITIONS)]), - vol.Optional(CONF_DISKS, default=None): cv.ensure_list, - vol.Optional(CONF_VOLUMES, default=None): cv.ensure_list, + vol.Optional(CONF_DISKS): cv.ensure_list, + vol.Optional(CONF_VOLUMES): cv.ensure_list, }) @@ -106,22 +106,14 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if variable in _UTILISATION_MON_COND] # Handle all volumes - volumes = config['volumes'] - if volumes is None: - volumes = api.storage.volumes - - for volume in volumes: + for volume in config.get(CONF_VOLUMES, api.storage.volumes): sensors += [SynoNasStorageSensor( api, variable, _STORAGE_VOL_MON_COND[variable], volume) for variable in monitored_conditions if variable in _STORAGE_VOL_MON_COND] # Handle all disks - disks = config['disks'] - if disks is None: - disks = api.storage.disks - - for disk in disks: + for disk in config.get(CONF_DISKS, api.storage.disks): sensors += [SynoNasStorageSensor( api, variable, _STORAGE_DSK_MON_COND[variable], disk) for variable in monitored_conditions diff --git a/homeassistant/components/sensor/systemmonitor.py b/homeassistant/components/sensor/systemmonitor.py index ea8595e3991..3aed9d5a21b 100644 --- a/homeassistant/components/sensor/systemmonitor.py +++ b/homeassistant/components/sensor/systemmonitor.py @@ -48,7 +48,7 @@ SENSOR_TYPES = { } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_RESOURCES, default=['disk_use']): + vol.Optional(CONF_RESOURCES, default={CONF_TYPE: 'disk_use'}): vol.All(cv.ensure_list, [vol.Schema({ vol.Required(CONF_TYPE): vol.In(SENSOR_TYPES), vol.Optional(CONF_ARG): cv.string, diff --git a/homeassistant/components/sensor/teksavvy.py b/homeassistant/components/sensor/teksavvy.py index cb78caae095..33e5c0cf4ce 100644 --- a/homeassistant/components/sensor/teksavvy.py +++ b/homeassistant/components/sensor/teksavvy.py @@ -6,9 +6,11 @@ https://home-assistant.io/components/sensor.teksavvy/ """ from datetime import timedelta import logging - import asyncio import async_timeout + +import voluptuous as vol + from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( CONF_API_KEY, CONF_MONITORED_VARIABLES, CONF_NAME) @@ -16,7 +18,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -import voluptuous as vol _LOGGER = logging.getLogger(__name__) @@ -142,18 +143,17 @@ class TekSavvyData(object): if req.status != 200: _LOGGER.error("Request failed with status: %u", req.status) return False - else: - data = yield from req.json() - for (api, ha_name) in API_HA_MAP: - self.data[ha_name] = float(data["value"][0][api]) - on_peak_download = self.data["onpeak_download"] - on_peak_upload = self.data["onpeak_upload"] - off_peak_download = self.data["offpeak_download"] - off_peak_upload = self.data["offpeak_upload"] - limit = self.data["limit"] - self.data["usage"] = 100*on_peak_download/self.bandwidth_cap - self.data["usage_gb"] = on_peak_download - self.data["onpeak_total"] = on_peak_download + on_peak_upload - self.data["offpeak_total"] = off_peak_download + off_peak_upload - self.data["onpeak_remaining"] = limit - on_peak_download - return True + data = yield from req.json() + for (api, ha_name) in API_HA_MAP: + self.data[ha_name] = float(data["value"][0][api]) + on_peak_download = self.data["onpeak_download"] + on_peak_upload = self.data["onpeak_upload"] + off_peak_download = self.data["offpeak_download"] + off_peak_upload = self.data["offpeak_upload"] + limit = self.data["limit"] + self.data["usage"] = 100*on_peak_download/self.bandwidth_cap + self.data["usage_gb"] = on_peak_download + self.data["onpeak_total"] = on_peak_download + on_peak_upload + self.data["offpeak_total"] = off_peak_download + off_peak_upload + self.data["onpeak_remaining"] = limit - on_peak_download + return True diff --git a/homeassistant/components/sensor/template.py b/homeassistant/components/sensor/template.py index b347439e08d..582bc3a0150 100644 --- a/homeassistant/components/sensor/template.py +++ b/homeassistant/components/sensor/template.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ENTITY_ID_FORMAT, PLATFORM_SCHEMA from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, CONF_ICON_TEMPLATE, CONF_ENTITY_PICTURE_TEMPLATE, ATTR_ENTITY_ID, - CONF_SENSORS, EVENT_HOMEASSISTANT_START) + CONF_SENSORS, EVENT_HOMEASSISTANT_START, CONF_FRIENDLY_NAME_TEMPLATE) from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity, async_generate_entity_id @@ -26,6 +26,7 @@ SENSOR_SCHEMA = vol.Schema({ vol.Required(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_ICON_TEMPLATE): cv.template, vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template, + vol.Optional(CONF_FRIENDLY_NAME_TEMPLATE): cv.template, vol.Optional(ATTR_FRIENDLY_NAME): cv.string, vol.Optional(ATTR_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(ATTR_ENTITY_ID): cv.entity_ids @@ -50,6 +51,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): entity_ids = (device_config.get(ATTR_ENTITY_ID) or state_template.extract_entities()) friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device) + friendly_name_template = device_config.get(CONF_FRIENDLY_NAME_TEMPLATE) unit_of_measurement = device_config.get(ATTR_UNIT_OF_MEASUREMENT) state_template.hass = hass @@ -60,11 +62,15 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if entity_picture_template is not None: entity_picture_template.hass = hass + if friendly_name_template is not None: + friendly_name_template.hass = hass + sensors.append( SensorTemplate( hass, device, friendly_name, + friendly_name_template, unit_of_measurement, state_template, icon_template, @@ -82,7 +88,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class SensorTemplate(Entity): """Representation of a Template Sensor.""" - def __init__(self, hass, device_id, friendly_name, + def __init__(self, hass, device_id, friendly_name, friendly_name_template, unit_of_measurement, state_template, icon_template, entity_picture_template, entity_ids): """Initialize the sensor.""" @@ -90,6 +96,7 @@ class SensorTemplate(Entity): self.entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) self._name = friendly_name + self._friendly_name_template = friendly_name_template self._unit_of_measurement = unit_of_measurement self._template = state_template self._state = None @@ -165,7 +172,8 @@ class SensorTemplate(Entity): for property_name, template in ( ('_icon', self._icon_template), - ('_entity_picture', self._entity_picture_template)): + ('_entity_picture', self._entity_picture_template), + ('_name', self._friendly_name_template)): if template is None: continue diff --git a/homeassistant/components/sensor/tibber.py b/homeassistant/components/sensor/tibber.py index dd09b9f7891..519ff05cbd8 100644 --- a/homeassistant/components/sensor/tibber.py +++ b/homeassistant/components/sensor/tibber.py @@ -42,7 +42,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): yield from home.update_info() dev.append(TibberSensor(home)) - async_add_devices(dev) + async_add_devices(dev, True) class TibberSensor(Entity): diff --git a/homeassistant/components/sensor/viaggiatreno.py b/homeassistant/components/sensor/viaggiatreno.py index a7f4b070f2d..43ba80d2630 100644 --- a/homeassistant/components/sensor/viaggiatreno.py +++ b/homeassistant/components/sensor/viaggiatreno.py @@ -76,9 +76,8 @@ def async_http_request(hass, uri): req = yield from session.get(uri) if req.status != 200: return {'error': req.status} - else: - json_response = yield from req.json() - return json_response + json_response = yield from req.json() + return json_response except (asyncio.TimeoutError, aiohttp.ClientError) as exc: _LOGGER.error("Cannot connect to ViaggiaTreno API endpoint: %s", exc) except ValueError: diff --git a/homeassistant/components/sensor/volvooncall.py b/homeassistant/components/sensor/volvooncall.py index 32b228ca1f9..343bcdf2033 100644 --- a/homeassistant/components/sensor/volvooncall.py +++ b/homeassistant/components/sensor/volvooncall.py @@ -42,12 +42,10 @@ class VolvoSensor(VolvoEntity): val /= 10 # L/1000km -> L/100km if 'mil' in self.unit_of_measurement: return round(val, 2) - else: - return round(val, 1) + return round(val, 1) elif self._attribute == 'distance_to_empty': return int(floor(val)) - else: - return int(round(val)) + return int(round(val)) @property def unit_of_measurement(self): @@ -56,8 +54,7 @@ class VolvoSensor(VolvoEntity): if self._state.config[CONF_SCANDINAVIAN_MILES] and 'km' in unit: if self._attribute == 'average_fuel_consumption': return 'L/mil' - else: - return unit.replace('km', 'mil') + return unit.replace('km', 'mil') return unit @property diff --git a/homeassistant/components/sensor/vultr.py b/homeassistant/components/sensor/vultr.py index 012c6eb7398..291639c81d6 100644 --- a/homeassistant/components/sensor/vultr.py +++ b/homeassistant/components/sensor/vultr.py @@ -30,8 +30,9 @@ MONITORED_CONDITIONS = { PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_SUBSCRIPTION): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_MONITORED_CONDITIONS, default=MONITORED_CONDITIONS): - vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]) + vol.Optional(CONF_MONITORED_CONDITIONS, + default=list(MONITORED_CONDITIONS)): + vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]) }) diff --git a/homeassistant/components/sensor/worldtidesinfo.py b/homeassistant/components/sensor/worldtidesinfo.py index f23d244cf3a..8884d790eed 100644 --- a/homeassistant/components/sensor/worldtidesinfo.py +++ b/homeassistant/components/sensor/worldtidesinfo.py @@ -90,10 +90,8 @@ class WorldTidesInfoSensor(Entity): tidetime = time.strftime('%I:%M %p', time.localtime( self.data['extremes'][0]['dt'])) return "Low tide at %s" % (tidetime) - else: - return STATE_UNKNOWN - else: return STATE_UNKNOWN + return STATE_UNKNOWN def update(self): """Get the latest data from WorldTidesInfo API.""" diff --git a/homeassistant/components/sensor/wunderground.py b/homeassistant/components/sensor/wunderground.py index aa5d431a7b0..edcc1c92bf9 100644 --- a/homeassistant/components/sensor/wunderground.py +++ b/homeassistant/components/sensor/wunderground.py @@ -4,21 +4,24 @@ Support for WUnderground weather service. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.wunderground/ """ +import asyncio from datetime import timedelta import logging - import re -import requests + +import aiohttp +import async_timeout import voluptuous as vol -from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.helpers.typing import HomeAssistantType, ConfigType from homeassistant.components.sensor import PLATFORM_SCHEMA, ENTITY_ID_FORMAT from homeassistant.const import ( CONF_MONITORED_CONDITIONS, CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, TEMP_FAHRENHEIT, TEMP_CELSIUS, LENGTH_INCHES, LENGTH_KILOMETERS, - LENGTH_MILES, LENGTH_FEET, STATE_UNKNOWN, ATTR_ATTRIBUTION) + LENGTH_MILES, LENGTH_FEET, ATTR_ATTRIBUTION) from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers.entity import Entity, generate_entity_id +from homeassistant.helpers.entity import Entity, async_generate_entity_id +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv @@ -627,7 +630,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -def setup_platform(hass, config, add_devices, discovery_info=None): +@asyncio.coroutine +def async_setup_platform(hass: HomeAssistantType, config: ConfigType, + async_add_devices, discovery_info=None): """Set up the WUnderground sensor.""" latitude = config.get(CONF_LATITUDE, hass.config.latitude) longitude = config.get(CONF_LONGITUDE, hass.config.longitude) @@ -639,13 +644,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): for variable in config[CONF_MONITORED_CONDITIONS]: sensors.append(WUndergroundSensor(hass, rest, variable)) - rest.update() + yield from rest.async_update() if not rest.data: raise PlatformNotReady - add_devices(sensors) - - return True + async_add_devices(sensors, True) class WUndergroundSensor(Entity): @@ -663,7 +666,7 @@ class WUndergroundSensor(Entity): self._entity_picture = None self._unit_of_measurement = self._cfg_expand("unit_of_measurement") self.rest.request_feature(SENSOR_TYPES[condition].feature) - self.entity_id = generate_entity_id( + self.entity_id = async_generate_entity_id( ENTITY_ID_FORMAT, "pws_" + condition, hass=hass) def _cfg_expand(self, what, default=None): @@ -727,15 +730,16 @@ class WUndergroundSensor(Entity): """Return the units of measurement.""" return self._unit_of_measurement - def update(self): + @asyncio.coroutine + def async_update(self): """Update current conditions.""" - self.rest.update() + yield from self.rest.async_update() if not self.rest.data: # no data, return return - self._state = self._cfg_expand("value", STATE_UNKNOWN) + self._state = self._cfg_expand("value") self._update_attrs() self._icon = self._cfg_expand("icon", super().icon) url = self._cfg_expand("entity_picture") @@ -757,6 +761,7 @@ class WUndergroundData(object): self._longitude = longitude self._features = set() self.data = None + self._session = async_get_clientsession(self._hass) def request_feature(self, feature): """Register feature to be fetched from WU API.""" @@ -764,7 +769,7 @@ class WUndergroundData(object): def _build_url(self, baseurl=_RESOURCE): url = baseurl.format( - self._api_key, "/".join(self._features), self._lang) + self._api_key, '/'.join(sorted(self._features)), self._lang) if self._pws_id: url = url + 'pws:{}'.format(self._pws_id) else: @@ -772,20 +777,18 @@ class WUndergroundData(object): return url + '.json' + @asyncio.coroutine @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): + def async_update(self): """Get the latest data from WUnderground.""" try: - result = requests.get(self._build_url(), timeout=10).json() + with async_timeout.timeout(10, loop=self._hass.loop): + response = yield from self._session.get(self._build_url()) + result = yield from response.json() if "error" in result['response']: - raise ValueError(result['response']["error"] - ["description"]) - else: - self.data = result - return True + raise ValueError(result['response']["error"]["description"]) + self.data = result except ValueError as err: _LOGGER.error("Check WUnderground API %s", err.args) - self.data = None - except requests.RequestException as err: + except (asyncio.TimeoutError, aiohttp.ClientError) as err: _LOGGER.error("Error fetching WUnderground data: %s", repr(err)) - self.data = None diff --git a/homeassistant/components/sensor/yr.py b/homeassistant/components/sensor/yr.py index 3f9579c1f13..88c23771bd4 100644 --- a/homeassistant/components/sensor/yr.py +++ b/homeassistant/components/sensor/yr.py @@ -222,7 +222,7 @@ class YrData(object): # Update all devices tasks = [] - if len(ordered_entries) > 0: + if ordered_entries: for dev in self.devices: new_state = None @@ -254,5 +254,5 @@ class YrData(object): dev._state = new_state tasks.append(dev.async_update_ha_state()) - if len(tasks) > 0: + if tasks: yield from asyncio.wait(tasks, loop=self.hass.loop) diff --git a/homeassistant/components/sensor/yweather.py b/homeassistant/components/sensor/yweather.py index e066e38fb1e..df18e086ddd 100644 --- a/homeassistant/components/sensor/yweather.py +++ b/homeassistant/components/sensor/yweather.py @@ -42,7 +42,7 @@ SENSOR_TYPES = { } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_WOEID, default=None): cv.string, + vol.Optional(CONF_WOEID): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_FORECAST, default=0): vol.All(vol.Coerce(int), vol.Range(min=0, max=5)), diff --git a/homeassistant/components/sensor/zabbix.py b/homeassistant/components/sensor/zabbix.py index a47d466c07e..baeed391557 100644 --- a/homeassistant/components/sensor/zabbix.py +++ b/homeassistant/components/sensor/zabbix.py @@ -25,8 +25,8 @@ _CONF_INDIVIDUAL = 'individual' _ZABBIX_ID_LIST_SCHEMA = vol.Schema([int]) _ZABBIX_TRIGGER_SCHEMA = vol.Schema({ vol.Optional(_CONF_HOSTIDS, default=[]): _ZABBIX_ID_LIST_SCHEMA, - vol.Optional(_CONF_INDIVIDUAL, default=False): cv.boolean(True), - vol.Optional(CONF_NAME, default=None): cv.string, + vol.Optional(_CONF_INDIVIDUAL, default=False): cv.boolean, + vol.Optional(CONF_NAME): cv.string, }) # SCAN_INTERVAL = 30 diff --git a/homeassistant/components/sensor/zha.py b/homeassistant/components/sensor/zha.py index a1820f7d7dd..36cdca2e638 100644 --- a/homeassistant/components/sensor/zha.py +++ b/homeassistant/components/sensor/zha.py @@ -31,19 +31,22 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): @asyncio.coroutine def make_sensor(discovery_info): """Create ZHA sensors factory.""" - from zigpy.zcl.clusters.measurement import TemperatureMeasurement + from zigpy.zcl.clusters.measurement import ( + RelativeHumidity, TemperatureMeasurement + ) in_clusters = discovery_info['in_clusters'] - if TemperatureMeasurement.cluster_id in in_clusters: + if RelativeHumidity.cluster_id in in_clusters: + sensor = RelativeHumiditySensor(**discovery_info) + elif TemperatureMeasurement.cluster_id in in_clusters: sensor = TemperatureSensor(**discovery_info) else: sensor = Sensor(**discovery_info) - attr = sensor.value_attribute if discovery_info['new_join']: cluster = list(in_clusters.values())[0] yield from cluster.bind() yield from cluster.configure_reporting( - attr, 300, 600, sensor.min_reportable_change, + sensor.value_attribute, 300, 600, sensor.min_reportable_change, ) return sensor @@ -89,3 +92,22 @@ class TemperatureSensor(Sensor): celsius = round(float(self._state) / 100, 1) return convert_temperature( celsius, TEMP_CELSIUS, self.unit_of_measurement) + + +class RelativeHumiditySensor(Sensor): + """ZHA relative humidity sensor.""" + + min_reportable_change = 50 # 0.5% + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity.""" + return '%' + + @property + def state(self): + """Return the state of the entity.""" + if self._state == 'unknown': + return 'unknown' + + return round(float(self._state) / 100, 1) diff --git a/homeassistant/components/sensor/zigbee.py b/homeassistant/components/sensor/zigbee.py index a1d549cb382..37cc6fabe2e 100644 --- a/homeassistant/components/sensor/zigbee.py +++ b/homeassistant/components/sensor/zigbee.py @@ -70,7 +70,7 @@ class ZigBeeTemperatureSensor(Entity): """Return the unit of measurement the value is expressed in.""" return TEMP_CELSIUS - def update(self, *args): + def update(self): """Get the latest data.""" try: self._temp = zigbee.DEVICE.get_temperature(self._config.address) diff --git a/homeassistant/components/shopping_list.py b/homeassistant/components/shopping_list.py index 31259325c04..2452188a889 100644 --- a/homeassistant/components/shopping_list.py +++ b/homeassistant/components/shopping_list.py @@ -1,8 +1,6 @@ """Component to manage a shopping list.""" import asyncio -import json import logging -import os import uuid import voluptuous as vol @@ -10,9 +8,11 @@ import voluptuous as vol from homeassistant.const import HTTP_NOT_FOUND, HTTP_BAD_REQUEST from homeassistant.core import callback from homeassistant.components import http +from homeassistant.components.http.data_validator import ( + RequestDataValidator) from homeassistant.helpers import intent import homeassistant.helpers.config_validation as cv - +from homeassistant.util.json import load_json, save_json DOMAIN = 'shopping_list' DEPENDENCIES = ['http'] @@ -99,18 +99,13 @@ class ShoppingData: """Load items.""" def load(): """Load the items synchronously.""" - path = self.hass.config.path(PERSISTENCE) - if not os.path.isfile(path): - return [] - with open(path) as file: - return json.loads(file.read()) + return load_json(self.hass.config.path(PERSISTENCE), default=[]) self.items = yield from self.hass.async_add_job(load) def save(self): """Save the items.""" - with open(self.hass.config.path(PERSISTENCE), 'wt') as file: - file.write(json.dumps(self.items, sort_keys=True, indent=4)) + save_json(self.hass.config.path(PERSISTENCE), self.items) class AddItemIntent(intent.IntentHandler): @@ -199,7 +194,7 @@ class CreateShoppingListItemView(http.HomeAssistantView): url = '/api/shopping_list/item' name = "api:shopping_list:item" - @http.RequestDataValidator(vol.Schema({ + @RequestDataValidator(vol.Schema({ vol.Required('name'): str, })) @asyncio.coroutine diff --git a/homeassistant/components/sleepiq.py b/homeassistant/components/sleepiq.py index baf6d154c66..3b74b79b36b 100644 --- a/homeassistant/components/sleepiq.py +++ b/homeassistant/components/sleepiq.py @@ -6,6 +6,7 @@ https://home-assistant.io/components/sleepiq/ """ import logging from datetime import timedelta +from requests.exceptions import HTTPError import voluptuous as vol @@ -14,7 +15,6 @@ from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity from homeassistant.const import CONF_USERNAME, CONF_PASSWORD from homeassistant.util import Throttle -from requests.exceptions import HTTPError DOMAIN = 'sleepiq' diff --git a/homeassistant/components/smappee.py b/homeassistant/components/smappee.py new file mode 100644 index 00000000000..0111e0437fb --- /dev/null +++ b/homeassistant/components/smappee.py @@ -0,0 +1,337 @@ +""" +Support for Smappee energy monitor. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/smappee/ +""" +import logging +from datetime import datetime, timedelta +import re +import voluptuous as vol +from requests.exceptions import RequestException +from homeassistant.const import ( + CONF_USERNAME, CONF_PASSWORD, CONF_HOST +) +from homeassistant.util import Throttle +from homeassistant.helpers.discovery import load_platform +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['smappy==0.2.15'] + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Smappee' +DEFAULT_HOST_PASSWORD = 'admin' + +CONF_CLIENT_ID = 'client_id' +CONF_CLIENT_SECRET = 'client_secret' +CONF_HOST_PASSWORD = 'host_password' + +DOMAIN = 'smappee' +DATA_SMAPPEE = 'SMAPPEE' + +_SENSOR_REGEX = re.compile( + r'(?P([A-Za-z]+))\=' + + r'(?P([0-9\.]+))') + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Inclusive(CONF_CLIENT_ID, 'Server credentials'): cv.string, + vol.Inclusive(CONF_CLIENT_SECRET, 'Server credentials'): cv.string, + vol.Inclusive(CONF_USERNAME, 'Server credentials'): cv.string, + vol.Inclusive(CONF_PASSWORD, 'Server credentials'): cv.string, + vol.Optional(CONF_HOST): cv.string, + vol.Optional(CONF_HOST_PASSWORD, default=DEFAULT_HOST_PASSWORD): + cv.string + }), +}, extra=vol.ALLOW_EXTRA) + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) + + +def setup(hass, config): + """Set up the Smapee component.""" + client_id = config.get(DOMAIN).get(CONF_CLIENT_ID) + client_secret = config.get(DOMAIN).get(CONF_CLIENT_SECRET) + username = config.get(DOMAIN).get(CONF_USERNAME) + password = config.get(DOMAIN).get(CONF_PASSWORD) + host = config.get(DOMAIN).get(CONF_HOST) + host_password = config.get(DOMAIN).get(CONF_HOST_PASSWORD) + + smappee = Smappee(client_id, client_secret, username, + password, host, host_password) + + if not smappee.is_local_active and not smappee.is_remote_active: + _LOGGER.error("Neither Smappee server or local component enabled.") + return False + + hass.data[DATA_SMAPPEE] = smappee + load_platform(hass, 'switch', DOMAIN) + load_platform(hass, 'sensor', DOMAIN) + return True + + +class Smappee(object): + """Stores data retrieved from Smappee sensor.""" + + def __init__(self, client_id, client_secret, username, + password, host, host_password): + """Initialize the data.""" + import smappy + + self._remote_active = False + self._local_active = False + if client_id is not None: + try: + self._smappy = smappy.Smappee(client_id, client_secret) + self._smappy.authenticate(username, password) + self._remote_active = True + except RequestException as error: + self._smappy = None + _LOGGER.exception( + "Smappee server authentication failed (%s)", + error) + else: + _LOGGER.warning("Smappee server component init skipped.") + + if host is not None: + try: + self._localsmappy = smappy.LocalSmappee(host) + self._localsmappy.logon(host_password) + self._local_active = True + except RequestException as error: + self._localsmappy = None + _LOGGER.exception( + "Local Smappee device authentication failed (%s)", + error) + else: + _LOGGER.warning("Smappee local component init skipped.") + + self.locations = {} + self.info = {} + self.consumption = {} + self.instantaneous = {} + + if self._remote_active or self._local_active: + self.update() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Update data from Smappee API.""" + if self.is_remote_active: + service_locations = self._smappy.get_service_locations() \ + .get('serviceLocations') + for location in service_locations: + location_id = location.get('serviceLocationId') + if location_id is not None: + self.locations[location_id] = location.get('name') + self.info[location_id] = self._smappy \ + .get_service_location_info(location_id) + _LOGGER.debug("Remote info %s %s", + self.locations, self.info) + + self.consumption[location_id] = self.get_consumption( + location_id, aggregation=3, delta=1440) + _LOGGER.debug("Remote consumption %s %s", + self.locations, + self.consumption[location_id]) + + if self.is_local_active: + self.local_devices = self.get_switches() + _LOGGER.debug("Local switches %s", self.local_devices) + + self.instantaneous = self.load_instantaneous() + _LOGGER.debug("Local values %s", self.instantaneous) + + @property + def is_remote_active(self): + """Return true if Smappe server is configured and working.""" + return self._remote_active + + @property + def is_local_active(self): + """Return true if Smappe local device is configured and working.""" + return self._local_active + + def get_switches(self): + """Get switches from local Smappee.""" + if not self.is_local_active: + return + + try: + return self._localsmappy.load_command_control_config() + except RequestException as error: + _LOGGER.error( + "Error getting switches from local Smappee. (%s)", + error) + + def get_consumption(self, location_id, aggregation, delta): + """Update data from Smappee.""" + # Start & End accept epoch (in milliseconds), + # datetime and pandas timestamps + # Aggregation: + # 1 = 5 min values (only available for the last 14 days), + # 2 = hourly values, + # 3 = daily values, + # 4 = monthly values, + # 5 = quarterly values + if not self.is_remote_active: + return + + end = datetime.utcnow() + start = end - timedelta(minutes=delta) + try: + return self._smappy.get_consumption(location_id, + start, + end, + aggregation) + except RequestException as error: + _LOGGER.error( + "Error getting comsumption from Smappee cloud. (%s)", + error) + + def get_sensor_consumption(self, location_id, sensor_id): + """Update data from Smappee.""" + # Start & End accept epoch (in milliseconds), + # datetime and pandas timestamps + # Aggregation: + # 1 = 5 min values (only available for the last 14 days), + # 2 = hourly values, + # 3 = daily values, + # 4 = monthly values, + # 5 = quarterly values + if not self.is_remote_active: + return + + start = datetime.utcnow() - timedelta(minutes=30) + end = datetime.utcnow() + try: + return self._smappy.get_sensor_consumption(location_id, + sensor_id, + start, + end, 1) + except RequestException as error: + _LOGGER.error( + "Error getting comsumption from Smappee cloud. (%s)", + error) + + def actuator_on(self, location_id, actuator_id, + is_remote_switch, duration=None): + """Turn on actuator.""" + # Duration = 300,900,1800,3600 + # or any other value for an undetermined period of time. + # + # The comport plugs have a tendency to ignore the on/off signal. + # And because you can't read the status of a plug, it's more + # reliable to execute the command twice. + try: + if is_remote_switch: + self._smappy.actuator_on(location_id, actuator_id, duration) + self._smappy.actuator_on(location_id, actuator_id, duration) + else: + self._localsmappy.on_command_control(actuator_id) + self._localsmappy.on_command_control(actuator_id) + except RequestException as error: + _LOGGER.error( + "Error turning actuator on. (%s)", + error) + return False + + return True + + def actuator_off(self, location_id, actuator_id, + is_remote_switch, duration=None): + """Turn off actuator.""" + # Duration = 300,900,1800,3600 + # or any other value for an undetermined period of time. + # + # The comport plugs have a tendency to ignore the on/off signal. + # And because you can't read the status of a plug, it's more + # reliable to execute the command twice. + try: + if is_remote_switch: + self._smappy.actuator_off(location_id, actuator_id, duration) + self._smappy.actuator_off(location_id, actuator_id, duration) + else: + self._localsmappy.off_command_control(actuator_id) + self._localsmappy.off_command_control(actuator_id) + except RequestException as error: + _LOGGER.error( + "Error turning actuator on. (%s)", + error) + return False + + return True + + def active_power(self): + """Get sum of all instantanious active power values from local hub.""" + if not self.is_local_active: + return + + try: + return self._localsmappy.active_power() + except RequestException as error: + _LOGGER.error( + "Error getting data from Local Smappee unit. (%s)", + error) + + def active_cosfi(self): + """Get the average of all instantaneous cosfi values.""" + if not self.is_local_active: + return + + try: + return self._localsmappy.active_cosfi() + except RequestException as error: + _LOGGER.error( + "Error getting data from Local Smappee unit. (%s)", + error) + + def instantaneous_values(self): + """ReportInstantaneousValues.""" + if not self.is_local_active: + return + + report_instantaneous_values = \ + self._localsmappy.report_instantaneous_values() + + report_result = \ + report_instantaneous_values['report'].split('
') + properties = {} + for lines in report_result: + lines_result = lines.split(',') + for prop in lines_result: + match = _SENSOR_REGEX.search(prop) + if match: + properties[match.group('key')] = \ + match.group('value') + _LOGGER.debug(properties) + return properties + + def active_current(self): + """Get current active Amps.""" + if not self.is_local_active: + return + + properties = self.instantaneous_values() + return float(properties['current']) + + def active_voltage(self): + """Get current active Voltage.""" + if not self.is_local_active: + return + + properties = self.instantaneous_values() + return float(properties['voltage']) + + def load_instantaneous(self): + """LoadInstantaneous.""" + if not self.is_local_active: + return + + try: + return self._localsmappy.load_instantaneous() + except RequestException as error: + _LOGGER.error( + "Error getting data from Local Smappee unit. (%s)", + error) diff --git a/homeassistant/components/statsd.py b/homeassistant/components/statsd.py index 3613f53c098..6b528733601 100644 --- a/homeassistant/components/statsd.py +++ b/homeassistant/components/statsd.py @@ -35,7 +35,7 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_PREFIX, default=DEFAULT_PREFIX): cv.string, vol.Optional(CONF_RATE, default=DEFAULT_RATE): vol.All(vol.Coerce(int), vol.Range(min=1)), - vol.Optional(CONF_VALUE_MAP, default=None): dict, + vol.Optional(CONF_VALUE_MAP): dict, }), }, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/components/switch/acer_projector.py b/homeassistant/components/switch/acer_projector.py index 8fd70ec7ed8..527456d6d19 100644 --- a/homeassistant/components/switch/acer_projector.py +++ b/homeassistant/components/switch/acer_projector.py @@ -155,13 +155,13 @@ class AcerSwitch(SwitchDevice): awns = self._write_read_format(msg) self._attributes[key] = awns - def turn_on(self): + def turn_on(self, **kwargs): """Turn the projector on.""" msg = CMD_DICT[STATE_ON] self._write_read(msg) self._state = STATE_ON - def turn_off(self): + def turn_off(self, **kwargs): """Turn the projector off.""" msg = CMD_DICT[STATE_OFF] self._write_read(msg) diff --git a/homeassistant/components/switch/anel_pwrctrl.py b/homeassistant/components/switch/anel_pwrctrl.py index bfa6e2af976..9144222e5c7 100644 --- a/homeassistant/components/switch/anel_pwrctrl.py +++ b/homeassistant/components/switch/anel_pwrctrl.py @@ -101,11 +101,11 @@ class PwrCtrlSwitch(SwitchDevice): """Trigger update for all switches on the parent device.""" self._parent_device.update() - def turn_on(self): + def turn_on(self, **kwargs): """Turn the switch on.""" self._port.on() - def turn_off(self): + def turn_off(self, **kwargs): """Turn the switch off.""" self._port.off() diff --git a/homeassistant/components/switch/arduino.py b/homeassistant/components/switch/arduino.py index 3aa61feffc8..1547f4f1dee 100644 --- a/homeassistant/components/switch/arduino.py +++ b/homeassistant/components/switch/arduino.py @@ -83,12 +83,12 @@ class ArduinoSwitch(SwitchDevice): """Return true if pin is high/on.""" return self._state - def turn_on(self): + def turn_on(self, **kwargs): """Turn the pin to high/on.""" self._state = True self.turn_on_handler(self._pin) - def turn_off(self): + def turn_off(self, **kwargs): """Turn the pin to low/off.""" self._state = False self.turn_off_handler(self._pin) diff --git a/homeassistant/components/switch/broadlink.py b/homeassistant/components/switch/broadlink.py index e79b7c3f34c..91ecc9c7111 100644 --- a/homeassistant/components/switch/broadlink.py +++ b/homeassistant/components/switch/broadlink.py @@ -45,8 +45,8 @@ MP1_TYPES = ['mp1'] SWITCH_TYPES = RM_TYPES + SP1_TYPES + SP2_TYPES + MP1_TYPES SWITCH_SCHEMA = vol.Schema({ - vol.Optional(CONF_COMMAND_OFF, default=None): cv.string, - vol.Optional(CONF_COMMAND_ON, default=None): cv.string, + vol.Optional(CONF_COMMAND_OFF): cv.string, + vol.Optional(CONF_COMMAND_ON): cv.string, vol.Optional(CONF_FRIENDLY_NAME): cv.string, }) diff --git a/homeassistant/components/switch/dlink.py b/homeassistant/components/switch/dlink.py index f6ed6dac018..5d727e72138 100644 --- a/homeassistant/components/switch/dlink.py +++ b/homeassistant/components/switch/dlink.py @@ -117,7 +117,7 @@ class SmartPlugSwitch(SwitchDevice): """Turn the switch on.""" self.data.smartplug.state = 'ON' - def turn_off(self): + def turn_off(self, **kwargs): """Turn the switch off.""" self.data.smartplug.state = 'OFF' diff --git a/homeassistant/components/switch/edimax.py b/homeassistant/components/switch/edimax.py index c5973c3ee04..d4b02749c1b 100644 --- a/homeassistant/components/switch/edimax.py +++ b/homeassistant/components/switch/edimax.py @@ -77,7 +77,7 @@ class SmartPlugSwitch(SwitchDevice): """Turn the switch on.""" self.smartplug.state = 'ON' - def turn_off(self): + def turn_off(self, **kwargs): """Turn the switch off.""" self.smartplug.state = 'OFF' diff --git a/homeassistant/components/switch/fritzdect.py b/homeassistant/components/switch/fritzdect.py index 8ddfca05fb6..58ad745a2d2 100644 --- a/homeassistant/components/switch/fritzdect.py +++ b/homeassistant/components/switch/fritzdect.py @@ -130,7 +130,7 @@ class FritzDectSwitch(SwitchDevice): _LOGGER.error("Fritz!Box query failed, triggering relogin") self.data.is_online = False - def turn_off(self): + def turn_off(self, **kwargs): """Turn the switch off.""" if not self.data.is_online: _LOGGER.error("turn_off: Not online skipping request") diff --git a/homeassistant/components/switch/gc100.py b/homeassistant/components/switch/gc100.py index ed50c3f63f6..f4175926aa0 100644 --- a/homeassistant/components/switch/gc100.py +++ b/homeassistant/components/switch/gc100.py @@ -56,11 +56,11 @@ class GC100Switch(ToggleEntity): """Return the state of the entity.""" return self._state - def turn_on(self): + def turn_on(self, **kwargs): """Turn the device on.""" self._gc100.write_switch(self._port_addr, 1, self.set_state) - def turn_off(self): + def turn_off(self, **kwargs): """Turn the device off.""" self._gc100.write_switch(self._port_addr, 0, self.set_state) diff --git a/homeassistant/components/switch/hdmi_cec.py b/homeassistant/components/switch/hdmi_cec.py index 65a7a762c0f..e81c09894ab 100644 --- a/homeassistant/components/switch/hdmi_cec.py +++ b/homeassistant/components/switch/hdmi_cec.py @@ -47,7 +47,7 @@ class CecSwitchDevice(CecDevice, SwitchDevice): self._device.turn_off() self._state = STATE_ON - def toggle(self): + def toggle(self, **kwargs): """Toggle the entity.""" self._device.toggle() if self._state == STATE_ON: diff --git a/homeassistant/components/switch/ihc.py b/homeassistant/components/switch/ihc.py index eab88035c73..499a4ca53a7 100644 --- a/homeassistant/components/switch/ihc.py +++ b/homeassistant/components/switch/ihc.py @@ -53,7 +53,7 @@ class IHCSwitch(IHCDevice, SwitchDevice): """IHC Switch.""" def __init__(self, ihc_controller, name: str, ihc_id: int, - info: bool, product: Element=None) -> None: + info: bool, product: Element = None) -> None: """Initialize the IHC switch.""" super().__init__(ihc_controller, name, ihc_id, product) self._state = False diff --git a/homeassistant/components/switch/isy994.py b/homeassistant/components/switch/isy994.py index f0fd397710e..efdda6ed40c 100644 --- a/homeassistant/components/switch/isy994.py +++ b/homeassistant/components/switch/isy994.py @@ -33,10 +33,6 @@ def setup_platform(hass, config: ConfigType, class ISYSwitchDevice(ISYDevice, SwitchDevice): """Representation of an ISY994 switch device.""" - def __init__(self, node) -> None: - """Initialize the ISY994 switch device.""" - super().__init__(node) - @property def is_on(self) -> bool: """Get whether the ISY994 device is in the on state.""" diff --git a/homeassistant/components/switch/knx.py b/homeassistant/components/switch/knx.py index 01c08767ca0..86a9adf0495 100644 --- a/homeassistant/components/switch/knx.py +++ b/homeassistant/components/switch/knx.py @@ -30,9 +30,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up switch(es) for KNX platform.""" - if DATA_KNX not in hass.data or not hass.data[DATA_KNX].initialized: - return - if discovery_info is not None: async_add_devices_discovery(hass, discovery_info, async_add_devices) else: diff --git a/homeassistant/components/switch/modbus.py b/homeassistant/components/switch/modbus.py index 211ff54d5a4..ca70c212774 100644 --- a/homeassistant/components/switch/modbus.py +++ b/homeassistant/components/switch/modbus.py @@ -37,12 +37,12 @@ REGISTERS_SCHEMA = vol.Schema({ vol.Required(CONF_COMMAND_ON): cv.positive_int, vol.Required(CONF_COMMAND_OFF): cv.positive_int, vol.Optional(CONF_VERIFY_STATE, default=True): cv.boolean, - vol.Optional(CONF_VERIFY_REGISTER, default=None): + vol.Optional(CONF_VERIFY_REGISTER): cv.positive_int, vol.Optional(CONF_REGISTER_TYPE, default=REGISTER_TYPE_HOLDING): vol.In([REGISTER_TYPE_HOLDING, REGISTER_TYPE_INPUT]), - vol.Optional(CONF_STATE_ON, default=None): cv.positive_int, - vol.Optional(CONF_STATE_OFF, default=None): cv.positive_int, + vol.Optional(CONF_STATE_ON): cv.positive_int, + vol.Optional(CONF_STATE_OFF): cv.positive_int, }) COILS_SCHEMA = vol.Schema({ diff --git a/homeassistant/components/switch/netio.py b/homeassistant/components/switch/netio.py index 2a72703c5df..365bbaa3679 100644 --- a/homeassistant/components/switch/netio.py +++ b/homeassistant/components/switch/netio.py @@ -141,11 +141,11 @@ class NetioSwitch(SwitchDevice): """Return true if entity is available.""" return not hasattr(self, 'telnet') - def turn_on(self): + def turn_on(self, **kwargs): """Turn switch on.""" self._set(True) - def turn_off(self): + def turn_off(self, **kwargs): """Turn switch off.""" self._set(False) diff --git a/homeassistant/components/switch/pilight.py b/homeassistant/components/switch/pilight.py index 1ce599366a1..57fa4b00c98 100644 --- a/homeassistant/components/switch/pilight.py +++ b/homeassistant/components/switch/pilight.py @@ -188,10 +188,10 @@ class PilightSwitch(SwitchDevice): self._state = turn_on self.schedule_update_ha_state() - def turn_on(self): + def turn_on(self, **kwargs): """Turn the switch on by calling pilight.send service with on code.""" self.set_state(turn_on=True) - def turn_off(self): + def turn_off(self, **kwargs): """Turn the switch on by calling pilight.send service with off code.""" self.set_state(turn_on=False) diff --git a/homeassistant/components/switch/rachio.py b/homeassistant/components/switch/rachio.py index d8d424be361..dc661c3e5bf 100644 --- a/homeassistant/components/switch/rachio.py +++ b/homeassistant/components/switch/rachio.py @@ -216,7 +216,7 @@ class RachioZone(SwitchDevice): _LOGGER.debug("Updated %s", str(self)) - def turn_on(self): + def turn_on(self, **kwargs): """Start the zone.""" # Stop other zones first self.turn_off() @@ -224,7 +224,7 @@ class RachioZone(SwitchDevice): _LOGGER.info("Watering %s for %d s", self.name, self._manual_run_secs) self.rachio.zone.start(self.zone_id, self._manual_run_secs) - def turn_off(self): + def turn_off(self, **kwargs): """Stop all zones.""" _LOGGER.info("Stopping watering of all zones") self.rachio.device.stopWater(self._device.device_id) diff --git a/homeassistant/components/switch/raincloud.py b/homeassistant/components/switch/raincloud.py index a18d6544acc..8a5c4347cf7 100644 --- a/homeassistant/components/switch/raincloud.py +++ b/homeassistant/components/switch/raincloud.py @@ -59,7 +59,7 @@ class RainCloudSwitch(RainCloudEntity, SwitchDevice): """Return true if device is on.""" return self._state - def turn_on(self): + def turn_on(self, **kwargs): """Turn the device on.""" if self._sensor_type == 'manual_watering': self.data.watering_time = self._default_watering_timer @@ -67,7 +67,7 @@ class RainCloudSwitch(RainCloudEntity, SwitchDevice): self.data.auto_watering = True self._state = True - def turn_off(self): + def turn_off(self, **kwargs): """Turn the device off.""" if self._sensor_type == 'manual_watering': self.data.watering_time = 'off' diff --git a/homeassistant/components/switch/rainmachine.py b/homeassistant/components/switch/rainmachine.py index 3147ded96bd..99d41bdd9c3 100644 --- a/homeassistant/components/switch/rainmachine.py +++ b/homeassistant/components/switch/rainmachine.py @@ -32,22 +32,16 @@ PLATFORM_SCHEMA = vol.Schema( vol.All( cv.has_at_least_one_key(CONF_IP_ADDRESS, CONF_EMAIL), { - vol.Required(CONF_PLATFORM): - cv.string, - vol.Optional(CONF_SCAN_INTERVAL): - cv.time_period, - vol.Exclusive(CONF_IP_ADDRESS, 'auth'): - cv.string, + vol.Required(CONF_PLATFORM): cv.string, + vol.Optional(CONF_SCAN_INTERVAL): cv.time_period, + vol.Exclusive(CONF_IP_ADDRESS, 'auth'): cv.string, vol.Exclusive(CONF_EMAIL, 'auth'): - vol.Email(), # pylint: disable=no-value-for-parameter - vol.Required(CONF_PASSWORD): - cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): - cv.port, - vol.Optional(CONF_SSL, default=DEFAULT_SSL): - cv.boolean, + vol.Email(), # pylint: disable=no-value-for-parameter + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, vol.Optional(CONF_ZONE_RUN_TIME, default=DEFAULT_ZONE_RUN_SECONDS): - cv.positive_int + cv.positive_int }), extra=vol.ALLOW_EXTRA) @@ -56,27 +50,19 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set this component up under its platform.""" import regenmaschine as rm - ip_address = config.get(CONF_IP_ADDRESS) - _LOGGER.debug('IP address: %s', ip_address) + _LOGGER.debug('Config data: %s', config) - email_address = config.get(CONF_EMAIL) - _LOGGER.debug('Email address: %s', email_address) - - password = config.get(CONF_PASSWORD) - _LOGGER.debug('Password: %s', password) - - zone_run_time = config.get(CONF_ZONE_RUN_TIME) - _LOGGER.debug('Zone run time: %s', zone_run_time) + ip_address = config.get(CONF_IP_ADDRESS, None) + email_address = config.get(CONF_EMAIL, None) + password = config[CONF_PASSWORD] + zone_run_time = config[CONF_ZONE_RUN_TIME] try: if ip_address: - port = config.get(CONF_PORT) - _LOGGER.debug('Port: %s', port) - - ssl = config.get(CONF_SSL) - _LOGGER.debug('SSL: %s', ssl) - _LOGGER.debug('Configuring local API') + + port = config[CONF_PORT] + ssl = config[CONF_SSL] auth = rm.Authenticator.create_local( ip_address, password, port=port, https=ssl) elif email_address: @@ -85,32 +71,27 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.debug('Querying against: %s', auth.url) - _LOGGER.debug('Instantiating RainMachine client') client = rm.Client(auth) - - rainmachine_device_name = client.provision.device_name().get('name') + device_name = client.provision.device_name()['name'] + device_mac = client.provision.wifi()['macAddress'] entities = [] - for program in client.programs.all().get('programs'): + for program in client.programs.all().get('programs', {}): if not program.get('active'): continue _LOGGER.debug('Adding program: %s', program) entities.append( - RainMachineProgram( - client, program, device_name=rainmachine_device_name)) + RainMachineProgram(client, device_name, device_mac, program)) - for zone in client.zones.all().get('zones'): + for zone in client.zones.all().get('zones', {}): if not zone.get('active'): continue _LOGGER.debug('Adding zone: %s', zone) entities.append( - RainMachineZone( - client, - zone, - zone_run_time, - device_name=rainmachine_device_name, )) + RainMachineZone(client, device_name, device_mac, zone, + zone_run_time)) add_devices(entities) except rm.exceptions.HTTPError as exc_info: @@ -149,16 +130,17 @@ def aware_throttle(api_type): class RainMachineEntity(SwitchDevice): """A class to represent a generic RainMachine entity.""" - def __init__(self, client, entity_json, **kwargs): + def __init__(self, client, device_name, device_mac, entity_json): """Initialize a generic RainMachine entity.""" self._api_type = 'remote' if client.auth.using_remote_api else 'local' self._client = client - self._device_name = kwargs.get('device_name') self._entity_json = entity_json + self.device_mac = device_mac + self.device_name = device_name self._attrs = { ATTR_ATTRIBUTION: '© RainMachine', - ATTR_DEVICE_CLASS: self._device_name + ATTR_DEVICE_CLASS: self.device_name } @property @@ -173,15 +155,10 @@ class RainMachineEntity(SwitchDevice): return self._entity_json.get('active') @property - def rainmachine_id(self) -> int: + def rainmachine_entity_id(self) -> int: """Return the RainMachine ID for this entity.""" return self._entity_json.get('uid') - @property - def unique_id(self) -> str: - """Return a unique, HASS-friendly identifier for this entity.""" - return self.rainmachine_id - @aware_throttle('local') def _local_update(self) -> None: """Call an update with scan times appropriate for the local API.""" @@ -217,17 +194,22 @@ class RainMachineProgram(RainMachineEntity): """Return the name of the program.""" return 'Program: {}'.format(self._entity_json.get('name')) + @property + def unique_id(self) -> str: + """Return a unique, HASS-friendly identifier for this entity.""" + return '{0}_program_{1}'.format( + self.device_mac.replace(':', ''), self.rainmachine_entity_id) + def turn_off(self, **kwargs) -> None: """Turn the program off.""" import regenmaschine.exceptions as exceptions try: - self._client.programs.stop(self.rainmachine_id) + self._client.programs.stop(self.rainmachine_entity_id) except exceptions.BrokenAPICall: _LOGGER.error('programs.stop currently broken in remote API') except exceptions.HTTPError as exc_info: - _LOGGER.error('Unable to turn off program "%s"', - self.rainmachine_id) + _LOGGER.error('Unable to turn off program "%s"', self.unique_id) _LOGGER.debug(exc_info) def turn_on(self, **kwargs) -> None: @@ -235,12 +217,11 @@ class RainMachineProgram(RainMachineEntity): import regenmaschine.exceptions as exceptions try: - self._client.programs.start(self.rainmachine_id) + self._client.programs.start(self.rainmachine_entity_id) except exceptions.BrokenAPICall: _LOGGER.error('programs.start currently broken in remote API') except exceptions.HTTPError as exc_info: - _LOGGER.error('Unable to turn on program "%s"', - self.rainmachine_id) + _LOGGER.error('Unable to turn on program "%s"', self.unique_id) _LOGGER.debug(exc_info) def _update(self) -> None: @@ -248,25 +229,25 @@ class RainMachineProgram(RainMachineEntity): import regenmaschine.exceptions as exceptions try: - self._entity_json = self._client.programs.get(self.rainmachine_id) + self._entity_json = self._client.programs.get( + self.rainmachine_entity_id) except exceptions.HTTPError as exc_info: _LOGGER.error('Unable to update info for program "%s"', - self.rainmachine_id) + self.unique_id) _LOGGER.debug(exc_info) class RainMachineZone(RainMachineEntity): """A RainMachine zone.""" - def __init__(self, client, zone_json, zone_run_time, **kwargs): + def __init__(self, client, device_name, device_mac, zone_json, + zone_run_time): """Initialize a RainMachine zone.""" - super().__init__(client, zone_json, **kwargs) + super().__init__(client, device_name, device_mac, zone_json) self._run_time = zone_run_time self._attrs.update({ - ATTR_CYCLES: - self._entity_json.get('noOfCycles'), - ATTR_TOTAL_DURATION: - self._entity_json.get('userDuration') + ATTR_CYCLES: self._entity_json.get('noOfCycles'), + ATTR_TOTAL_DURATION: self._entity_json.get('userDuration') }) @property @@ -279,14 +260,20 @@ class RainMachineZone(RainMachineEntity): """Return the name of the zone.""" return 'Zone: {}'.format(self._entity_json.get('name')) + @property + def unique_id(self) -> str: + """Return a unique, HASS-friendly identifier for this entity.""" + return '{0}_zone_{1}'.format( + self.device_mac.replace(':', ''), self.rainmachine_entity_id) + def turn_off(self, **kwargs) -> None: """Turn the zone off.""" import regenmaschine.exceptions as exceptions try: - self._client.zones.stop(self.rainmachine_id) + self._client.zones.stop(self.rainmachine_entity_id) except exceptions.HTTPError as exc_info: - _LOGGER.error('Unable to turn off zone "%s"', self.rainmachine_id) + _LOGGER.error('Unable to turn off zone "%s"', self.unique_id) _LOGGER.debug(exc_info) def turn_on(self, **kwargs) -> None: @@ -294,9 +281,10 @@ class RainMachineZone(RainMachineEntity): import regenmaschine.exceptions as exceptions try: - self._client.zones.start(self.rainmachine_id, self._run_time) + self._client.zones.start(self.rainmachine_entity_id, + self._run_time) except exceptions.HTTPError as exc_info: - _LOGGER.error('Unable to turn on zone "%s"', self.rainmachine_id) + _LOGGER.error('Unable to turn on zone "%s"', self.unique_id) _LOGGER.debug(exc_info) def _update(self) -> None: @@ -304,8 +292,9 @@ class RainMachineZone(RainMachineEntity): import regenmaschine.exceptions as exceptions try: - self._entity_json = self._client.zones.get(self.rainmachine_id) + self._entity_json = self._client.zones.get( + self.rainmachine_entity_id) except exceptions.HTTPError as exc_info: _LOGGER.error('Unable to update info for zone "%s"', - self.rainmachine_id) + self.unique_id) _LOGGER.debug(exc_info) diff --git a/homeassistant/components/switch/raspihats.py b/homeassistant/components/switch/raspihats.py index 183ee6edb77..7be3a6f0baa 100644 --- a/homeassistant/components/switch/raspihats.py +++ b/homeassistant/components/switch/raspihats.py @@ -25,7 +25,7 @@ _CHANNELS_SCHEMA = vol.Schema([{ vol.Required(CONF_INDEX): cv.positive_int, vol.Required(CONF_NAME): cv.string, vol.Optional(CONF_INVERT_LOGIC, default=False): cv.boolean, - vol.Optional(CONF_INITIAL_STATE, default=None): cv.boolean, + vol.Optional(CONF_INITIAL_STATE): cv.boolean, }]) _I2C_HATS_SCHEMA = vol.Schema([{ @@ -56,7 +56,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): board, address, channel_config[CONF_INDEX], channel_config[CONF_NAME], channel_config[CONF_INVERT_LOGIC], - channel_config[CONF_INITIAL_STATE] + channel_config.get(CONF_INITIAL_STATE) ) ) except I2CHatsException as ex: @@ -121,7 +121,7 @@ class I2CHatSwitch(ToggleEntity): _LOGGER.error(self._log_message("Is ON check failed, " + str(ex))) return False - def turn_on(self): + def turn_on(self, **kwargs): """Turn the device on.""" try: state = True if self._invert_logic is False else False @@ -130,7 +130,7 @@ class I2CHatSwitch(ToggleEntity): except I2CHatsException as ex: _LOGGER.error(self._log_message("Turn ON failed, " + str(ex))) - def turn_off(self): + def turn_off(self, **kwargs): """Turn the device off.""" try: state = False if self._invert_logic is False else True diff --git a/homeassistant/components/switch/rest.py b/homeassistant/components/switch/rest.py index c0f75509425..b68cc038e89 100644 --- a/homeassistant/components/switch/rest.py +++ b/homeassistant/components/switch/rest.py @@ -17,7 +17,6 @@ from homeassistant.const import ( CONF_PASSWORD) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.template import Template _LOGGER = logging.getLogger(__name__) @@ -26,8 +25,8 @@ CONF_BODY_ON = 'body_on' CONF_IS_ON_TEMPLATE = 'is_on_template' DEFAULT_METHOD = 'post' -DEFAULT_BODY_OFF = Template('OFF') -DEFAULT_BODY_ON = Template('ON') +DEFAULT_BODY_OFF = 'OFF' +DEFAULT_BODY_ON = 'ON' DEFAULT_NAME = 'REST Switch' DEFAULT_TIMEOUT = 10 diff --git a/homeassistant/components/switch/rpi_pfio.py b/homeassistant/components/switch/rpi_pfio.py index a493a8e9589..c10f417ba49 100644 --- a/homeassistant/components/switch/rpi_pfio.py +++ b/homeassistant/components/switch/rpi_pfio.py @@ -26,7 +26,7 @@ CONF_PORTS = 'ports' DEFAULT_INVERT_LOGIC = False PORT_SCHEMA = vol.Schema({ - vol.Optional(ATTR_NAME, default=None): cv.string, + vol.Optional(ATTR_NAME): cv.string, vol.Optional(ATTR_INVERT_LOGIC, default=DEFAULT_INVERT_LOGIC): cv.boolean, }) @@ -42,7 +42,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): switches = [] ports = config.get(CONF_PORTS) for port, port_entity in ports.items(): - name = port_entity[ATTR_NAME] + name = port_entity.get(ATTR_NAME) invert_logic = port_entity[ATTR_INVERT_LOGIC] switches.append(RPiPFIOSwitch(port, name, invert_logic)) @@ -75,13 +75,13 @@ class RPiPFIOSwitch(ToggleEntity): """Return true if device is on.""" return self._state - def turn_on(self): + def turn_on(self, **kwargs): """Turn the device on.""" rpi_pfio.write_output(self._port, 0 if self._invert_logic else 1) self._state = True self.schedule_update_ha_state() - def turn_off(self): + def turn_off(self, **kwargs): """Turn the device off.""" rpi_pfio.write_output(self._port, 1 if self._invert_logic else 0) self._state = False diff --git a/homeassistant/components/switch/rpi_rf.py b/homeassistant/components/switch/rpi_rf.py index 94a61314d1d..40200f05806 100644 --- a/homeassistant/components/switch/rpi_rf.py +++ b/homeassistant/components/switch/rpi_rf.py @@ -44,7 +44,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -# pylint: disable=unused-argument, import-error +# pylint: disable=unused-argument, import-error, no-member def setup_platform(hass, config, add_devices, discovery_info=None): """Find and return switches controlled by a generic RF device via GPIO.""" import rpi_rf @@ -117,13 +117,13 @@ class RPiRFSwitch(SwitchDevice): self._rfdevice.tx_code(code, protocol, pulselength) return True - def turn_on(self): + def turn_on(self, **kwargs): """Turn the switch on.""" if self._send_code(self._code_on, self._protocol, self._pulselength): self._state = True self.schedule_update_ha_state() - def turn_off(self): + def turn_off(self, **kwargs): """Turn the switch off.""" if self._send_code(self._code_off, self._protocol, self._pulselength): self._state = False diff --git a/homeassistant/components/switch/smappee.py b/homeassistant/components/switch/smappee.py new file mode 100644 index 00000000000..fd8f141500b --- /dev/null +++ b/homeassistant/components/switch/smappee.py @@ -0,0 +1,92 @@ +""" +Support for interacting with Smappee Comport Plugs. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/switch.smappee/ +""" +import logging + +from homeassistant.components.smappee import DATA_SMAPPEE +from homeassistant.components.switch import (SwitchDevice) + +DEPENDENCIES = ['smappee'] + +_LOGGER = logging.getLogger(__name__) + +ICON = 'mdi:power-plug' + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Smappee Comfort Plugs.""" + smappee = hass.data[DATA_SMAPPEE] + + dev = [] + if smappee.is_remote_active: + for location_id in smappee.locations.keys(): + for items in smappee.info[location_id].get('actuators'): + if items.get('name') != '': + _LOGGER.debug("Remote actuator %s", items) + dev.append(SmappeeSwitch(smappee, + items.get('name'), + location_id, + items.get('id'))) + elif smappee.is_local_active: + for items in smappee.local_devices: + _LOGGER.debug("Local actuator %s", items) + dev.append(SmappeeSwitch(smappee, + items.get('value'), + None, + items.get('key'))) + add_devices(dev) + + +class SmappeeSwitch(SwitchDevice): + """Representation of a Smappee Comport Plug.""" + + def __init__(self, smappee, name, location_id, switch_id): + """Initialize a new Smappee Comfort Plug.""" + self._name = name + self._state = False + self._smappee = smappee + self._location_id = location_id + self._switch_id = switch_id + self._remoteswitch = True + if location_id is None: + self._remoteswitch = False + + @property + def name(self): + """Return the name of the switch.""" + return self._name + + @property + def is_on(self): + """Return true if switch is on.""" + return self._state + + @property + def icon(self): + """Icon to use in the frontend.""" + return ICON + + def turn_on(self, **kwargs): + """Turn on Comport Plug.""" + if self._smappee.actuator_on(self._location_id, self._switch_id, + self._remoteswitch): + self._state = True + + def turn_off(self, **kwargs): + """Turn off Comport Plug.""" + if self._smappee.actuator_off(self._location_id, self._switch_id, + self._remoteswitch): + self._state = False + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + attr = {} + if self._remoteswitch: + attr['Location Id'] = self._location_id + attr['Location Name'] = self._smappee.locations[self._location_id] + attr['Switch Id'] = self._switch_id + return attr diff --git a/homeassistant/components/switch/snmp.py b/homeassistant/components/switch/snmp.py index 8aa9744b3da..b0c192cdafa 100644 --- a/homeassistant/components/switch/snmp.py +++ b/homeassistant/components/switch/snmp.py @@ -95,13 +95,13 @@ class SnmpSwitch(SwitchDevice): self._payload_on = payload_on self._payload_off = payload_off - def turn_on(self): + def turn_on(self, **kwargs): """Turn on the switch.""" from pyasn1.type.univ import (Integer) self._set(Integer(self._command_payload_on)) - def turn_off(self): + def turn_off(self, **kwargs): """Turn off the switch.""" from pyasn1.type.univ import (Integer) diff --git a/homeassistant/components/switch/toon.py b/homeassistant/components/switch/toon.py index 09dc45c6587..94086d819e2 100644 --- a/homeassistant/components/switch/toon.py +++ b/homeassistant/components/switch/toon.py @@ -64,7 +64,7 @@ class EnecoSmartPlug(SwitchDevice): """Turn the switch on.""" return self.smartplug.turn_on() - def turn_off(self): + def turn_off(self, **kwargs): """Turn the switch off.""" return self.smartplug.turn_off() diff --git a/homeassistant/components/switch/tplink.py b/homeassistant/components/switch/tplink.py index 14faa98fb59..1eca5284f76 100644 --- a/homeassistant/components/switch/tplink.py +++ b/homeassistant/components/switch/tplink.py @@ -75,7 +75,7 @@ class SmartPlugSwitch(SwitchDevice): """Turn the switch on.""" self.smartplug.turn_on() - def turn_off(self): + def turn_off(self, **kwargs): """Turn the switch off.""" self.smartplug.turn_off() diff --git a/homeassistant/components/switch/verisure.py b/homeassistant/components/switch/verisure.py index 710580c2ec6..810946a5058 100644 --- a/homeassistant/components/switch/verisure.py +++ b/homeassistant/components/switch/verisure.py @@ -60,13 +60,13 @@ class VerisureSmartplug(SwitchDevice): "$.smartPlugs[?(@.deviceLabel == '%s')]", self._device_label) is not None - def turn_on(self): + def turn_on(self, **kwargs): """Set smartplug status on.""" hub.session.set_smartplug_state(self._device_label, True) self._state = True self._change_timestamp = time() - def turn_off(self): + def turn_off(self, **kwargs): """Set smartplug status off.""" hub.session.set_smartplug_state(self._device_label, False) self._state = False diff --git a/homeassistant/components/switch/vultr.py b/homeassistant/components/switch/vultr.py index a044fca2972..fe3d67470d7 100644 --- a/homeassistant/components/switch/vultr.py +++ b/homeassistant/components/switch/vultr.py @@ -90,12 +90,12 @@ class VultrSwitch(SwitchDevice): ATTR_VCPUS: self.data.get('vcpu_count'), } - def turn_on(self): + def turn_on(self, **kwargs): """Boot-up the subscription.""" if self.data['power_status'] != 'running': self._vultr.start(self.subscription) - def turn_off(self): + def turn_off(self, **kwargs): """Halt the subscription.""" if self.data['power_status'] == 'running': self._vultr.halt(self.subscription) diff --git a/homeassistant/components/switch/wake_on_lan.py b/homeassistant/components/switch/wake_on_lan.py index ecaff14e2e2..80102621f7d 100644 --- a/homeassistant/components/switch/wake_on_lan.py +++ b/homeassistant/components/switch/wake_on_lan.py @@ -78,7 +78,7 @@ class WOLSwitch(SwitchDevice): """Return the name of the switch.""" return self._name - def turn_on(self): + def turn_on(self, **kwargs): """Turn the device on.""" if self._broadcast_address: self._wol.send_magic_packet( @@ -86,7 +86,7 @@ class WOLSwitch(SwitchDevice): else: self._wol.send_magic_packet(self._mac_address) - def turn_off(self): + def turn_off(self, **kwargs): """Turn the device off if an off action is present.""" if self._off_script is not None: self._off_script.run() diff --git a/homeassistant/components/switch/wink.py b/homeassistant/components/switch/wink.py index 5a43de9425c..6a244615065 100644 --- a/homeassistant/components/switch/wink.py +++ b/homeassistant/components/switch/wink.py @@ -54,7 +54,7 @@ class WinkToggleDevice(WinkDevice, ToggleEntity): """Turn the device on.""" self.wink.set_state(True) - def turn_off(self): + def turn_off(self, **kwargs): """Turn the device off.""" self.wink.set_state(False) diff --git a/homeassistant/components/switch/xiaomi_aqara.py b/homeassistant/components/switch/xiaomi_aqara.py index 578036a1677..1688b6b89e1 100644 --- a/homeassistant/components/switch/xiaomi_aqara.py +++ b/homeassistant/components/switch/xiaomi_aqara.py @@ -101,7 +101,7 @@ class XiaomiGenericSwitch(XiaomiDevice, SwitchDevice): self._state = True self.schedule_update_ha_state() - def turn_off(self): + def turn_off(self, **kwargs): """Turn the switch off.""" if self._write_to_hub(self._sid, **{self._data_key: 'off'}): self._state = False diff --git a/homeassistant/components/switch/xiaomi_miio.py b/homeassistant/components/switch/xiaomi_miio.py index ad71b3944cf..7defc3d3b2b 100644 --- a/homeassistant/components/switch/xiaomi_miio.py +++ b/homeassistant/components/switch/xiaomi_miio.py @@ -25,7 +25,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) -REQUIREMENTS = ['python-miio==0.3.6'] +REQUIREMENTS = ['python-miio==0.3.7'] ATTR_POWER = 'power' ATTR_TEMPERATURE = 'temperature' diff --git a/homeassistant/components/switch/zoneminder.py b/homeassistant/components/switch/zoneminder.py index 5dffd99c324..adf3bf2d9bd 100644 --- a/homeassistant/components/switch/zoneminder.py +++ b/homeassistant/components/switch/zoneminder.py @@ -75,14 +75,14 @@ class ZMSwitchMonitors(SwitchDevice): """Return True if entity is on.""" return self._state - def turn_on(self): + def turn_on(self, **kwargs): """Turn the entity on.""" zoneminder.change_state( 'api/monitors/%i.json' % self._monitor_id, {'Monitor[Function]': self._on_state} ) - def turn_off(self): + def turn_off(self, **kwargs): """Turn the entity off.""" zoneminder.change_state( 'api/monitors/%i.json' % self._monitor_id, diff --git a/homeassistant/components/tahoma.py b/homeassistant/components/tahoma.py index 28a54f40d56..b288a704d74 100644 --- a/homeassistant/components/tahoma.py +++ b/homeassistant/components/tahoma.py @@ -14,7 +14,7 @@ from homeassistant.helpers import discovery from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['tahoma-api==0.0.11'] +REQUIREMENTS = ['tahoma-api==0.0.12'] _LOGGER = logging.getLogger(__name__) @@ -32,7 +32,7 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) TAHOMA_COMPONENTS = [ - 'sensor', 'cover' + 'scene', 'sensor', 'cover' ] TAHOMA_TYPES = { @@ -57,19 +57,21 @@ def setup(hass, config): try: api = TahomaApi(username, password) except RequestException: - _LOGGER.exception("Error communicating with Tahoma API") + _LOGGER.exception("Error when trying to log in to the Tahoma API") return False try: api.get_setup() devices = api.get_devices() + scenes = api.get_action_groups() except RequestException: - _LOGGER.exception("Cannot fetch information from Tahoma API") + _LOGGER.exception("Error when getting devices from the Tahoma API") return False hass.data[DOMAIN] = { 'controller': api, - 'devices': defaultdict(list) + 'devices': defaultdict(list), + 'scenes': [] } for device in devices: @@ -82,6 +84,9 @@ def setup(hass, config): continue hass.data[DOMAIN]['devices'][device_type].append(_device) + for scene in scenes: + hass.data[DOMAIN]['scenes'].append(scene) + for component in TAHOMA_COMPONENTS: discovery.load_platform(hass, component, DOMAIN, {}, config) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 170e1517a6d..d4ac115d9c6 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -96,6 +96,7 @@ BASE_SERVICE_SCHEMA = vol.Schema({ vol.Optional(ATTR_DISABLE_WEB_PREV): cv.boolean, vol.Optional(ATTR_KEYBOARD): vol.All(cv.ensure_list, [cv.string]), vol.Optional(ATTR_KEYBOARD_INLINE): cv.ensure_list, + vol.Optional(CONF_TIMEOUT): vol.Coerce(float), }, extra=vol.ALLOW_EXTRA) SERVICE_SCHEMA_SEND_MESSAGE = BASE_SERVICE_SCHEMA.extend({ diff --git a/homeassistant/components/telegram_bot/polling.py b/homeassistant/components/telegram_bot/polling.py index 0ce11441843..bec239ba1dd 100644 --- a/homeassistant/components/telegram_bot/polling.py +++ b/homeassistant/components/telegram_bot/polling.py @@ -93,7 +93,7 @@ class TelegramPoll(BaseTelegramBotEntity): _json = yield from resp.json() return _json else: - raise WrongHttpStatus('wrong status %s', resp.status) + raise WrongHttpStatus('wrong status {}'.format(resp.status)) finally: if resp is not None: yield from resp.release() diff --git a/homeassistant/components/telegram_bot/webhooks.py b/homeassistant/components/telegram_bot/webhooks.py index 055f68884a6..5c293459447 100644 --- a/homeassistant/components/telegram_bot/webhooks.py +++ b/homeassistant/components/telegram_bot/webhooks.py @@ -12,7 +12,7 @@ import logging import voluptuous as vol from homeassistant.components.http import HomeAssistantView -from homeassistant.components.http.util import get_real_ip +from homeassistant.components.http.const import KEY_REAL_IP from homeassistant.components.telegram_bot import ( CONF_ALLOWED_CHAT_IDS, BaseTelegramBotEntity, PLATFORM_SCHEMA) from homeassistant.const import ( @@ -110,7 +110,7 @@ class BotPushReceiver(HomeAssistantView, BaseTelegramBotEntity): @asyncio.coroutine def post(self, request): """Accept the POST from telegram.""" - real_ip = get_real_ip(request) + real_ip = request[KEY_REAL_IP] if not any(real_ip in net for net in self.trusted_networks): _LOGGER.warning("Access denied from %s", real_ip) return self.json_message('Access denied', HTTP_UNAUTHORIZED) diff --git a/homeassistant/components/tellduslive.py b/homeassistant/components/tellduslive.py index 28bf65bc4c5..dfb4b1e5fa9 100644 --- a/homeassistant/components/tellduslive.py +++ b/homeassistant/components/tellduslive.py @@ -7,6 +7,8 @@ https://home-assistant.io/components/tellduslive/ from datetime import datetime, timedelta import logging +import voluptuous as vol + from homeassistant.const import ( ATTR_BATTERY_LEVEL, DEVICE_DEFAULT_NAME, CONF_TOKEN, CONF_HOST, @@ -18,7 +20,6 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import track_point_in_utc_time from homeassistant.util.dt import utcnow from homeassistant.util.json import load_json, save_json -import voluptuous as vol APPLICATION_NAME = 'Home Assistant' @@ -352,8 +353,7 @@ class TelldusLiveEntity(Entity): return None elif self.device.battery == BATTERY_OK: return 100 - else: - return self.device.battery # Percentage + return self.device.battery # Percentage @property def _last_updated(self): diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 532b4529eca..17aa66ea825 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -411,7 +411,7 @@ class SpeechManager(object): if key not in self.mem_cache: if key not in self.file_cache: - raise HomeAssistantError("%s not in cache!", key) + raise HomeAssistantError("{} not in cache!".format(key)) yield from self.async_file_to_mem(key) content, _ = mimetypes.guess_type(filename) diff --git a/homeassistant/components/upnp.py b/homeassistant/components/upnp.py index 87990495cf4..960d8f3780e 100644 --- a/homeassistant/components/upnp.py +++ b/homeassistant/components/upnp.py @@ -51,7 +51,7 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) -# pylint: disable=import-error, no-member, broad-except +# pylint: disable=import-error, no-member, broad-except, c-extension-no-member def setup(hass, config): """Register a port mapping for Home Assistant via UPnP.""" config = config[DOMAIN] diff --git a/homeassistant/components/usps.py b/homeassistant/components/usps.py index 58f858b0975..364562f1119 100644 --- a/homeassistant/components/usps.py +++ b/homeassistant/components/usps.py @@ -15,7 +15,7 @@ from homeassistant.helpers import (config_validation as cv, discovery) from homeassistant.util import Throttle from homeassistant.util.dt import now -REQUIREMENTS = ['myusps==1.2.2'] +REQUIREMENTS = ['myusps==1.3.2'] _LOGGER = logging.getLogger(__name__) @@ -24,6 +24,7 @@ DATA_USPS = 'data_usps' MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) COOKIE = 'usps_cookies.pickle' CACHE = 'usps_cache' +CONF_DRIVER = 'driver' USPS_TYPE = ['sensor', 'camera'] @@ -32,6 +33,7 @@ CONFIG_SCHEMA = vol.Schema({ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_NAME, default=DOMAIN): cv.string, + vol.Optional(CONF_DRIVER): cv.string }), }, extra=vol.ALLOW_EXTRA) @@ -42,13 +44,15 @@ def setup(hass, config): username = conf.get(CONF_USERNAME) password = conf.get(CONF_PASSWORD) name = conf.get(CONF_NAME) + driver = conf.get(CONF_DRIVER) import myusps try: cookie = hass.config.path(COOKIE) cache = hass.config.path(CACHE) session = myusps.get_session(username, password, - cookie_path=cookie, cache_path=cache) + cookie_path=cookie, cache_path=cache, + driver=driver) except myusps.USPSError: _LOGGER.exception('Could not connect to My USPS') return False diff --git a/homeassistant/components/vacuum/roomba.py b/homeassistant/components/vacuum/roomba.py index 500b98420fc..6485f0025e2 100644 --- a/homeassistant/components/vacuum/roomba.py +++ b/homeassistant/components/vacuum/roomba.py @@ -242,7 +242,7 @@ class RoombaVacuum(VacuumDevice): self.vacuum.set_preference, 'vacHigh', str(high_perf)) @asyncio.coroutine - def async_send_command(self, command, params, **kwargs): + def async_send_command(self, command, params=None, **kwargs): """Send raw command.""" _LOGGER.debug("async_send_command %s (%s), %s", command, params, kwargs) diff --git a/homeassistant/components/vacuum/xiaomi_miio.py b/homeassistant/components/vacuum/xiaomi_miio.py index bcfb3b6738e..55f166c4004 100644 --- a/homeassistant/components/vacuum/xiaomi_miio.py +++ b/homeassistant/components/vacuum/xiaomi_miio.py @@ -19,7 +19,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_TOKEN, STATE_OFF, STATE_ON) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-miio==0.3.6'] +REQUIREMENTS = ['python-miio==0.3.7'] _LOGGER = logging.getLogger(__name__) @@ -341,9 +341,9 @@ class MiroboVacuum(VacuumDevice): @asyncio.coroutine def async_remote_control_move(self, - rotation: int=0, - velocity: float=0.3, - duration: int=1500): + rotation: int = 0, + velocity: float = 0.3, + duration: int = 1500): """Move vacuum with remote control mode.""" yield from self._try_command( "Unable to move with remote control the vacuum: %s", @@ -352,9 +352,9 @@ class MiroboVacuum(VacuumDevice): @asyncio.coroutine def async_remote_control_move_step(self, - rotation: int=0, - velocity: float=0.2, - duration: int=1500): + rotation: int = 0, + velocity: float = 0.2, + duration: int = 1500): """Move vacuum one step with remote control mode.""" yield from self._try_command( "Unable to remote control the vacuum: %s", diff --git a/homeassistant/components/velux.py b/homeassistant/components/velux.py index b0c902aa83e..ad541ee9cfe 100644 --- a/homeassistant/components/velux.py +++ b/homeassistant/components/velux.py @@ -52,16 +52,13 @@ class VeluxModule: def __init__(self, hass, config): """Initialize for velux component.""" from pyvlx import PyVLX - self.initialized = False host = config[DOMAIN].get(CONF_HOST) password = config[DOMAIN].get(CONF_PASSWORD) self.pyvlx = PyVLX( host=host, password=password) - self.hass = hass @asyncio.coroutine def async_start(self): """Start velux component.""" yield from self.pyvlx.load_scenes() - self.initialized = True diff --git a/homeassistant/components/vera.py b/homeassistant/components/vera.py index b15c4ddabfd..a7c10462e0d 100644 --- a/homeassistant/components/vera.py +++ b/homeassistant/components/vera.py @@ -19,7 +19,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, CONF_LIGHTS, CONF_EXCLUDE) from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pyvera==0.2.39'] +REQUIREMENTS = ['pyvera==0.2.41'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/volvooncall.py b/homeassistant/components/volvooncall.py index 3e36d0a3028..6557be2fb1b 100644 --- a/homeassistant/components/volvooncall.py +++ b/homeassistant/components/volvooncall.py @@ -4,10 +4,11 @@ Support for Volvo On Call. For more details about this component, please refer to the documentation at https://home-assistant.io/components/volvooncall/ """ - from datetime import timedelta import logging +import voluptuous as vol + from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, CONF_NAME, CONF_RESOURCES) from homeassistant.helpers import discovery @@ -16,7 +17,6 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import track_point_in_utc_time from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.util.dt import utcnow -import voluptuous as vol DOMAIN = 'volvooncall' @@ -143,8 +143,7 @@ class VolvoData: return vehicle.registration_number elif vehicle.vin: return vehicle.vin - else: - return '' + return '' class VolvoEntity(Entity): diff --git a/homeassistant/components/weather/buienradar.py b/homeassistant/components/weather/buienradar.py index f37914b3b0f..a49a1664eec 100644 --- a/homeassistant/components/weather/buienradar.py +++ b/homeassistant/components/weather/buienradar.py @@ -6,6 +6,9 @@ https://home-assistant.io/components/weather.buienradar/ """ import logging import asyncio + +import voluptuous as vol + from homeassistant.components.weather import ( WeatherEntity, PLATFORM_SCHEMA, ATTR_FORECAST_TEMP, ATTR_FORECAST_TIME) from homeassistant.const import \ @@ -14,9 +17,8 @@ from homeassistant.helpers import config_validation as cv # Reuse data and API logic from the sensor implementation from homeassistant.components.sensor.buienradar import ( BrData) -import voluptuous as vol -REQUIREMENTS = ['buienradar==0.9'] +REQUIREMENTS = ['buienradar==0.91'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/weather/yweather.py b/homeassistant/components/weather/yweather.py index bbf9f1ae590..f9610e469b2 100644 --- a/homeassistant/components/weather/yweather.py +++ b/homeassistant/components/weather/yweather.py @@ -50,7 +50,7 @@ CONDITION_CLASSES = { PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_WOEID, default=None): cv.string, + vol.Optional(CONF_WOEID): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) diff --git a/homeassistant/components/weblink.py b/homeassistant/components/weblink.py index a20b0fc9b0c..cd87bd838fa 100644 --- a/homeassistant/components/weblink.py +++ b/homeassistant/components/weblink.py @@ -22,9 +22,10 @@ CONF_RELATIVE_URL_REGEX = r'\A/' DOMAIN = 'weblink' ENTITIES_SCHEMA = vol.Schema({ + # pylint: disable=no-value-for-parameter vol.Required(CONF_URL): vol.Any( vol.Match(CONF_RELATIVE_URL_REGEX, msg=CONF_RELATIVE_URL_ERROR_MSG), - cv.url), + vol.Url()), vol.Required(CONF_NAME): cv.string, vol.Optional(CONF_ICON): cv.icon, }) diff --git a/homeassistant/components/websocket_api.py b/homeassistant/components/websocket_api.py index 030d1bee579..b79812a8dce 100644 --- a/homeassistant/components/websocket_api.py +++ b/homeassistant/components/websocket_api.py @@ -81,7 +81,7 @@ CALL_SERVICE_MESSAGE_SCHEMA = vol.Schema({ vol.Required('type'): TYPE_CALL_SERVICE, vol.Required('domain'): str, vol.Required('service'): str, - vol.Optional('service_data', default=None): dict + vol.Optional('service_data'): dict }) GET_STATES_MESSAGE_SCHEMA = vol.Schema({ @@ -451,7 +451,7 @@ class ActiveConnection: def call_service_helper(msg): """Call a service and fire complete message.""" yield from self.hass.services.async_call( - msg['domain'], msg['service'], msg['service_data'], True) + msg['domain'], msg['service'], msg.get('service_data'), True) self.send_message_outside(result_message(msg['id'])) self.hass.async_add_job(call_service_helper(msg)) diff --git a/homeassistant/components/xiaomi_aqara.py b/homeassistant/components/xiaomi_aqara.py index 0dcca28e228..e5942f97139 100644 --- a/homeassistant/components/xiaomi_aqara.py +++ b/homeassistant/components/xiaomi_aqara.py @@ -21,8 +21,9 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util.dt import utcnow +from homeassistant.util import slugify -REQUIREMENTS = ['PyXiaomiGateway==0.8.0'] +REQUIREMENTS = ['PyXiaomiGateway==0.8.1'] _LOGGER = logging.getLogger(__name__) @@ -68,7 +69,7 @@ SERVICE_SCHEMA_REMOVE_DEVICE = vol.Schema({ GATEWAY_CONFIG = vol.Schema({ vol.Optional(CONF_MAC, default=None): vol.Any(GW_MAC, None), - vol.Optional(CONF_KEY, default=None): + vol.Optional(CONF_KEY): vol.All(cv.string, vol.Length(min=16, max=16)), vol.Optional(CONF_HOST): cv.string, vol.Optional(CONF_PORT, default=9898): cv.port, @@ -90,11 +91,9 @@ def _fix_conf_defaults(config): return config -DEFAULT_GATEWAY_CONFIG = [{CONF_MAC: None, CONF_KEY: None}] - CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Optional(CONF_GATEWAYS, default=DEFAULT_GATEWAY_CONFIG): + vol.Optional(CONF_GATEWAYS, default={}): vol.All(cv.ensure_list, [GATEWAY_CONFIG], [_fix_conf_defaults]), vol.Optional(CONF_INTERFACE, default='any'): cv.string, vol.Optional(CONF_DISCOVERY_RETRY, default=3): cv.positive_int @@ -205,12 +204,13 @@ def setup(hass, config): class XiaomiDevice(Entity): """Representation a base Xiaomi device.""" - def __init__(self, device, name, xiaomi_hub): + def __init__(self, device, device_type, xiaomi_hub): """Initialize the Xiaomi device.""" self._state = None self._is_available = True self._sid = device['sid'] - self._name = '{}_{}'.format(name, self._sid) + self._name = '{}_{}'.format(device_type, self._sid) + self._type = device_type self._write_to_hub = xiaomi_hub.write_to_hub self._get_from_hub = xiaomi_hub.get_from_hub self._device_state_attributes = {} @@ -219,6 +219,14 @@ class XiaomiDevice(Entity): self.parse_data(device['data'], device['raw_data']) self.parse_voltage(device['data']) + if hasattr(self, '_data_key') \ + and self._data_key: # pylint: disable=no-member + self._unique_id = slugify("{}-{}".format( + self._data_key, # pylint: disable=no-member + self._sid)) + else: + self._unique_id = slugify("{}-{}".format(self._type, self._sid)) + def _add_push_data_job(self, *args): self.hass.add_job(self.push_data, *args) @@ -232,6 +240,11 @@ class XiaomiDevice(Entity): """Return the name of the device.""" return self._name + @property + def unique_id(self) -> str: + """Return an unique ID.""" + return self._unique_id + @property def available(self): """Return True if entity is available.""" diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 3729ce8a153..bb29cb28b0f 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -5,6 +5,7 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/zha/ """ import asyncio +import collections import enum import logging @@ -44,8 +45,7 @@ DEVICE_CONFIG_SCHEMA_ENTRY = vol.Schema({ CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Optional(CONF_RADIO_TYPE, default=RadioType.ezsp): - cv.enum(RadioType), + vol.Optional(CONF_RADIO_TYPE, default='ezsp'): cv.enum(RadioType), CONF_USB_PATH: cv.string, vol.Optional(CONF_BAUDRATE, default=57600): cv.positive_int, CONF_DATABASE: cv.string, @@ -55,13 +55,18 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) ATTR_DURATION = 'duration' +ATTR_IEEE = 'ieee_address' SERVICE_PERMIT = 'permit' +SERVICE_REMOVE = 'remove' SERVICE_SCHEMAS = { SERVICE_PERMIT: vol.Schema({ vol.Optional(ATTR_DURATION, default=60): vol.All(vol.Coerce(int), vol.Range(1, 254)), }), + SERVICE_REMOVE: vol.Schema({ + vol.Required(ATTR_IEEE): cv.string, + }), } @@ -116,6 +121,18 @@ def async_setup(hass, config): hass.services.async_register(DOMAIN, SERVICE_PERMIT, permit, schema=SERVICE_SCHEMAS[SERVICE_PERMIT]) + @asyncio.coroutine + def remove(service): + """Remove a node from the network.""" + from bellows.types import EmberEUI64, uint8_t + ieee = service.data.get(ATTR_IEEE) + ieee = EmberEUI64([uint8_t(p, base=16) for p in ieee.split(':')]) + _LOGGER.info("Removing node %s", ieee) + yield from APPLICATION_CONTROLLER.remove(ieee) + + hass.services.async_register(DOMAIN, SERVICE_REMOVE, remove, + schema=SERVICE_SCHEMAS[SERVICE_REMOVE]) + return True @@ -126,6 +143,7 @@ class ApplicationListener: """Initialize the listener.""" self._hass = hass self._config = config + self._device_registry = collections.defaultdict(list) hass.data[DISCOVERY_KEY] = hass.data.get(DISCOVERY_KEY, {}) def device_joined(self, device): @@ -147,7 +165,8 @@ class ApplicationListener: def device_removed(self, device): """Handle device being removed from the network.""" - pass + for device_entity in self._device_registry[device.ieee]: + self._hass.async_add_job(device_entity.async_remove()) @asyncio.coroutine def async_device_initialized(self, device, join): @@ -164,7 +183,7 @@ class ApplicationListener: component = None profile_clusters = ([], []) - device_key = '%s-%s' % (str(device.ieee), endpoint_id) + device_key = "{}-{}".format(device.ieee, endpoint_id) node_config = self._config[DOMAIN][CONF_DEVICE_CONFIG].get( device_key, {}) @@ -189,10 +208,12 @@ class ApplicationListener: for c in profile_clusters[1] if c in endpoint.out_clusters] discovery_info = { + 'application_listener': self, 'endpoint': endpoint, 'in_clusters': {c.cluster_id: c for c in in_clusters}, 'out_clusters': {c.cluster_id: c for c in out_clusters}, 'new_join': join, + 'unique_id': device_key, } discovery_info.update(discovered_info) self._hass.data[DISCOVERY_KEY][device_key] = discovery_info @@ -213,14 +234,17 @@ class ApplicationListener: continue component = zha_const.SINGLE_CLUSTER_DEVICE_CLASS[cluster_type] + cluster_key = "{}-{}".format(device_key, cluster_id) discovery_info = { + 'application_listener': self, 'endpoint': endpoint, 'in_clusters': {cluster.cluster_id: cluster}, 'out_clusters': {}, 'new_join': join, + 'unique_id': cluster_key, + 'entity_suffix': '_{}'.format(cluster_id), } discovery_info.update(discovered_info) - cluster_key = '%s-%s' % (device_key, cluster_id) self._hass.data[DISCOVERY_KEY][cluster_key] = discovery_info yield from discovery.async_load_platform( @@ -231,6 +255,10 @@ class ApplicationListener: self._config, ) + def register_entity(self, ieee, entity_obj): + """Record the creation of a hass entity associated with ieee.""" + self._device_registry[ieee].append(entity_obj) + class Entity(entity.Entity): """A base class for ZHA entities.""" @@ -238,30 +266,32 @@ class Entity(entity.Entity): _domain = None # Must be overridden by subclasses def __init__(self, endpoint, in_clusters, out_clusters, manufacturer, - model, **kwargs): + model, application_listener, unique_id, **kwargs): """Init ZHA entity.""" self._device_state_attributes = {} - ieeetail = ''.join([ - '%02x' % (o, ) for o in endpoint.device.ieee[-4:] - ]) + ieee = endpoint.device.ieee + ieeetail = ''.join(['%02x' % (o, ) for o in ieee[-4:]]) if manufacturer and model is not None: - self.entity_id = '%s.%s_%s_%s_%s' % ( + self.entity_id = "{}.{}_{}_{}_{}{}".format( self._domain, slugify(manufacturer), slugify(model), ieeetail, endpoint.endpoint_id, + kwargs.get('entity_suffix', ''), ) - self._device_state_attributes['friendly_name'] = '%s %s' % ( + self._device_state_attributes['friendly_name'] = "{} {}".format( manufacturer, model, ) else: - self.entity_id = "%s.zha_%s_%s" % ( + self.entity_id = "{}.zha_{}_{}{}".format( self._domain, ieeetail, endpoint.endpoint_id, + kwargs.get('entity_suffix', ''), ) + for cluster in in_clusters.values(): cluster.add_listener(self) for cluster in out_clusters.values(): @@ -270,6 +300,19 @@ class Entity(entity.Entity): self._in_clusters = in_clusters self._out_clusters = out_clusters self._state = ha_const.STATE_UNKNOWN + self._unique_id = unique_id + + application_listener.register_entity(ieee, self) + + @property + def unique_id(self) -> str: + """Return an unique ID.""" + return self._unique_id + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + return self._device_state_attributes def attribute_updated(self, attribute, value): """Handle an attribute updated on this cluster.""" @@ -279,11 +322,6 @@ class Entity(entity.Entity): """Handle a ZDO command received on this cluster.""" pass - @property - def device_state_attributes(self): - """Return device specific state attributes.""" - return self._device_state_attributes - @asyncio.coroutine def _discover_endpoint_info(endpoint): @@ -335,8 +373,7 @@ def get_discovery_info(hass, discovery_info): discovery_key = discovery_info.get('discovery_key', None) all_discovery_info = hass.data.get(DISCOVERY_KEY, {}) - discovery_info = all_discovery_info.get(discovery_key, None) - return discovery_info + return all_discovery_info.get(discovery_key, None) @asyncio.coroutine diff --git a/homeassistant/components/zha/const.py b/homeassistant/components/zha/const.py index a8d4671ebf7..deaa1257396 100644 --- a/homeassistant/components/zha/const.py +++ b/homeassistant/components/zha/const.py @@ -15,15 +15,11 @@ def populate_data(): from zigpy.profiles import PROFILES, zha, zll DEVICE_CLASS[zha.PROFILE_ID] = { - zha.DeviceType.ON_OFF_SWITCH: 'switch', zha.DeviceType.SMART_PLUG: 'switch', zha.DeviceType.ON_OFF_LIGHT: 'light', zha.DeviceType.DIMMABLE_LIGHT: 'light', zha.DeviceType.COLOR_DIMMABLE_LIGHT: 'light', - zha.DeviceType.ON_OFF_LIGHT_SWITCH: 'light', - zha.DeviceType.DIMMER_SWITCH: 'light', - zha.DeviceType.COLOR_DIMMER_SWITCH: 'light', } DEVICE_CLASS[zll.PROFILE_ID] = { zll.DeviceType.ON_OFF_LIGHT: 'light', @@ -37,6 +33,7 @@ def populate_data(): SINGLE_CLUSTER_DEVICE_CLASS.update({ zcl.clusters.general.OnOff: 'switch', + zcl.clusters.measurement.RelativeHumidity: 'sensor', zcl.clusters.measurement.TemperatureMeasurement: 'sensor', zcl.clusters.security.IasZone: 'binary_sensor', }) diff --git a/homeassistant/components/zha/services.yaml b/homeassistant/components/zha/services.yaml index a9ad0e7a1ca..4b1122b8167 100644 --- a/homeassistant/components/zha/services.yaml +++ b/homeassistant/components/zha/services.yaml @@ -6,3 +6,10 @@ permit: duration: description: Time to permit joins, in seconds example: 60 + +remove: + description: Remove a node from the ZigBee network. + fields: + ieee_address: + description: IEEE address of the node to remove + example: "00:0d:6f:00:05:7d:2d:34" diff --git a/homeassistant/config.py b/homeassistant/config.py index 5e82ef1baa0..6507e2a74f6 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -95,10 +95,6 @@ conversation: # Enables support for tracking state changes over time history: -# Tracked history is kept for 10 days -recorder: - purge_keep_days: 10 - # View all events in a logbook logbook: @@ -166,7 +162,7 @@ def get_default_config_dir() -> str: return os.path.join(data_dir, CONFIG_DIR_NAME) -def ensure_config_exists(config_dir: str, detect_location: bool=True) -> str: +def ensure_config_exists(config_dir: str, detect_location: bool = True) -> str: """Ensure a configuration file exists in given configuration directory. Creating a default one if needed. @@ -677,7 +673,7 @@ def async_check_ha_config_file(hass): @callback -def async_notify_setup_error(hass, component, link=False): +def async_notify_setup_error(hass, component, display_link=False): """Print a persistent notification. This method must be run in the event loop. @@ -689,7 +685,7 @@ def async_notify_setup_error(hass, component, link=False): if errors is None: errors = hass.data[DATA_PERSISTENT_ERRORS] = {} - errors[component] = errors.get(component) or link + errors[component] = errors.get(component) or display_link message = 'The following components and platforms could not be set up:\n\n' diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py new file mode 100644 index 00000000000..7b5d23d284f --- /dev/null +++ b/homeassistant/config_entries.py @@ -0,0 +1,516 @@ +"""The Config Manager is responsible for managing configuration for components. + +The Config Manager allows for creating config entries to be consumed by +components. Each entry is created via a Config Flow Handler, as defined by each +component. + +During startup, Home Assistant will setup the entries during the normal setup +of a component. It will first call the normal setup and then call the method +`async_setup_entry(hass, entry)` for each entry. The same method is called when +Home Assistant is running while a config entry is created. + +## Config Flows + +A component needs to define a Config Handler to allow the user to create config +entries for that component. A config flow will manage the creation of entries +from user input, discovery or other sources (like hassio). + +When a config flow is started for a domain, the handler will be instantiated +and receives a unique id. The instance of this handler will be reused for every +interaction of the user with this flow. This makes it possible to store +instance variables on the handler. + +Before instantiating the handler, Home Assistant will make sure to load all +dependencies and install the requirements of the component. + +At a minimum, each config flow will have to define a version number and the +'init' step. + + @config_entries.HANDLERS.register(DOMAIN) + class ExampleConfigFlow(config_entries.ConfigFlowHandler): + + VERSION = 1 + + async def async_step_init(self, user_input=None): + … + +The 'init' step is the first step of a flow and is called when a user +starts a new flow. Each step has three different possible results: "Show Form", +"Abort" and "Create Entry". + +### Show Form + +This will show a form to the user to fill in. You define the current step, +a title, a description and the schema of the data that needs to be returned. + + async def async_step_init(self, user_input=None): + # Use OrderedDict to guarantee order of the form shown to the user + data_schema = OrderedDict() + data_schema[vol.Required('username')] = str + data_schema[vol.Required('password')] = str + + return self.async_show_form( + step_id='init', + title='Account Info', + data_schema=vol.Schema(data_schema) + ) + +After the user has filled in the form, the step method will be called again and +the user input is passed in. If the validation of the user input fails , you +can return a dictionary with errors. Each key in the dictionary refers to a +field name that contains the error. Use the key 'base' if you want to show a +generic error. + + async def async_step_init(self, user_input=None): + errors = None + if user_input is not None: + # Validate user input + if valid: + return self.create_entry(…) + + errors['base'] = 'Unable to reach authentication server.' + + return self.async_show_form(…) + +If the user input passes validation, you can again return one of the three +return values. If you want to navigate the user to the next step, return the +return value of that step: + + return (await self.async_step_account()) + +### Abort + +When the result is "Abort", a message will be shown to the user and the +configuration flow is finished. + + return self.async_abort( + reason='This device is not supported by Home Assistant.' + ) + +### Create Entry + +When the result is "Create Entry", an entry will be created and stored in Home +Assistant, a success message is shown to the user and the flow is finished. + +## Initializing a config flow from an external source + +You might want to initialize a config flow programmatically. For example, if +we discover a device on the network that requires user interaction to finish +setup. To do so, pass a source parameter and optional user input to the init +step: + + await hass.config_entries.flow.async_init( + 'hue', source='discovery', data=discovery_info) + +The config flow handler will need to add a step to support the source. The step +should follow the same return values as a normal step. + + async def async_step_discovery(info): + +If the result of the step is to show a form, the user will be able to continue +the flow from the config panel. +""" +import asyncio +import logging +import os +import uuid + +from .core import callback +from .exceptions import HomeAssistantError +from .setup import async_setup_component, async_process_deps_reqs +from .util.json import load_json, save_json +from .util.decorator import Registry + + +_LOGGER = logging.getLogger(__name__) +HANDLERS = Registry() +# Components that have config flows. In future we will auto-generate this list. +FLOWS = [ + 'config_entry_example' +] + +SOURCE_USER = 'user' +SOURCE_DISCOVERY = 'discovery' + +PATH_CONFIG = '.config_entries.json' + +SAVE_DELAY = 1 + +RESULT_TYPE_FORM = 'form' +RESULT_TYPE_CREATE_ENTRY = 'create_entry' +RESULT_TYPE_ABORT = 'abort' + +ENTRY_STATE_LOADED = 'loaded' +ENTRY_STATE_SETUP_ERROR = 'setup_error' +ENTRY_STATE_NOT_LOADED = 'not_loaded' +ENTRY_STATE_FAILED_UNLOAD = 'failed_unload' + + +class ConfigEntry: + """Hold a configuration entry.""" + + __slots__ = ('entry_id', 'version', 'domain', 'title', 'data', 'source', + 'state') + + def __init__(self, version, domain, title, data, source, entry_id=None, + state=ENTRY_STATE_NOT_LOADED): + """Initialize a config entry.""" + # Unique id of the config entry + self.entry_id = entry_id or uuid.uuid4().hex + + # Version of the configuration. + self.version = version + + # Domain the configuration belongs to + self.domain = domain + + # Title of the configuration + self.title = title + + # Config data + self.data = data + + # Source of the configuration (user, discovery, cloud) + self.source = source + + # State of the entry (LOADED, NOT_LOADED) + self.state = state + + @asyncio.coroutine + def async_setup(self, hass, *, component=None): + """Set up an entry.""" + if component is None: + component = getattr(hass.components, self.domain) + + try: + result = yield from component.async_setup_entry(hass, self) + + if not isinstance(result, bool): + _LOGGER.error('%s.async_config_entry did not return boolean', + self.domain) + result = False + except Exception: # pylint: disable=broad-except + _LOGGER.exception('Error setting up entry %s for %s', + self.title, self.domain) + result = False + + if result: + self.state = ENTRY_STATE_LOADED + else: + self.state = ENTRY_STATE_SETUP_ERROR + + @asyncio.coroutine + def async_unload(self, hass): + """Unload an entry. + + Returns if unload is possible and was successful. + """ + component = getattr(hass.components, self.domain) + + supports_unload = hasattr(component, 'async_unload_entry') + + if not supports_unload: + return False + + try: + result = yield from component.async_unload_entry(hass, self) + + if not isinstance(result, bool): + _LOGGER.error('%s.async_unload_entry did not return boolean', + self.domain) + result = False + + return result + except Exception: # pylint: disable=broad-except + _LOGGER.exception('Error unloading entry %s for %s', + self.title, self.domain) + self.state = ENTRY_STATE_FAILED_UNLOAD + return False + + def as_dict(self): + """Return dictionary version of this entry.""" + return { + 'entry_id': self.entry_id, + 'version': self.version, + 'domain': self.domain, + 'title': self.title, + 'data': self.data, + 'source': self.source, + } + + +class ConfigError(HomeAssistantError): + """Error while configuring an account.""" + + +class UnknownEntry(ConfigError): + """Unknown entry specified.""" + + +class UnknownHandler(ConfigError): + """Unknown handler specified.""" + + +class UnknownFlow(ConfigError): + """Uknown flow specified.""" + + +class UnknownStep(ConfigError): + """Unknown step specified.""" + + +class ConfigEntries: + """Manage the configuration entries. + + An instance of this object is available via `hass.config_entries`. + """ + + def __init__(self, hass, hass_config): + """Initialize the entry manager.""" + self.hass = hass + self.flow = FlowManager(hass, hass_config, self._async_add_entry) + self._hass_config = hass_config + self._entries = None + self._sched_save = None + + @callback + def async_domains(self): + """Return domains for which we have entries.""" + seen = set() + result = [] + + for entry in self._entries: + if entry.domain not in seen: + seen.add(entry.domain) + result.append(entry.domain) + + return result + + @callback + def async_entries(self, domain=None): + """Return all entries or entries for a specific domain.""" + if domain is None: + return list(self._entries) + return [entry for entry in self._entries if entry.domain == domain] + + @asyncio.coroutine + def async_remove(self, entry_id): + """Remove an entry.""" + found = None + for index, entry in enumerate(self._entries): + if entry.entry_id == entry_id: + found = index + break + + if found is None: + raise UnknownEntry + + entry = self._entries.pop(found) + self._async_schedule_save() + + unloaded = yield from entry.async_unload(self.hass) + + return { + 'require_restart': not unloaded + } + + @asyncio.coroutine + def async_load(self): + """Load the config.""" + path = self.hass.config.path(PATH_CONFIG) + if not os.path.isfile(path): + self._entries = [] + return + + entries = yield from self.hass.async_add_job(load_json, path) + self._entries = [ConfigEntry(**entry) for entry in entries] + + @asyncio.coroutine + def _async_add_entry(self, entry): + """Add an entry.""" + self._entries.append(entry) + self._async_schedule_save() + + # Setup entry + if entry.domain in self.hass.config.components: + # Component already set up, just need to call setup_entry + yield from entry.async_setup(self.hass) + else: + # Setting up component will also load the entries + yield from async_setup_component( + self.hass, entry.domain, self._hass_config) + + @callback + def _async_schedule_save(self): + """Schedule saving the entity registry.""" + if self._sched_save is not None: + self._sched_save.cancel() + + self._sched_save = self.hass.loop.call_later( + SAVE_DELAY, self.hass.async_add_job, self._async_save + ) + + @asyncio.coroutine + def _async_save(self): + """Save the entity registry to a file.""" + self._sched_save = None + data = [entry.as_dict() for entry in self._entries] + + yield from self.hass.async_add_job( + save_json, self.hass.config.path(PATH_CONFIG), data) + + +class FlowManager: + """Manage all the config flows that are in progress.""" + + def __init__(self, hass, hass_config, async_add_entry): + """Initialize the flow manager.""" + self.hass = hass + self._hass_config = hass_config + self._progress = {} + self._async_add_entry = async_add_entry + + @callback + def async_progress(self): + """Return the flows in progress.""" + return [{ + 'flow_id': flow.flow_id, + 'domain': flow.domain, + 'source': flow.source, + } for flow in self._progress.values()] + + @asyncio.coroutine + def async_init(self, domain, *, source=SOURCE_USER, data=None): + """Start a configuration flow.""" + handler = HANDLERS.get(domain) + + if handler is None: + # This will load the component and thus register the handler + component = getattr(self.hass.components, domain) + handler = HANDLERS.get(domain) + + if handler is None: + raise self.hass.helpers.UnknownHandler + + # Make sure requirements and dependencies of component are resolved + yield from async_process_deps_reqs( + self.hass, self._hass_config, domain, component) + + flow_id = uuid.uuid4().hex + flow = self._progress[flow_id] = handler() + flow.hass = self.hass + flow.domain = domain + flow.flow_id = flow_id + flow.source = source + + if source == SOURCE_USER: + step = 'init' + else: + step = source + + return (yield from self._async_handle_step(flow, step, data)) + + @asyncio.coroutine + def async_configure(self, flow_id, user_input=None): + """Start or continue a configuration flow.""" + flow = self._progress.get(flow_id) + + if flow is None: + raise UnknownFlow + + step_id, data_schema = flow.cur_step + + if data_schema is not None and user_input is not None: + user_input = data_schema(user_input) + + return (yield from self._async_handle_step( + flow, step_id, user_input)) + + @callback + def async_abort(self, flow_id): + """Abort a flow.""" + if self._progress.pop(flow_id, None) is None: + raise UnknownFlow + + @asyncio.coroutine + def _async_handle_step(self, flow, step_id, user_input): + """Handle a step of a flow.""" + method = "async_step_{}".format(step_id) + + if not hasattr(flow, method): + self._progress.pop(flow.flow_id) + raise UnknownStep("Handler {} doesn't support step {}".format( + flow.__class__.__name__, step_id)) + + result = yield from getattr(flow, method)(user_input) + + if result['type'] not in (RESULT_TYPE_FORM, RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_ABORT): + raise ValueError( + 'Handler returned incorrect type: {}'.format(result['type'])) + + if result['type'] == RESULT_TYPE_FORM: + flow.cur_step = (result.pop('step_id'), result['data_schema']) + return result + + # Abort and Success results both finish the flow + self._progress.pop(flow.flow_id) + + if result['type'] == RESULT_TYPE_ABORT: + return result + + entry = ConfigEntry( + version=flow.VERSION, + domain=flow.domain, + title=result['title'], + data=result.pop('data'), + source=flow.source + ) + yield from self._async_add_entry(entry) + return result + + +class ConfigFlowHandler: + """Handle the configuration flow of a component.""" + + # Set by flow manager + flow_id = None + hass = None + source = SOURCE_USER + cur_step = None + + # Set by dev + # VERSION + + @callback + def async_show_form(self, *, title, step_id, description=None, + data_schema=None, errors=None): + """Return the definition of a form to gather user input.""" + return { + 'type': RESULT_TYPE_FORM, + 'flow_id': self.flow_id, + 'title': title, + 'step_id': step_id, + 'description': description, + 'data_schema': data_schema, + 'errors': errors, + } + + @callback + def async_create_entry(self, *, title, data): + """Finish config flow and create a config entry.""" + return { + 'type': RESULT_TYPE_CREATE_ENTRY, + 'flow_id': self.flow_id, + 'title': title, + 'data': data, + } + + @callback + def async_abort(self, *, reason): + """Abort the config flow.""" + return { + 'type': RESULT_TYPE_ABORT, + 'flow_id': self.flow_id, + 'reason': reason + } diff --git a/homeassistant/const.py b/homeassistant/const.py index eb90a19c778..10c29d19107 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,8 +1,8 @@ # coding: utf-8 """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 -MINOR_VERSION = 63 -PATCH_VERSION = '3' +MINOR_VERSION = 64 +PATCH_VERSION = '0.dev0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 4, 2) @@ -76,6 +76,7 @@ CONF_FILENAME = 'filename' CONF_FOR = 'for' CONF_FORCE_UPDATE = 'force_update' CONF_FRIENDLY_NAME = 'friendly_name' +CONF_FRIENDLY_NAME_TEMPLATE = 'friendly_name_template' CONF_HEADERS = 'headers' CONF_HOST = 'host' CONF_HOSTS = 'hosts' diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 87b84a80815..bad6bfe83c3 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -13,7 +13,7 @@ from homeassistant.const import ( CONF_ENTITY_ID, CONF_VALUE_TEMPLATE, CONF_CONDITION, WEEKDAYS, CONF_STATE, CONF_ZONE, CONF_BEFORE, CONF_AFTER, CONF_WEEKDAY, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET, - CONF_BELOW, CONF_ABOVE) + CONF_BELOW, CONF_ABOVE, STATE_UNAVAILABLE, STATE_UNKNOWN) from homeassistant.exceptions import TemplateError, HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.sun import get_astral_event_date @@ -47,7 +47,7 @@ def _threaded_factory(async_factory): return factory -def async_from_config(config: ConfigType, config_validation: bool=True): +def async_from_config(config: ConfigType, config_validation: bool = True): """Turn a condition configuration into a method. Should be run on the event loop. @@ -70,7 +70,7 @@ def async_from_config(config: ConfigType, config_validation: bool=True): from_config = _threaded_factory(async_from_config) -def async_and_from_config(config: ConfigType, config_validation: bool=True): +def async_and_from_config(config: ConfigType, config_validation: bool = True): """Create multi condition matcher using 'AND'.""" if config_validation: config = cv.AND_CONDITION_SCHEMA(config) @@ -101,7 +101,7 @@ def async_and_from_config(config: ConfigType, config_validation: bool=True): and_from_config = _threaded_factory(async_and_from_config) -def async_or_from_config(config: ConfigType, config_validation: bool=True): +def async_or_from_config(config: ConfigType, config_validation: bool = True): """Create multi condition matcher using 'OR'.""" if config_validation: config = cv.OR_CONDITION_SCHEMA(config) @@ -160,6 +160,9 @@ def async_numeric_state(hass: HomeAssistant, entity, below=None, above=None, _LOGGER.error("Template error: %s", ex) return False + if value in (STATE_UNAVAILABLE, STATE_UNKNOWN): + return False + try: value = float(value) except ValueError: diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 6b882d2fdad..04719e89187 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -22,8 +22,8 @@ SLOW_UPDATE_WARNING = 10 def generate_entity_id(entity_id_format: str, name: Optional[str], - current_ids: Optional[List[str]]=None, - hass: Optional[HomeAssistant]=None) -> str: + current_ids: Optional[List[str]] = None, + hass: Optional[HomeAssistant] = None) -> str: """Generate a unique entity ID based on given entity IDs or used IDs.""" if current_ids is None: if hass is None: @@ -42,8 +42,8 @@ def generate_entity_id(entity_id_format: str, name: Optional[str], @callback def async_generate_entity_id(entity_id_format: str, name: Optional[str], - current_ids: Optional[List[str]]=None, - hass: Optional[HomeAssistant]=None) -> str: + current_ids: Optional[List[str]] = None, + hass: Optional[HomeAssistant] = None) -> str: """Generate a unique entity ID based on given entity IDs or used IDs.""" if current_ids is None: if hass is None: diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 6cf58212c8e..e17e178bcfb 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -216,6 +216,14 @@ class EntityPlatform(object): entry = registry.async_get_or_create( self.domain, self.platform_name, entity.unique_id, suggested_object_id=suggested_object_id) + + if entry.disabled: + self.logger.info( + "Not adding entity %s because it's disabled", + entry.name or entity.name or + '"{} {}"'.format(self.platform_name, entity.unique_id)) + return + entity.entity_id = entry.entity_id entity.registry_name = entry.name diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index d33ca93f290..89719b0b823 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -26,6 +26,9 @@ PATH_REGISTRY = 'entity_registry.yaml' SAVE_DELAY = 10 _LOGGER = logging.getLogger(__name__) +DISABLED_HASS = 'hass' +DISABLED_USER = 'user' + @attr.s(slots=True, frozen=True) class RegistryEntry: @@ -35,12 +38,20 @@ class RegistryEntry: unique_id = attr.ib(type=str) platform = attr.ib(type=str) name = attr.ib(type=str, default=None) + disabled_by = attr.ib( + type=str, default=None, + validator=attr.validators.in_((DISABLED_HASS, DISABLED_USER, None))) domain = attr.ib(type=str, default=None, init=False, repr=False) def __attrs_post_init__(self): """Computed properties.""" object.__setattr__(self, "domain", split_entity_id(self.entity_id)[0]) + @property + def disabled(self): + """Return if entry is disabled.""" + return self.disabled_by is not None + class EntityRegistry: """Class to hold a registry of entities.""" @@ -116,7 +127,8 @@ class EntityRegistry: entity_id=entity_id, unique_id=info['unique_id'], platform=info['platform'], - name=info.get('name') + name=info.get('name'), + disabled_by=info.get('disabled_by') ) self.entities = entities diff --git a/homeassistant/helpers/entityfilter.py b/homeassistant/helpers/entityfilter.py index f78c70e57d3..c9554488aa7 100644 --- a/homeassistant/helpers/entityfilter.py +++ b/homeassistant/helpers/entityfilter.py @@ -74,8 +74,7 @@ def generate_filter(include_domains, include_entities, domain = split_entity_id(entity_id)[0] if domain in include_d: return entity_id not in exclude_e - else: - return entity_id in include_e + return entity_id in include_e return entity_filter_4a @@ -88,8 +87,7 @@ def generate_filter(include_domains, include_entities, domain = split_entity_id(entity_id)[0] if domain in exclude_d: return entity_id in include_e - else: - return entity_id not in exclude_e + return entity_id not in exclude_e return entity_filter_4b diff --git a/homeassistant/helpers/icon.py b/homeassistant/helpers/icon.py index e4c78fcbed2..e3fb983f691 100644 --- a/homeassistant/helpers/icon.py +++ b/homeassistant/helpers/icon.py @@ -2,8 +2,8 @@ from typing import Optional -def icon_for_battery_level(battery_level: Optional[int]=None, - charging: bool=False) -> str: +def icon_for_battery_level(battery_level: Optional[int] = None, + charging: bool = False) -> str: """Return a battery icon valid identifier.""" icon = 'mdi:battery' if battery_level is None: diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 0cf9d83863f..bf2773d32b8 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -1,20 +1,27 @@ """Module to coordinate user intentions.""" import asyncio import logging +import re import voluptuous as vol from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv from homeassistant.loader import bind_hass +from homeassistant.const import ATTR_ENTITY_ID - -DATA_KEY = 'intent' _LOGGER = logging.getLogger(__name__) +INTENT_TURN_OFF = 'HassTurnOff' +INTENT_TURN_ON = 'HassTurnOn' +INTENT_TOGGLE = 'HassToggle' + SLOT_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) +DATA_KEY = 'intent' + SPEECH_TYPE_PLAIN = 'plain' SPEECH_TYPE_SSML = 'ssml' @@ -87,7 +94,7 @@ class IntentHandler: intent_type = None slot_schema = None _slot_schema = None - platforms = None + platforms = [] @callback def async_can_handle(self, intent_obj): @@ -117,6 +124,67 @@ class IntentHandler: return '<{} - {}>'.format(self.__class__.__name__, self.intent_type) +def fuzzymatch(name, entities): + """Fuzzy matching function.""" + matches = [] + pattern = '.*?'.join(name) + regex = re.compile(pattern, re.IGNORECASE) + for entity_id, entity_name in entities.items(): + match = regex.search(entity_name) + if match: + matches.append((len(match.group()), match.start(), entity_id)) + return [x for _, _, x in sorted(matches)] + + +class ServiceIntentHandler(IntentHandler): + """Service Intent handler registration. + + Service specific intent handler that calls a service by name/entity_id. + """ + + slot_schema = { + 'name': cv.string, + } + + def __init__(self, intent_type, domain, service, speech): + """Create Service Intent Handler.""" + self.intent_type = intent_type + self.domain = domain + self.service = service + self.speech = speech + + @asyncio.coroutine + def async_handle(self, intent_obj): + """Handle the hass intent.""" + hass = intent_obj.hass + slots = self.async_validate_slots(intent_obj.slots) + response = intent_obj.create_response() + + name = slots['name']['value'] + entities = {state.entity_id: state.name for state + in hass.states.async_all()} + + matches = fuzzymatch(name, entities) + entity_id = matches[0] if matches else None + _LOGGER.debug("%s matched entity: %s", name, entity_id) + + response = intent_obj.create_response() + if not entity_id: + response.async_set_speech( + "Could not find entity id matching {}.".format(name)) + _LOGGER.error("Could not find entity id matching %s", name) + return response + + yield from hass.services.async_call( + self.domain, self.service, { + ATTR_ENTITY_ID: entity_id + }) + + response.async_set_speech( + self.speech.format(name)) + return response + + class Intent: """Hold the intent.""" diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 1ef9aa15674..7a989267572 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -33,7 +33,7 @@ CONF_WAIT_TEMPLATE = 'wait_template' def call_from_config(hass: HomeAssistant, config: ConfigType, - variables: Optional[Sequence]=None) -> None: + variables: Optional[Sequence] = None) -> None: """Call a script based on a config entry.""" Script(hass, cv.SCRIPT_SCHEMA(config)).run(variables) @@ -41,7 +41,7 @@ def call_from_config(hass: HomeAssistant, config: ConfigType, class Script(): """Representation of a script.""" - def __init__(self, hass: HomeAssistant, sequence, name: str=None, + def __init__(self, hass: HomeAssistant, sequence, name: str = None, change_listener=None) -> None: """Initialize the script.""" self.hass = hass @@ -69,7 +69,7 @@ class Script(): self.async_run(variables), self.hass.loop).result() @asyncio.coroutine - def async_run(self, variables: Optional[Sequence]=None) -> None: + def async_run(self, variables: Optional[Sequence] = None) -> None: """Run script. This method is a coroutine. diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index b381e1c2b0e..6fab1c6c844 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -271,8 +271,7 @@ class TemplateState(State): """Return an attribute of the state.""" if name in TemplateState.__dict__: return object.__getattribute__(self, name) - else: - return getattr(object.__getattribute__(self, '_state'), name) + return getattr(object.__getattribute__(self, '_state'), name) def __repr__(self): """Representation of Template State.""" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7d182aebfa3..2eb42b94389 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -3,7 +3,7 @@ pyyaml>=3.11,<4 pytz>=2017.02 pip>=8.0.3 jinja2>=2.10 -voluptuous==0.10.5 +voluptuous==0.11.1 typing>=3,<4 aiohttp==2.3.10 yarl==1.1.0 @@ -13,5 +13,5 @@ astral==1.5 certifi>=2017.4.17 attrs==17.4.0 -# Breaks Python 3.6 and is not needed for our supported Pythons +# Breaks Python 3.6 and is not needed for our supported Python versions enum34==1000000000.0.0 diff --git a/homeassistant/remote.py b/homeassistant/remote.py index 7d032303548..566f37a621a 100644 --- a/homeassistant/remote.py +++ b/homeassistant/remote.py @@ -45,8 +45,9 @@ class APIStatus(enum.Enum): class API(object): """Object to pass around Home Assistant API location and credentials.""" - def __init__(self, host: str, api_password: Optional[str]=None, - port: Optional[int]=SERVER_PORT, use_ssl: bool=False) -> None: + def __init__(self, host: str, api_password: Optional[str] = None, + port: Optional[int] = SERVER_PORT, + use_ssl: bool = False) -> None: """Init the API.""" self.host = host self.port = port @@ -68,7 +69,7 @@ class API(object): if api_password is not None: self._headers[HTTP_HEADER_HA_AUTH] = api_password - def validate_api(self, force_validate: bool=False) -> bool: + def validate_api(self, force_validate: bool = False) -> bool: """Test if we can communicate with the API.""" if self.status is None or force_validate: self.status = validate_api(self) diff --git a/homeassistant/scripts/benchmark/__init__.py b/homeassistant/scripts/benchmark/__init__.py index 470040b8295..834334b8a90 100644 --- a/homeassistant/scripts/benchmark/__init__.py +++ b/homeassistant/scripts/benchmark/__init__.py @@ -144,3 +144,53 @@ def async_million_state_changed_helper(hass): yield from event.wait() return timer() - start + + +@benchmark +@asyncio.coroutine +def logbook_filtering_state(hass): + """Filter state changes.""" + return _logbook_filtering(hass, 1, 1) + + +@benchmark +@asyncio.coroutine +def logbook_filtering_attributes(hass): + """Filter attribute changes.""" + return _logbook_filtering(hass, 1, 2) + + +@benchmark +@asyncio.coroutine +def _logbook_filtering(hass, last_changed, last_updated): + from homeassistant.components import logbook + + entity_id = 'test.entity' + + old_state = { + 'entity_id': entity_id, + 'state': 'off' + } + + new_state = { + 'entity_id': entity_id, + 'state': 'on', + 'last_updated': last_updated, + 'last_changed': last_changed + } + + event = core.Event(EVENT_STATE_CHANGED, { + 'entity_id': entity_id, + 'old_state': old_state, + 'new_state': new_state + }) + + events = [event] * 10**5 + + start = timer() + + # pylint: disable=protected-access + events = logbook._exclude_events(events, {}) + list(logbook.humanify(events)) + + return timer() - start diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 5cfcf628ec5..ec55b1d70c5 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -1,4 +1,5 @@ """Script to ensure a configuration file exists.""" +import asyncio import argparse import logging import os @@ -30,15 +31,14 @@ MOCKS = { config_util._log_pkg_error), 'logger_exception': ("homeassistant.setup._LOGGER.error", setup._LOGGER.error), + 'logger_exception_bootstrap': ("homeassistant.bootstrap._LOGGER.error", + bootstrap._LOGGER.error), } SILENCE = ( + 'homeassistant.bootstrap.async_enable_logging', 'homeassistant.bootstrap.clear_secret_cache', 'homeassistant.bootstrap.async_register_signal_handling', - 'homeassistant.core._LOGGER.info', - 'homeassistant.loader._LOGGER.info', - 'homeassistant.bootstrap._LOGGER.info', - 'homeassistant.bootstrap._LOGGER.warning', - 'homeassistant.util.yaml._LOGGER.debug', + 'homeassistant.config.process_ha_config_upgrade', ) PATCHES = {} @@ -46,6 +46,12 @@ C_HEAD = 'bold' ERROR_STR = 'General Errors' +@asyncio.coroutine +def mock_coro(*args): + """Coroutine that returns None.""" + return None + + def color(the_color, *args, reset=None): """Color helper.""" from colorlog.escape_codes import escape_codes, parse_colors @@ -153,6 +159,11 @@ def run(script_args: List) -> int: def check(config_path): """Perform a check by mocking hass load functions.""" + logging.getLogger('homeassistant.core').setLevel(logging.WARNING) + logging.getLogger('homeassistant.loader').setLevel(logging.WARNING) + logging.getLogger('homeassistant.setup').setLevel(logging.WARNING) + logging.getLogger('homeassistant.bootstrap').setLevel(logging.ERROR) + logging.getLogger('homeassistant.util.yaml').setLevel(logging.INFO) res = { 'yaml_files': OrderedDict(), # yaml_files loaded 'secrets': OrderedDict(), # secret cache and secrets loaded @@ -170,11 +181,12 @@ def check(config_path): # pylint: disable=unused-variable def mock_get(comp_name): """Mock hass.loader.get_component to replace setup & setup_platform.""" - def mock_setup(*kwargs): + @asyncio.coroutine + def mock_async_setup(*args): """Mock setup, only record the component name & config.""" assert comp_name not in res['components'], \ "Components should contain a list of platforms" - res['components'][comp_name] = kwargs[1].get(comp_name) + res['components'][comp_name] = args[1].get(comp_name) return True module = MOCKS['get'][1](comp_name) @@ -187,15 +199,15 @@ def check(config_path): # Test if platform/component and overwrite setup if '.' in comp_name: - module.setup_platform = mock_setup + module.async_setup_platform = mock_async_setup - if hasattr(module, 'async_setup_platform'): - del module.async_setup_platform + if hasattr(module, 'setup_platform'): + del module.setup_platform else: - module.setup = mock_setup + module.async_setup = mock_async_setup - if hasattr(module, 'async_setup'): - del module.async_setup + if hasattr(module, 'setup'): + del module.setup return module @@ -229,9 +241,14 @@ def check(config_path): res['except'].setdefault(ERROR_STR, []).append(msg % params) MOCKS['logger_exception'][1](msg, *params) + def mock_logger_exception_bootstrap(msg, *params): + """Log logger.exceptions.""" + res['except'].setdefault(ERROR_STR, []).append(msg % params) + MOCKS['logger_exception_bootstrap'][1](msg, *params) + # Patches to skip functions for sil in SILENCE: - PATCHES[sil] = patch(sil) + PATCHES[sil] = patch(sil, return_value=mock_coro()) # Patches with local mock functions for key, val in MOCKS.items(): diff --git a/homeassistant/scripts/credstash.py b/homeassistant/scripts/credstash.py index 12516e55c7d..84ba20619d8 100644 --- a/homeassistant/scripts/credstash.py +++ b/homeassistant/scripts/credstash.py @@ -24,7 +24,7 @@ def run(args): 'value', help="The value to save when putting a secret", nargs='?', default=None) - # pylint: disable=import-error + # pylint: disable=import-error, no-member import credstash import botocore diff --git a/homeassistant/scripts/db_migrator.py b/homeassistant/scripts/db_migrator.py index bf4dddc94fe..419f1138bf0 100644 --- a/homeassistant/scripts/db_migrator.py +++ b/homeassistant/scripts/db_migrator.py @@ -23,8 +23,9 @@ def ts_to_dt(timestamp: Optional[float]) -> Optional[datetime]: # Based on code at # http://stackoverflow.com/questions/3173320/text-progress-bar-in-the-console -def print_progress(iteration: int, total: int, prefix: str='', suffix: str='', - decimals: int=2, bar_length: int=68) -> None: +def print_progress(iteration: int, total: int, prefix: str = '', + suffix: str = '', decimals: int = 2, + bar_length: int = 68) -> None: """Print progress bar. Call in a loop to create terminal progress bar diff --git a/homeassistant/scripts/influxdb_import.py b/homeassistant/scripts/influxdb_import.py index e91aeb8a0d7..421e84d503a 100644 --- a/homeassistant/scripts/influxdb_import.py +++ b/homeassistant/scripts/influxdb_import.py @@ -257,8 +257,9 @@ def run(script_args: List) -> int: # Based on code at # http://stackoverflow.com/questions/3173320/text-progress-bar-in-the-console -def print_progress(iteration: int, total: int, prefix: str='', suffix: str='', - decimals: int=2, bar_length: int=68) -> None: +def print_progress(iteration: int, total: int, prefix: str = '', + suffix: str = '', decimals: int = 2, + bar_length: int = 68) -> None: """Print progress bar. Call in a loop to create terminal progress bar diff --git a/homeassistant/scripts/influxdb_migrator.py b/homeassistant/scripts/influxdb_migrator.py index f41240bad74..a4c0df74b09 100644 --- a/homeassistant/scripts/influxdb_migrator.py +++ b/homeassistant/scripts/influxdb_migrator.py @@ -8,8 +8,9 @@ from typing import List # Based on code at # http://stackoverflow.com/questions/3173320/text-progress-bar-in-the-console -def print_progress(iteration: int, total: int, prefix: str='', suffix: str='', - decimals: int=2, bar_length: int=68) -> None: +def print_progress(iteration: int, total: int, prefix: str = '', + suffix: str = '', decimals: int = 2, + bar_length: int = 68) -> None: """Print progress bar. Call in a loop to create terminal progress bar diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 5cff2cbc6f5..5a8681e82fd 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -24,7 +24,7 @@ SLOW_SETUP_WARNING = 10 def setup_component(hass: core.HomeAssistant, domain: str, - config: Optional[Dict]=None) -> bool: + config: Optional[Dict] = None) -> bool: """Set up a component and all its dependencies.""" return run_coroutine_threadsafe( async_setup_component(hass, domain, config), loop=hass.loop).result() @@ -32,7 +32,7 @@ def setup_component(hass: core.HomeAssistant, domain: str, @asyncio.coroutine def async_setup_component(hass: core.HomeAssistant, domain: str, - config: Optional[Dict]=None) -> bool: + config: Optional[Dict] = None) -> bool: """Set up a component and all its dependencies. This method is a coroutine. @@ -123,7 +123,7 @@ def _async_setup_component(hass: core.HomeAssistant, return False try: - yield from _process_deps_reqs(hass, config, domain, component) + yield from async_process_deps_reqs(hass, config, domain, component) except HomeAssistantError as err: log_error(str(err)) return False @@ -165,6 +165,9 @@ def _async_setup_component(hass: core.HomeAssistant, loader.set_component(domain, None) return False + for entry in hass.config_entries.async_entries(domain): + yield from entry.async_setup(hass, component=component) + hass.config.components.add(component.DOMAIN) # Cleanup @@ -206,7 +209,8 @@ def async_prepare_setup_platform(hass: core.HomeAssistant, config, domain: str, return platform try: - yield from _process_deps_reqs(hass, config, platform_path, platform) + yield from async_process_deps_reqs( + hass, config, platform_path, platform) except HomeAssistantError as err: log_error(str(err)) return None @@ -215,7 +219,7 @@ def async_prepare_setup_platform(hass: core.HomeAssistant, config, domain: str, @asyncio.coroutine -def _process_deps_reqs(hass, config, name, module): +def async_process_deps_reqs(hass, config, name, module): """Process all dependencies and requirements for a module. Module is a Python module of either a component or platform. diff --git a/homeassistant/util/__init__.py b/homeassistant/util/__init__.py index c4fea2846c5..75721a37466 100644 --- a/homeassistant/util/__init__.py +++ b/homeassistant/util/__init__.py @@ -61,7 +61,7 @@ def repr_helper(inp: Any) -> str: def convert(value: T, to_type: Callable[[T], U], - default: Optional[U]=None) -> Optional[U]: + default: Optional[U] = None) -> Optional[U]: """Convert value to to_type, returns default if fails.""" try: return default if value is None else to_type(value) @@ -164,6 +164,7 @@ class OrderedSet(MutableSet): """Check if key is in set.""" return key in self.map + # pylint: disable=arguments-differ def add(self, key): """Add an element to the end of the set.""" if key not in self.map: @@ -180,6 +181,7 @@ class OrderedSet(MutableSet): curr = begin[1] curr[2] = begin[1] = self.map[key] = [key, curr, begin] + # pylint: disable=arguments-differ def discard(self, key): """Discard an element from the set.""" if key in self.map: diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index 9c7fa0d70e7..089e1e733ed 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -392,8 +392,8 @@ def color_temperature_to_rgb(color_temperature_kelvin): return (red, green, blue) -def _bound(color_component: float, minimum: float=0, - maximum: float=255) -> float: +def _bound(color_component: float, minimum: float = 0, + maximum: float = 255) -> float: """ Bound the given color component value between the given min and max values. diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index c3400bac9be..7b5b996a3a3 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -51,7 +51,7 @@ def utcnow() -> dt.datetime: return dt.datetime.now(UTC) -def now(time_zone: dt.tzinfo=None) -> dt.datetime: +def now(time_zone: dt.tzinfo = None) -> dt.datetime: """Get now in specified time zone.""" return dt.datetime.now(time_zone or DEFAULT_TIME_ZONE) diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index 810463260fd..7a326c34f15 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -8,8 +8,11 @@ from homeassistant.exceptions import HomeAssistantError _LOGGER = logging.getLogger(__name__) +_UNDEFINED = object() -def load_json(filename: str) -> Union[List, Dict]: + +def load_json(filename: str, default: Union[List, Dict] = _UNDEFINED) \ + -> Union[List, Dict]: """Load JSON data from a file and return as dict or list. Defaults to returning empty dict if file is not found. @@ -26,7 +29,7 @@ def load_json(filename: str) -> Union[List, Dict]: except OSError as error: _LOGGER.exception('JSON file reading failed: %s', filename) raise HomeAssistantError(error) - return {} # (also evaluates to False) + return {} if default is _UNDEFINED else default def save_json(filename: str, config: Union[List, Dict]): diff --git a/homeassistant/util/location.py b/homeassistant/util/location.py index 35b266cb104..0cd0b14d3ab 100644 --- a/homeassistant/util/location.py +++ b/homeassistant/util/location.py @@ -84,7 +84,7 @@ def elevation(latitude, longitude): # License: https://github.com/maurycyp/vincenty/blob/master/LICENSE # pylint: disable=invalid-name, unused-variable, invalid-sequence-index def vincenty(point1: Tuple[float, float], point2: Tuple[float, float], - miles: bool=False) -> Optional[float]: + miles: bool = False) -> Optional[float]: """ Vincenty formula (inverse method) to calculate the distance. diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py index a82a50f4e02..e8149a85262 100644 --- a/homeassistant/util/package.py +++ b/homeassistant/util/package.py @@ -17,9 +17,9 @@ _LOGGER = logging.getLogger(__name__) INSTALL_LOCK = threading.Lock() -def install_package(package: str, upgrade: bool=True, - target: Optional[str]=None, - constraints: Optional[str]=None) -> bool: +def install_package(package: str, upgrade: bool = True, + target: Optional[str] = None, + constraints: Optional[str] = None) -> bool: """Install a package on PyPi. Accepts pip compatible package strings. Return boolean if install successful. diff --git a/homeassistant/util/yaml.py b/homeassistant/util/yaml.py index d0d5199e0f4..8ac8d096b99 100644 --- a/homeassistant/util/yaml.py +++ b/homeassistant/util/yaml.py @@ -13,7 +13,7 @@ except ImportError: keyring = None try: - import credstash # pylint: disable=import-error + import credstash # pylint: disable=import-error, no-member except ImportError: credstash = None @@ -276,6 +276,7 @@ def _secret_yaml(loader: SafeLineLoader, global credstash # pylint: disable=invalid-name if credstash: + # pylint: disable=no-member try: pwd = credstash.getSecret(node.value, table=_SECRET_NAMESPACE) if pwd: diff --git a/pylintrc b/pylintrc index 1ed8d2af336..85a44782af1 100644 --- a/pylintrc +++ b/pylintrc @@ -13,6 +13,7 @@ reports=no # too-many-* - are not enforced for the sake of readability # too-few-* - same as too-many-* # abstract-method - with intro of async there are always methods missing +# inconsistent-return-statements - doesn't handle raise generated-members=botocore.errorfactory @@ -23,6 +24,7 @@ disable= cyclic-import, duplicate-code, global-statement, + inconsistent-return-statements, locally-disabled, not-context-manager, redefined-variable-type, diff --git a/requirements_all.txt b/requirements_all.txt index 19e6f5cb6e6..7deac085466 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -4,7 +4,7 @@ pyyaml>=3.11,<4 pytz>=2017.02 pip>=8.0.3 jinja2>=2.10 -voluptuous==0.10.5 +voluptuous==0.11.1 typing>=3,<4 aiohttp==2.3.10 yarl==1.1.0 @@ -23,6 +23,9 @@ attrs==17.4.0 # homeassistant.components.doorbird DoorBirdPy==0.1.2 +# homeassistant.components.homekit +HAP-python==1.1.5 + # homeassistant.components.isy994 PyISY==1.1.0 @@ -36,7 +39,7 @@ PyMVGLive==1.1.4 PyMata==2.14 # homeassistant.components.xiaomi_aqara -PyXiaomiGateway==0.8.0 +PyXiaomiGateway==0.8.1 # homeassistant.components.rpi_gpio # RPi.GPIO==0.6.1 @@ -45,7 +48,7 @@ PyXiaomiGateway==0.8.0 RtmAPI==0.7.0 # homeassistant.components.media_player.sonos -SoCo==0.13 +SoCo==0.14 # homeassistant.components.sensor.travisci TravisPy==0.3.5 @@ -59,8 +62,11 @@ YesssSMS==0.1.1b3 # homeassistant.components.abode abodepy==0.12.2 +# homeassistant.components.media_player.frontier_silicon +afsapi==0.0.3 + # homeassistant.components.device_tracker.automatic -aioautomatic==0.6.4 +aioautomatic==0.6.5 # homeassistant.components.sensor.dnsip aiodns==1.1.1 @@ -85,7 +91,7 @@ aiopvapi==1.5.4 alarmdecoder==1.13.2 # homeassistant.components.sensor.alpha_vantage -alpha_vantage==1.8.0 +alpha_vantage==1.9.0 # homeassistant.components.amcrest amcrest==1.2.1 @@ -129,6 +135,9 @@ beautifulsoup4==4.6.0 # homeassistant.components.zha bellows==0.5.0 +# homeassistant.components.bmw_connected_drive +bimmer_connected==0.3.0 + # homeassistant.components.blink blinkpy==0.6.0 @@ -162,7 +171,7 @@ broadlink==0.5 # homeassistant.components.sensor.buienradar # homeassistant.components.weather.buienradar -buienradar==0.9 +buienradar==0.91 # homeassistant.components.calendar.caldav caldav==0.5.0 @@ -210,7 +219,7 @@ defusedxml==0.5.0 deluge-client==1.0.5 # homeassistant.components.media_player.denonavr -denonavr==0.5.5 +denonavr==0.6.0 # homeassistant.components.media_player.directv directpy==0.2 @@ -266,7 +275,7 @@ evohomeclient==0.2.5 fastdotcom==0.0.3 # homeassistant.components.sensor.fedex -fedexdeliverymanager==1.0.4 +fedexdeliverymanager==1.0.5 # homeassistant.components.feedreader # homeassistant.components.sensor.geo_rss_events @@ -292,12 +301,6 @@ freesms==0.1.2 # homeassistant.components.switch.fritzdect fritzhome==1.0.4 -# homeassistant.components.media_player.frontier_silicon -fsapi==0.0.7 - -# homeassistant.components.conversation -fuzzywuzzy==0.16.0 - # homeassistant.components.tts.google gTTS-token==1.1.1 @@ -353,7 +356,7 @@ hipnotify==1.0.8 holidays==0.9.3 # homeassistant.components.frontend -home-assistant-frontend==20180209.0 +home-assistant-frontend==20180221.1 # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a @@ -376,9 +379,6 @@ https://github.com/jabesq/netatmo-api-python/archive/v0.9.2.1.zip#lnetatmo==0.9. # homeassistant.components.neato https://github.com/jabesq/pybotvac/archive/v0.0.5.zip#pybotvac==0.0.5 -# homeassistant.components.sensor.sabnzbd -https://github.com/jamespcole/home-assistant-nzb-clients/archive/616cad59154092599278661af17e2a9f2cf5e2a9.zip#python-sabnzbd==0.1 - # homeassistant.components.switch.anel_pwrctrl https://github.com/mweinelt/anel-pwrctrl/archive/ed26e8830e28a2bfa4260a9002db23ce3e7e63d7.zip#anel_pwrctrl==0.0.1 @@ -400,7 +400,7 @@ https://github.com/wokar/pylgnetcast/archive/v0.2.0.zip#pylgnetcast==0.2.0 # i2csense==0.0.4 # homeassistant.components.light.iglo -iglo==1.1.3 +iglo==1.2.5 # homeassistant.components.ihc ihcsdk==2.1.1 @@ -454,7 +454,7 @@ liffylights==0.9.4 lightify==1.0.6.1 # homeassistant.components.light.limitlessled -limitlessled==1.0.8 +limitlessled==1.1.0 # homeassistant.components.linode linode-api==4.1.4b2 @@ -507,7 +507,7 @@ mychevy==0.1.1 mycroftapi==2.0 # homeassistant.components.usps -myusps==1.2.2 +myusps==1.3.2 # homeassistant.components.media_player.nad # homeassistant.components.media_player.nadtcp @@ -555,7 +555,7 @@ orvibo==1.1.1 paho-mqtt==1.3.1 # homeassistant.components.media_player.panasonic_viera -panasonic_viera==0.3 +panasonic_viera==0.3.1 # homeassistant.components.media_player.dunehd pdunehd==1.3 @@ -618,6 +618,9 @@ pushetta==1.0.15 # homeassistant.components.light.rpi_gpio_pwm pwmled==1.2.1 +# homeassistant.components.august +py-august==0.3.0 + # homeassistant.components.canary py-canary==0.4.0 @@ -625,7 +628,7 @@ py-canary==0.4.0 py-cpuinfo==3.3.0 # homeassistant.components.melissa -py-melissa-climate==1.0.1 +py-melissa-climate==1.0.6 # homeassistant.components.camera.synology py-synology==0.1.5 @@ -675,7 +678,7 @@ pybbox==0.0.5-alpha # pybluez==0.22 # homeassistant.components.media_player.cast -pychromecast==1.0.3 +pychromecast==2.0.0 # homeassistant.components.media_player.cmus pycmus==0.1.0 @@ -694,7 +697,7 @@ pycsspeechtts==1.0.2 pydaikin==0.4 # homeassistant.components.deconz -pydeconz==27 +pydeconz==30 # homeassistant.components.zwave pydispatcher==2.0.5 @@ -793,6 +796,9 @@ pymailgunner==1.4 # homeassistant.components.media_player.mediaroom pymediaroom==0.5 +# homeassistant.components.media_player.xiaomi_tv +pymitv==1.0.0 + # homeassistant.components.mochad pymochad==0.2.0 @@ -849,6 +855,9 @@ pyqwikswitch==0.4 # homeassistant.components.rainbird pyrainbird==0.1.3 +# homeassistant.components.sensor.sabnzbd +pysabnzbd==0.0.3 + # homeassistant.components.climate.sensibo pysensibo==1.0.2 @@ -865,7 +874,7 @@ pysesame==0.1.0 pysher==0.2.0 # homeassistant.components.sensor.sma -pysma==0.1.3 +pysma==0.2 # homeassistant.components.device_tracker.snmp # homeassistant.components.sensor.snmp @@ -892,7 +901,7 @@ python-digitalocean==1.13.2 python-ecobee-api==0.0.15 # homeassistant.components.climate.eq3btsmart -# python-eq3bt==0.1.8 +# python-eq3bt==0.1.9 # homeassistant.components.sensor.etherscan python-etherscan-api==0.0.3 @@ -922,7 +931,7 @@ python-juicenet==0.0.5 # homeassistant.components.remote.xiaomi_miio # homeassistant.components.switch.xiaomi_miio # homeassistant.components.vacuum.xiaomi_miio -python-miio==0.3.6 +python-miio==0.3.7 # homeassistant.components.media_player.mpd python-mpd2==0.5.5 @@ -1001,7 +1010,7 @@ pyunifi==2.13 # pyuserinput==0.1.11 # homeassistant.components.vera -pyvera==0.2.39 +pyvera==0.2.41 # homeassistant.components.media_player.vizio pyvizio==0.0.2 @@ -1109,6 +1118,9 @@ sleekxmpp==1.3.2 # homeassistant.components.sleepiq sleepyq==0.6 +# homeassistant.components.smappee +smappy==0.2.15 + # homeassistant.components.raspihats # homeassistant.components.sensor.bh1750 # homeassistant.components.sensor.bme280 @@ -1124,7 +1136,10 @@ snapcast==2.0.8 somecomfort==0.5.0 # homeassistant.components.sensor.speedtest -speedtest-cli==1.0.7 +speedtest-cli==2.0.0 + +# homeassistant.components.sensor.spotcrime +spotcrime==1.0.2 # homeassistant.components.recorder # homeassistant.scripts.db_migrator @@ -1141,7 +1156,7 @@ steamodd==4.21 suds-py3==1.3.3.0 # homeassistant.components.tahoma -tahoma-api==0.0.11 +tahoma-api==0.0.12 # homeassistant.components.sensor.tank_utility tank_utility==1.4.0 @@ -1201,6 +1216,9 @@ uvcclient==0.10.1 # homeassistant.components.climate.venstar venstarcolortouch==0.6 +# homeassistant.components.config.config_entries +voluptuous-serialize==1 + # homeassistant.components.volvooncall volvooncall==0.4.0 @@ -1242,9 +1260,10 @@ xbee-helper==0.0.7 xboxapi==0.1.1 # homeassistant.components.knx -xknx==0.7.18 +xknx==0.8.3 # homeassistant.components.media_player.bluesound +# homeassistant.components.sensor.startca # homeassistant.components.sensor.swiss_hydrological_data # homeassistant.components.sensor.ted5000 # homeassistant.components.sensor.yr @@ -1264,7 +1283,7 @@ yeelight==0.3.3 yeelightsunflower==0.0.8 # homeassistant.components.media_extractor -youtube_dl==2018.01.21 +youtube_dl==2018.02.11 # homeassistant.components.light.zengge zengge==0.2 diff --git a/requirements_docs.txt b/requirements_docs.txt index c5c48e0bc73..60946fd00a8 100644 --- a/requirements_docs.txt +++ b/requirements_docs.txt @@ -1,3 +1,3 @@ -Sphinx==1.6.7 -sphinx-autodoc-typehints==1.2.4 +Sphinx==1.7.0 +sphinx-autodoc-typehints==1.2.5 sphinx-autodoc-annotation==1.0.post1 diff --git a/requirements_test.txt b/requirements_test.txt index cddf11a34b8..d56a7085c74 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -2,7 +2,7 @@ # make new things fail. Manually update these pins when pulling in a # new version flake8==3.5 -pylint==1.6.5 +pylint==1.8.2 mypy==0.560 pydocstyle==1.1.1 coveralls==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1ae1b9f2e14..1e443e3ad00 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -3,7 +3,7 @@ # make new things fail. Manually update these pins when pulling in a # new version flake8==3.5 -pylint==1.6.5 +pylint==1.8.2 mypy==0.560 pydocstyle==1.1.1 coveralls==1.2.0 @@ -18,14 +18,17 @@ flake8-docstrings==1.0.3 asynctest>=0.11.1 +# homeassistant.components.homekit +HAP-python==1.1.5 + # homeassistant.components.notify.html5 PyJWT==1.5.3 # homeassistant.components.media_player.sonos -SoCo==0.13 +SoCo==0.14 # homeassistant.components.device_tracker.automatic -aioautomatic==0.6.4 +aioautomatic==0.6.5 # homeassistant.components.emulated_hue # homeassistant.components.http @@ -56,9 +59,6 @@ evohomeclient==0.2.5 # homeassistant.components.sensor.geo_rss_events feedparser==5.2.1 -# homeassistant.components.conversation -fuzzywuzzy==0.16.0 - # homeassistant.components.tts.google gTTS-token==1.1.1 @@ -75,7 +75,7 @@ hbmqtt==0.9.1 holidays==0.9.3 # homeassistant.components.frontend -home-assistant-frontend==20180209.0 +home-assistant-frontend==20180221.1 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb @@ -178,6 +178,9 @@ statsd==3.2.1 # homeassistant.components.camera.uvc uvcclient==0.10.1 +# homeassistant.components.config.config_entries +voluptuous-serialize==1 + # homeassistant.components.vultr vultr==0.1.2 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index dcd201667dd..460c998f556 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -46,8 +46,8 @@ TEST_REQUIREMENTS = ( 'ephem', 'evohomeclient', 'feedparser', - 'fuzzywuzzy', 'gTTS-token', + 'HAP-python', 'ha-ffmpeg', 'haversine', 'hbmqtt', @@ -83,6 +83,7 @@ TEST_REQUIREMENTS = ( 'sqlalchemy', 'statsd', 'uvcclient', + 'voluptuous-serialize', 'warrant', 'yahoo-finance', 'pythonwhois', @@ -92,6 +93,9 @@ TEST_REQUIREMENTS = ( IGNORE_PACKAGES = ( 'homeassistant.components.recorder.models', + 'homeassistant.components.homekit.accessories', + 'homeassistant.components.homekit.covers', + 'homeassistant.components.homekit.sensors' ) IGNORE_PIN = ('colorlog>2.1,<3', 'keyring>=9.3,<10.0', 'urllib3') @@ -107,7 +111,7 @@ URL_PIN = ('https://home-assistant.io/developers/code_review_platform/' CONSTRAINT_PATH = os.path.join(os.path.dirname(__file__), '../homeassistant/package_constraints.txt') CONSTRAINT_BASE = """ -# Breaks Python 3.6 and is not needed for our supported Pythons +# Breaks Python 3.6 and is not needed for our supported Python versions enum34==1000000000.0.0 """ diff --git a/script/lint b/script/lint index ab7561b9a5b..b16b92a45b4 100755 --- a/script/lint +++ b/script/lint @@ -4,11 +4,15 @@ cd "$(dirname "$0")/.." if [ "$1" = "--changed" ]; then - export files="`git diff upstream/dev --name-only | grep -e '\.py$'`" + export files="`git diff upstream/dev... --name-only | grep -e '\.py$'`" echo "=================================================" - echo "FILES CHANGED (git diff upstream/dev --name-only)" + echo "FILES CHANGED (git diff upstream/dev... --name-only)" echo "=================================================" - echo $files + if $files >/dev/null; then + echo "No python file changed" + exit + fi + printf "%s\n" $files echo "================" echo "LINT with flake8" echo "================" diff --git a/setup.py b/setup.py index 0a454f9eb4d..bca49d33647 100755 --- a/setup.py +++ b/setup.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 """Home Assistant setup script.""" -import os -from setuptools import setup, find_packages import sys +from setuptools import setup, find_packages + import homeassistant.const as hass_const @@ -41,8 +41,6 @@ GITHUB_PATH = '{}/{}'.format( PROJECT_GITHUB_USERNAME, PROJECT_GITHUB_REPOSITORY) GITHUB_URL = 'https://github.com/{}'.format(GITHUB_PATH) - -HERE = os.path.abspath(os.path.dirname(__file__)) DOWNLOAD_URL = '{}/archive/{}.zip'.format(GITHUB_URL, hass_const.__version__) PACKAGES = find_packages(exclude=['tests', 'tests.*']) @@ -53,7 +51,7 @@ REQUIRES = [ 'pytz>=2017.02', 'pip>=8.0.3', 'jinja2>=2.10', - 'voluptuous==0.10.5', + 'voluptuous==0.11.1', 'typing>=3,<4', 'aiohttp==2.3.10', # If updated, check if yarl also needs an update! 'yarl==1.1.0', diff --git a/tests/common.py b/tests/common.py index 511d59dbdfe..6fee7b1bec0 100644 --- a/tests/common.py +++ b/tests/common.py @@ -9,13 +9,11 @@ import logging import threading from contextlib import contextmanager -from aiohttp import web - -from homeassistant import core as ha, loader +from homeassistant import core as ha, loader, config_entries from homeassistant.setup import setup_component, async_setup_component from homeassistant.config import async_process_component_config from homeassistant.helpers import ( - intent, dispatcher, entity, restore_state, entity_registry, + intent, entity, restore_state, entity_registry, entity_platform) from homeassistant.util.unit_system import METRIC_SYSTEM import homeassistant.util.dt as date_util @@ -25,9 +23,6 @@ from homeassistant.const import ( EVENT_STATE_CHANGED, EVENT_PLATFORM_DISCOVERED, ATTR_SERVICE, ATTR_DISCOVERED, SERVER_PORT, EVENT_HOMEASSISTANT_CLOSE) from homeassistant.components import mqtt, recorder -from homeassistant.components.http.auth import auth_middleware -from homeassistant.components.http.const import ( - KEY_USE_X_FORWARDED_FOR, KEY_BANS_ENABLED, KEY_TRUSTED_NETWORKS) from homeassistant.util.async import ( run_callback_threadsafe, run_coroutine_threadsafe) @@ -114,6 +109,9 @@ def get_test_home_assistant(): def async_test_home_assistant(loop): """Return a Home Assistant object pointing at test config dir.""" hass = ha.HomeAssistant(loop) + hass.config_entries = config_entries.ConfigEntries(hass, {}) + hass.config_entries._entries = [] + hass.config.async_load = Mock() INSTANCES.append(hass) orig_async_add_job = hass.async_add_job @@ -214,13 +212,12 @@ def async_mock_intent(hass, intent_typ): @ha.callback -def async_fire_mqtt_message(hass, topic, payload, qos=0): +def async_fire_mqtt_message(hass, topic, payload, qos=0, retain=False): """Fire the MQTT message.""" if isinstance(payload, str): payload = payload.encode('utf-8') - dispatcher.async_dispatcher_send( - hass, mqtt.SIGNAL_MQTT_MESSAGE_RECEIVED, topic, - payload, qos) + msg = mqtt.Message(topic, payload, qos, retain) + hass.async_run_job(hass.data['mqtt']._mqtt_on_message, None, None, msg) fire_mqtt_message = threadsafe_callback_factory(async_fire_mqtt_message) @@ -246,7 +243,7 @@ def fire_service_discovered(hass, service, info): def load_fixture(filename): """Load a fixture.""" path = os.path.join(os.path.dirname(__file__), 'fixtures', filename) - with open(path) as fptr: + with open(path, encoding='utf-8') as fptr: return fptr.read() @@ -263,46 +260,26 @@ def mock_state_change_event(hass, new_state, old_state=None): hass.bus.fire(EVENT_STATE_CHANGED, event_data) -def mock_http_component(hass, api_password=None): - """Mock the HTTP component.""" - hass.http = MagicMock(api_password=api_password) - mock_component(hass, 'http') - hass.http.views = {} - - def mock_register_view(view): - """Store registered view.""" - if isinstance(view, type): - # Instantiate the view, if needed - view = view() - - hass.http.views[view.name] = view - - hass.http.register_view = mock_register_view - - -def mock_http_component_app(hass, api_password=None): - """Create an aiohttp.web.Application instance for testing.""" - if 'http' not in hass.config.components: - mock_http_component(hass, api_password) - app = web.Application(middlewares=[auth_middleware]) - app['hass'] = hass - app[KEY_USE_X_FORWARDED_FOR] = False - app[KEY_BANS_ENABLED] = False - app[KEY_TRUSTED_NETWORKS] = [] - return app - - @asyncio.coroutine -def async_mock_mqtt_component(hass): +def async_mock_mqtt_component(hass, config=None): """Mock the MQTT component.""" - with patch('homeassistant.components.mqtt.MQTT') as mock_mqtt: - mock_mqtt().async_connect.return_value = mock_coro(True) - yield from async_setup_component(hass, mqtt.DOMAIN, { - mqtt.DOMAIN: { - mqtt.CONF_BROKER: 'mock-broker', - } + if config is None: + config = {mqtt.CONF_BROKER: 'mock-broker'} + + with patch('paho.mqtt.client.Client') as mock_client: + mock_client().connect.return_value = 0 + mock_client().subscribe.return_value = (0, 0) + mock_client().publish.return_value = (0, 0) + + result = yield from async_setup_component(hass, mqtt.DOMAIN, { + mqtt.DOMAIN: config }) - return mock_mqtt + assert result + + hass.data['mqtt'] = MagicMock(spec_set=hass.data['mqtt'], + wraps=hass.data['mqtt']) + + return hass.data['mqtt'] mock_mqtt_component = threadsafe_coroutine_factory(async_mock_mqtt_component) @@ -331,12 +308,12 @@ class MockModule(object): # pylint: disable=invalid-name def __init__(self, domain=None, dependencies=None, setup=None, requirements=None, config_schema=None, platform_schema=None, - async_setup=None): + async_setup=None, async_setup_entry=None, + async_unload_entry=None): """Initialize the mock module.""" self.DOMAIN = domain self.DEPENDENCIES = dependencies or [] self.REQUIREMENTS = requirements or [] - self._setup = setup if config_schema is not None: self.CONFIG_SCHEMA = config_schema @@ -344,18 +321,21 @@ class MockModule(object): if platform_schema is not None: self.PLATFORM_SCHEMA = platform_schema + if setup is not None: + # We run this in executor, wrap it in function + self.setup = lambda *args: setup(*args) + if async_setup is not None: self.async_setup = async_setup - def setup(self, hass, config): - """Set up the component. + if setup is None and async_setup is None: + self.async_setup = mock_coro_func(True) - We always define this mock because MagicMock setups will be seen by the - executor as a coroutine, raising an exception. - """ - if self._setup is not None: - return self._setup(hass, config) - return True + if async_setup_entry is not None: + self.async_setup_entry = async_setup_entry + + if async_unload_entry is not None: + self.async_unload_entry = async_unload_entry class MockPlatform(object): @@ -366,18 +346,19 @@ class MockPlatform(object): platform_schema=None, async_setup_platform=None): """Initialize the platform.""" self.DEPENDENCIES = dependencies or [] - self._setup_platform = setup_platform if platform_schema is not None: self.PLATFORM_SCHEMA = platform_schema + if setup_platform is not None: + # We run this in executor, wrap it in function + self.setup_platform = lambda *args: setup_platform(*args) + if async_setup_platform is not None: self.async_setup_platform = async_setup_platform - def setup_platform(self, hass, config, add_devices, discovery_info=None): - """Set up the platform.""" - if self._setup_platform is not None: - self._setup_platform(hass, config, add_devices, discovery_info) + if setup_platform is None and async_setup_platform is None: + self.async_setup_platform = mock_coro_func() class MockToggleDevice(entity.ToggleEntity): @@ -431,6 +412,35 @@ class MockToggleDevice(entity.ToggleEntity): return None +class MockConfigEntry(config_entries.ConfigEntry): + """Helper for creating config entries that adds some defaults.""" + + def __init__(self, *, domain='test', data=None, version=0, entry_id=None, + source=config_entries.SOURCE_USER, title='Mock Title', + state=None): + """Initialize a mock config entry.""" + kwargs = { + 'entry_id': entry_id or 'mock-id', + 'domain': domain, + 'data': data or {}, + 'version': version, + 'title': title + } + if source is not None: + kwargs['source'] = source + if state is not None: + kwargs['state'] = state + super().__init__(**kwargs) + + def add_to_hass(self, hass): + """Test helper to add entry to hass.""" + hass.config_entries._entries.append(self) + + def add_to_manager(self, manager): + """Test helper to add entry to entry manager.""" + manager._entries.append(self) + + def patch_yaml_files(files_dict, endswith=True): """Patch load_yaml with a dictionary of yaml files.""" # match using endswith, start search with longest string diff --git a/tests/components/alarm_control_panel/test_manual_mqtt.py b/tests/components/alarm_control_panel/test_manual_mqtt.py index 83254d9104f..719352c5419 100644 --- a/tests/components/alarm_control_panel/test_manual_mqtt.py +++ b/tests/components/alarm_control_panel/test_manual_mqtt.py @@ -1395,53 +1395,60 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): # Component should send disarmed alarm state on startup self.hass.block_till_done() - self.assertEqual(('alarm/state', STATE_ALARM_DISARMED, 0, True), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'alarm/state', STATE_ALARM_DISARMED, 0, True) + self.mock_publish.async_publish.reset_mock() # Arm in home mode alarm_control_panel.alarm_arm_home(self.hass) self.hass.block_till_done() - self.assertEqual(('alarm/state', STATE_ALARM_PENDING, 0, True), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'alarm/state', STATE_ALARM_PENDING, 0, True) + self.mock_publish.async_publish.reset_mock() # Fast-forward a little bit future = dt_util.utcnow() + timedelta(seconds=1) with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' 'dt_util.utcnow'), return_value=future): fire_time_changed(self.hass, future) self.hass.block_till_done() - self.assertEqual(('alarm/state', STATE_ALARM_ARMED_HOME, 0, True), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'alarm/state', STATE_ALARM_ARMED_HOME, 0, True) + self.mock_publish.async_publish.reset_mock() # Arm in away mode alarm_control_panel.alarm_arm_away(self.hass) self.hass.block_till_done() - self.assertEqual(('alarm/state', STATE_ALARM_PENDING, 0, True), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'alarm/state', STATE_ALARM_PENDING, 0, True) + self.mock_publish.async_publish.reset_mock() # Fast-forward a little bit future = dt_util.utcnow() + timedelta(seconds=1) with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' 'dt_util.utcnow'), return_value=future): fire_time_changed(self.hass, future) self.hass.block_till_done() - self.assertEqual(('alarm/state', STATE_ALARM_ARMED_AWAY, 0, True), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'alarm/state', STATE_ALARM_ARMED_AWAY, 0, True) + self.mock_publish.async_publish.reset_mock() # Arm in night mode alarm_control_panel.alarm_arm_night(self.hass) self.hass.block_till_done() - self.assertEqual(('alarm/state', STATE_ALARM_PENDING, 0, True), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'alarm/state', STATE_ALARM_PENDING, 0, True) + self.mock_publish.async_publish.reset_mock() # Fast-forward a little bit future = dt_util.utcnow() + timedelta(seconds=1) with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' 'dt_util.utcnow'), return_value=future): fire_time_changed(self.hass, future) self.hass.block_till_done() - self.assertEqual(('alarm/state', STATE_ALARM_ARMED_NIGHT, 0, True), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'alarm/state', STATE_ALARM_ARMED_NIGHT, 0, True) + self.mock_publish.async_publish.reset_mock() # Disarm alarm_control_panel.alarm_disarm(self.hass) self.hass.block_till_done() - self.assertEqual(('alarm/state', STATE_ALARM_DISARMED, 0, True), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'alarm/state', STATE_ALARM_DISARMED, 0, True) diff --git a/tests/components/alarm_control_panel/test_mqtt.py b/tests/components/alarm_control_panel/test_mqtt.py index 5a93a55254d..dee9b3959ca 100644 --- a/tests/components/alarm_control_panel/test_mqtt.py +++ b/tests/components/alarm_control_panel/test_mqtt.py @@ -106,8 +106,8 @@ class TestAlarmControlPanelMQTT(unittest.TestCase): alarm_control_panel.alarm_arm_home(self.hass) self.hass.block_till_done() - self.assertEqual(('alarm/command', 'ARM_HOME', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'alarm/command', 'ARM_HOME', 0, False) def test_arm_home_not_publishes_mqtt_with_invalid_code(self): """Test not publishing of MQTT messages with invalid code.""" @@ -139,8 +139,8 @@ class TestAlarmControlPanelMQTT(unittest.TestCase): alarm_control_panel.alarm_arm_away(self.hass) self.hass.block_till_done() - self.assertEqual(('alarm/command', 'ARM_AWAY', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'alarm/command', 'ARM_AWAY', 0, False) def test_arm_away_not_publishes_mqtt_with_invalid_code(self): """Test not publishing of MQTT messages with invalid code.""" @@ -172,8 +172,8 @@ class TestAlarmControlPanelMQTT(unittest.TestCase): alarm_control_panel.alarm_disarm(self.hass) self.hass.block_till_done() - self.assertEqual(('alarm/command', 'DISARM', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'alarm/command', 'DISARM', 0, False) def test_disarm_not_publishes_mqtt_with_invalid_code(self): """Test not publishing of MQTT messages with invalid code.""" diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 9654c667c5f..8de4d0d9aff 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -515,17 +515,15 @@ def test_media_player(hass): call, _ = yield from assert_request_calls_service( 'Alexa.StepSpeaker', 'AdjustVolume', 'media_player#test', - 'media_player.volume_set', + 'media_player.volume_up', hass, payload={'volumeSteps': 20}) - assert call.data['volume_level'] == 0.95 call, _ = yield from assert_request_calls_service( 'Alexa.StepSpeaker', 'AdjustVolume', 'media_player#test', - 'media_player.volume_set', + 'media_player.volume_down', hass, payload={'volumeSteps': -20}) - assert call.data['volume_level'] == 0.55 @asyncio.coroutine @@ -571,15 +569,11 @@ def test_group(hass): appliance = yield from discovery_test(device, hass) assert appliance['endpointId'] == 'group#test' - assert appliance['displayCategories'][0] == "SCENE_TRIGGER" + assert appliance['displayCategories'][0] == "OTHER" assert appliance['friendlyName'] == "Test group" + assert_endpoint_capabilities(appliance, 'Alexa.PowerController') - (capability,) = assert_endpoint_capabilities( - appliance, - 'Alexa.SceneController') - assert capability['supportsDeactivation'] - - yield from assert_scene_controller_works( + yield from assert_power_controller_works( 'group#test', 'homeassistant.turn_on', 'homeassistant.turn_off', diff --git a/tests/components/binary_sensor/test_command_line.py b/tests/components/binary_sensor/test_command_line.py index f35e6f08452..d01b62e4c12 100644 --- a/tests/components/binary_sensor/test_command_line.py +++ b/tests/components/binary_sensor/test_command_line.py @@ -3,7 +3,6 @@ import unittest from homeassistant.const import (STATE_ON, STATE_OFF) from homeassistant.components.binary_sensor import command_line -from homeassistant import setup from homeassistant.helpers import template from tests.common import get_test_home_assistant @@ -42,16 +41,6 @@ class TestCommandSensorBinarySensor(unittest.TestCase): self.assertEqual('Test', entity.name) self.assertEqual(STATE_ON, entity.state) - def test_setup_bad_config(self): - """Test the setup with a bad configuration.""" - config = {'name': 'test', - 'platform': 'not_command_line', - } - - self.assertFalse(setup.setup_component(self.hass, 'test', { - 'command_line': config, - })) - def test_template(self): """Test setting the state with a template.""" data = command_line.CommandSensorData(self.hass, 'echo 10') diff --git a/tests/components/calendar/test_caldav.py b/tests/components/calendar/test_caldav.py index 7234d40c410..e44e5cfc1f0 100644 --- a/tests/components/calendar/test_caldav.py +++ b/tests/components/calendar/test_caldav.py @@ -64,7 +64,49 @@ LOCATION:Hamburg DESCRIPTION:What a beautiful day END:VEVENT END:VCALENDAR +""", + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Global Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:4 +DTSTAMP:20171125T000000Z +DTSTART:20171127 +SUMMARY:This is an event without dtend or duration +LOCATION:Hamburg +DESCRIPTION:What an endless day +END:VEVENT +END:VCALENDAR +""", + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Global Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:5 +DTSTAMP:20171125T000000Z +DTSTART:20171127 +DURATION:PT1H +SUMMARY:This is an event with duration +LOCATION:Hamburg +DESCRIPTION:What a day +END:VEVENT +END:VCALENDAR +""", + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Global Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:6 +DTSTAMP:20171125T000000Z +DTSTART:20171127T100000Z +DURATION:PT1H +SUMMARY:This is an event with duration +LOCATION:Hamburg +DESCRIPTION:What a day +END:VEVENT +END:VCALENDAR """ + ] diff --git a/tests/components/camera/test_uvc.py b/tests/components/camera/test_uvc.py index ad7ee5f5bcb..40b4fb2d8e2 100644 --- a/tests/components/camera/test_uvc.py +++ b/tests/components/camera/test_uvc.py @@ -9,7 +9,7 @@ from uvcclient import nvr from homeassistant.setup import setup_component from homeassistant.components.camera import uvc -from tests.common import get_test_home_assistant, mock_http_component +from tests.common import get_test_home_assistant class TestUVCSetup(unittest.TestCase): @@ -18,7 +18,6 @@ class TestUVCSetup(unittest.TestCase): def setUp(self): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - mock_http_component(self.hass) def tearDown(self): """Stop everything that was started.""" diff --git a/tests/components/climate/test_melissa.py b/tests/components/climate/test_melissa.py index f8a044c2f4b..5022c556b7d 100644 --- a/tests/components/climate/test_melissa.py +++ b/tests/components/climate/test_melissa.py @@ -122,7 +122,7 @@ class TestMelissa(unittest.TestCase): def test_operation_list(self): """Test the operation list.""" self.assertEqual( - [STATE_AUTO, STATE_COOL, STATE_DRY, STATE_FAN_ONLY, STATE_HEAT], + [STATE_COOL, STATE_DRY, STATE_FAN_ONLY, STATE_HEAT], self.thermostat.operation_list ) @@ -202,7 +202,7 @@ class TestMelissa(unittest.TestCase): self.thermostat._cur_settings = None self.assertFalse(self.thermostat.send({ 'fan': self.api.FAN_LOW})) - self.assertNotEquals(SPEED_LOW, self.thermostat.current_fan_mode) + self.assertNotEqual(SPEED_LOW, self.thermostat.current_fan_mode) self.assertIsNone(self.thermostat._cur_settings) @mock.patch('homeassistant.components.climate.melissa._LOGGER.warning') @@ -226,7 +226,6 @@ class TestMelissa(unittest.TestCase): def test_melissa_op_to_hass(self): """Test for translate melissa operations to hass.""" - self.assertEqual(STATE_AUTO, self.thermostat.melissa_op_to_hass(0)) self.assertEqual(STATE_FAN_ONLY, self.thermostat.melissa_op_to_hass(1)) self.assertEqual(STATE_HEAT, self.thermostat.melissa_op_to_hass(2)) self.assertEqual(STATE_COOL, self.thermostat.melissa_op_to_hass(3)) @@ -245,7 +244,6 @@ class TestMelissa(unittest.TestCase): @mock.patch('homeassistant.components.climate.melissa._LOGGER.warning') def test_hass_mode_to_melissa(self, mocked_warning): """Test for hass operations to melssa.""" - self.assertEqual(0, self.thermostat.hass_mode_to_melissa(STATE_AUTO)) self.assertEqual( 1, self.thermostat.hass_mode_to_melissa(STATE_FAN_ONLY)) self.assertEqual(2, self.thermostat.hass_mode_to_melissa(STATE_HEAT)) diff --git a/tests/components/climate/test_mqtt.py b/tests/components/climate/test_mqtt.py index c4fa2b304df..663393503ac 100644 --- a/tests/components/climate/test_mqtt.py +++ b/tests/components/climate/test_mqtt.py @@ -104,8 +104,8 @@ class TestMQTTClimate(unittest.TestCase): state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual("cool", state.attributes.get('operation_mode')) self.assertEqual("cool", state.state) - self.assertEqual(('mode-topic', 'cool', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'mode-topic', 'cool', 0, False) def test_set_operation_pessimistic(self): """Test setting operation mode in pessimistic mode.""" @@ -178,8 +178,8 @@ class TestMQTTClimate(unittest.TestCase): self.assertEqual("low", state.attributes.get('fan_mode')) climate.set_fan_mode(self.hass, 'high', ENTITY_CLIMATE) self.hass.block_till_done() - self.assertEqual(('fan-mode-topic', 'high', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'fan-mode-topic', 'high', 0, False) state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual('high', state.attributes.get('fan_mode')) @@ -226,8 +226,8 @@ class TestMQTTClimate(unittest.TestCase): self.assertEqual("off", state.attributes.get('swing_mode')) climate.set_swing_mode(self.hass, 'on', ENTITY_CLIMATE) self.hass.block_till_done() - self.assertEqual(('swing-mode-topic', 'on', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'swing-mode-topic', 'on', 0, False) state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual("on", state.attributes.get('swing_mode')) @@ -239,15 +239,16 @@ class TestMQTTClimate(unittest.TestCase): self.assertEqual(21, state.attributes.get('temperature')) climate.set_operation_mode(self.hass, 'heat', ENTITY_CLIMATE) self.hass.block_till_done() - self.assertEqual(('mode-topic', 'heat', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'mode-topic', 'heat', 0, False) + self.mock_publish.async_publish.reset_mock() climate.set_temperature(self.hass, temperature=47, entity_id=ENTITY_CLIMATE) self.hass.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual(47, state.attributes.get('temperature')) - self.assertEqual(('temperature-topic', 47, 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'temperature-topic', 47, 0, False) def test_set_target_temperature_pessimistic(self): """Test setting the target temperature.""" @@ -328,15 +329,16 @@ class TestMQTTClimate(unittest.TestCase): self.assertEqual('off', state.attributes.get('away_mode')) climate.set_away_mode(self.hass, True, ENTITY_CLIMATE) self.hass.block_till_done() - self.assertEqual(('away-mode-topic', 'AN', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'away-mode-topic', 'AN', 0, False) + self.mock_publish.async_publish.reset_mock() state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual('on', state.attributes.get('away_mode')) climate.set_away_mode(self.hass, False, ENTITY_CLIMATE) self.hass.block_till_done() - self.assertEqual(('away-mode-topic', 'AUS', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'away-mode-topic', 'AUS', 0, False) state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual('off', state.attributes.get('away_mode')) @@ -372,15 +374,16 @@ class TestMQTTClimate(unittest.TestCase): self.assertEqual(None, state.attributes.get('hold_mode')) climate.set_hold_mode(self.hass, 'on', ENTITY_CLIMATE) self.hass.block_till_done() - self.assertEqual(('hold-topic', 'on', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'hold-topic', 'on', 0, False) + self.mock_publish.async_publish.reset_mock() state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual('on', state.attributes.get('hold_mode')) climate.set_hold_mode(self.hass, 'off', ENTITY_CLIMATE) self.hass.block_till_done() - self.assertEqual(('hold-topic', 'off', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'hold-topic', 'off', 0, False) state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual('off', state.attributes.get('hold_mode')) @@ -421,15 +424,16 @@ class TestMQTTClimate(unittest.TestCase): self.assertEqual('off', state.attributes.get('aux_heat')) climate.set_aux_heat(self.hass, True, ENTITY_CLIMATE) self.hass.block_till_done() - self.assertEqual(('aux-topic', 'ON', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'aux-topic', 'ON', 0, False) + self.mock_publish.async_publish.reset_mock() state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual('on', state.attributes.get('aux_heat')) climate.set_aux_heat(self.hass, False, ENTITY_CLIMATE) self.hass.block_till_done() - self.assertEqual(('aux-topic', 'OFF', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'aux-topic', 'OFF', 0, False) state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual('off', state.attributes.get('aux_heat')) diff --git a/tests/components/cloud/test_iot.py b/tests/components/cloud/test_iot.py index 53340ecede1..3eec350b2cb 100644 --- a/tests/components/cloud/test_iot.py +++ b/tests/components/cloud/test_iot.py @@ -17,7 +17,8 @@ def mock_client(): client = MagicMock() type(client).closed = PropertyMock(side_effect=[False, True]) - with patch('asyncio.sleep'), \ + # Trigger cancelled error to avoid reconnect. + with patch('asyncio.sleep', side_effect=asyncio.CancelledError), \ patch('homeassistant.components.cloud.iot' '.async_get_clientsession') as session: session().ws_connect.return_value = mock_coro(client) @@ -160,10 +161,10 @@ def test_cloud_getting_disconnected_by_server(mock_client, caplog, mock_cloud): type=WSMsgType.CLOSING, )) - yield from conn.connect() + with patch('asyncio.sleep', side_effect=[None, asyncio.CancelledError]): + yield from conn.connect() - assert 'Connection closed: Connection cancelled.' in caplog.text - assert 'connect' in str(mock_cloud.hass.async_add_job.mock_calls[-1][1][0]) + assert 'Connection closed' in caplog.text @asyncio.coroutine @@ -177,7 +178,6 @@ def test_cloud_receiving_bytes(mock_client, caplog, mock_cloud): yield from conn.connect() assert 'Connection closed: Received non-Text message' in caplog.text - assert 'connect' in str(mock_cloud.hass.async_add_job.mock_calls[-1][1][0]) @asyncio.coroutine @@ -192,19 +192,17 @@ def test_cloud_sending_invalid_json(mock_client, caplog, mock_cloud): yield from conn.connect() assert 'Connection closed: Received invalid JSON.' in caplog.text - assert 'connect' in str(mock_cloud.hass.async_add_job.mock_calls[-1][1][0]) @asyncio.coroutine def test_cloud_check_token_raising(mock_client, caplog, mock_cloud): - """Test cloud sending invalid JSON.""" + """Test cloud unable to check token.""" conn = iot.CloudIoT(mock_cloud) - mock_client.receive.side_effect = auth_api.CloudError + mock_cloud.hass.async_add_job.side_effect = auth_api.CloudError("BLA") yield from conn.connect() - assert 'Unable to connect: Unable to refresh token.' in caplog.text - assert 'connect' in str(mock_cloud.hass.async_add_job.mock_calls[-1][1][0]) + assert 'Unable to connect: BLA' in caplog.text @asyncio.coroutine diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py new file mode 100644 index 00000000000..1551ba74319 --- /dev/null +++ b/tests/components/config/test_config_entries.py @@ -0,0 +1,317 @@ +"""Test config entries API.""" + +import asyncio +from collections import OrderedDict +from unittest.mock import patch + +import pytest +import voluptuous as vol + +from homeassistant import config_entries as core_ce +from homeassistant.config_entries import ConfigFlowHandler, HANDLERS +from homeassistant.setup import async_setup_component +from homeassistant.components.config import config_entries +from homeassistant.loader import set_component + +from tests.common import MockConfigEntry, MockModule, mock_coro_func + + +@pytest.fixture +def client(hass, test_client): + """Fixture that can interact with the config manager API.""" + hass.loop.run_until_complete(async_setup_component(hass, 'http', {})) + hass.loop.run_until_complete(config_entries.async_setup(hass)) + yield hass.loop.run_until_complete(test_client(hass.http.app)) + + +@asyncio.coroutine +def test_get_entries(hass, client): + """Test get entries.""" + MockConfigEntry( + domain='comp', + title='Test 1', + source='bla' + ).add_to_hass(hass) + MockConfigEntry( + domain='comp2', + title='Test 2', + source='bla2', + state=core_ce.ENTRY_STATE_LOADED, + ).add_to_hass(hass) + resp = yield from client.get('/api/config/config_entries/entry') + assert resp.status == 200 + data = yield from resp.json() + for entry in data: + entry.pop('entry_id') + assert data == [ + { + 'domain': 'comp', + 'title': 'Test 1', + 'source': 'bla', + 'state': 'not_loaded' + }, + { + 'domain': 'comp2', + 'title': 'Test 2', + 'source': 'bla2', + 'state': 'loaded', + }, + ] + + +@asyncio.coroutine +def test_remove_entry(hass, client): + """Test removing an entry via the API.""" + entry = MockConfigEntry(domain='demo') + entry.add_to_hass(hass) + resp = yield from client.delete( + '/api/config/config_entries/entry/{}'.format(entry.entry_id)) + assert resp.status == 200 + data = yield from resp.json() + assert data == { + 'require_restart': True + } + assert len(hass.config_entries.async_entries()) == 0 + + +@asyncio.coroutine +def test_available_flows(hass, client): + """Test querying the available flows.""" + with patch.object(core_ce, 'FLOWS', ['hello', 'world']): + resp = yield from client.get( + '/api/config/config_entries/flow_handlers') + assert resp.status == 200 + data = yield from resp.json() + assert data == ['hello', 'world'] + + +############################ +# FLOW MANAGER API TESTS # +############################ + + +@asyncio.coroutine +def test_initialize_flow(hass, client): + """Test we can initialize a flow.""" + class TestFlow(ConfigFlowHandler): + @asyncio.coroutine + def async_step_init(self, user_input=None): + schema = OrderedDict() + schema[vol.Required('username')] = str + schema[vol.Required('password')] = str + + return self.async_show_form( + title='test-title', + step_id='init', + description='test-description', + data_schema=schema, + errors={ + 'username': 'Should be unique.' + } + ) + + with patch.dict(HANDLERS, {'test': TestFlow}): + resp = yield from client.post('/api/config/config_entries/flow', + json={'domain': 'test'}) + + assert resp.status == 200 + data = yield from resp.json() + + data.pop('flow_id') + + assert data == { + 'type': 'form', + 'title': 'test-title', + 'description': 'test-description', + 'data_schema': [ + { + 'name': 'username', + 'required': True, + 'type': 'string' + }, + { + 'name': 'password', + 'required': True, + 'type': 'string' + } + ], + 'errors': { + 'username': 'Should be unique.' + } + } + + +@asyncio.coroutine +def test_abort(hass, client): + """Test a flow that aborts.""" + class TestFlow(ConfigFlowHandler): + @asyncio.coroutine + def async_step_init(self, user_input=None): + return self.async_abort(reason='bla') + + with patch.dict(HANDLERS, {'test': TestFlow}): + resp = yield from client.post('/api/config/config_entries/flow', + json={'domain': 'test'}) + + assert resp.status == 200 + data = yield from resp.json() + data.pop('flow_id') + assert data == { + 'reason': 'bla', + 'type': 'abort' + } + + +@asyncio.coroutine +def test_create_account(hass, client): + """Test a flow that creates an account.""" + set_component( + 'test', MockModule('test', async_setup_entry=mock_coro_func(True))) + + class TestFlow(ConfigFlowHandler): + VERSION = 1 + + @asyncio.coroutine + def async_step_init(self, user_input=None): + return self.async_create_entry( + title='Test Entry', + data={'secret': 'account_token'} + ) + + with patch.dict(HANDLERS, {'test': TestFlow}): + resp = yield from client.post('/api/config/config_entries/flow', + json={'domain': 'test'}) + + assert resp.status == 200 + data = yield from resp.json() + data.pop('flow_id') + assert data == { + 'title': 'Test Entry', + 'type': 'create_entry' + } + + +@asyncio.coroutine +def test_two_step_flow(hass, client): + """Test we can finish a two step flow.""" + set_component( + 'test', MockModule('test', async_setup_entry=mock_coro_func(True))) + + class TestFlow(ConfigFlowHandler): + VERSION = 1 + + @asyncio.coroutine + def async_step_init(self, user_input=None): + return self.async_show_form( + title='test-title', + step_id='account', + data_schema=vol.Schema({ + 'user_title': str + })) + + @asyncio.coroutine + def async_step_account(self, user_input=None): + return self.async_create_entry( + title=user_input['user_title'], + data={'secret': 'account_token'} + ) + + with patch.dict(HANDLERS, {'test': TestFlow}): + resp = yield from client.post('/api/config/config_entries/flow', + json={'domain': 'test'}) + assert resp.status == 200 + data = yield from resp.json() + flow_id = data.pop('flow_id') + assert data == { + 'type': 'form', + 'title': 'test-title', + 'description': None, + 'data_schema': [ + { + 'name': 'user_title', + 'type': 'string' + } + ], + 'errors': None + } + + with patch.dict(HANDLERS, {'test': TestFlow}): + resp = yield from client.post( + '/api/config/config_entries/flow/{}'.format(flow_id), + json={'user_title': 'user-title'}) + assert resp.status == 200 + data = yield from resp.json() + data.pop('flow_id') + assert data == { + 'type': 'create_entry', + 'title': 'user-title', + } + + +@asyncio.coroutine +def test_get_progress_index(hass, client): + """Test querying for the flows that are in progress.""" + class TestFlow(ConfigFlowHandler): + VERSION = 5 + + @asyncio.coroutine + def async_step_hassio(self, info): + return (yield from self.async_step_account()) + + @asyncio.coroutine + def async_step_account(self, user_input=None): + return self.async_show_form( + step_id='account', + title='Finish setup' + ) + + with patch.dict(HANDLERS, {'test': TestFlow}): + form = yield from hass.config_entries.flow.async_init( + 'test', source='hassio') + + resp = yield from client.get('/api/config/config_entries/flow') + assert resp.status == 200 + data = yield from resp.json() + assert data == [ + { + 'flow_id': form['flow_id'], + 'domain': 'test', + 'source': 'hassio' + } + ] + + +@asyncio.coroutine +def test_get_progress_flow(hass, client): + """Test we can query the API for same result as we get from init a flow.""" + class TestFlow(ConfigFlowHandler): + @asyncio.coroutine + def async_step_init(self, user_input=None): + schema = OrderedDict() + schema[vol.Required('username')] = str + schema[vol.Required('password')] = str + + return self.async_show_form( + title='test-title', + step_id='init', + description='test-description', + data_schema=schema, + errors={ + 'username': 'Should be unique.' + } + ) + + with patch.dict(HANDLERS, {'test': TestFlow}): + resp = yield from client.post('/api/config/config_entries/flow', + json={'domain': 'test'}) + + assert resp.status == 200 + data = yield from resp.json() + + resp2 = yield from client.get( + '/api/config/config_entries/flow/{}'.format(data['flow_id'])) + + assert resp2.status == 200 + data2 = yield from resp2.json() + + assert data == data2 diff --git a/tests/components/config/test_hassbian.py b/tests/components/config/test_hassbian.py index 659e5ad2448..9038ccc6aa4 100644 --- a/tests/components/config/test_hassbian.py +++ b/tests/components/config/test_hassbian.py @@ -14,7 +14,7 @@ def test_setup_check_env_prevents_load(hass, loop): with patch.dict(os.environ, clear=True), \ patch.object(config, 'SECTIONS', ['hassbian']), \ patch('homeassistant.components.http.' - 'HomeAssistantWSGI.register_view') as reg_view: + 'HomeAssistantHTTP.register_view') as reg_view: loop.run_until_complete(async_setup_component(hass, 'config', {})) assert 'config' in hass.config.components assert reg_view.called is False @@ -25,7 +25,7 @@ def test_setup_check_env_works(hass, loop): with patch.dict(os.environ, {'FORCE_HASSBIAN': '1'}), \ patch.object(config, 'SECTIONS', ['hassbian']), \ patch('homeassistant.components.http.' - 'HomeAssistantWSGI.register_view') as reg_view: + 'HomeAssistantHTTP.register_view') as reg_view: loop.run_until_complete(async_setup_component(hass, 'config', {})) assert 'config' in hass.config.components assert len(reg_view.mock_calls) == 2 diff --git a/tests/components/config/test_init.py b/tests/components/config/test_init.py index 6f69f886419..2d5d814ac8a 100644 --- a/tests/components/config/test_init.py +++ b/tests/components/config/test_init.py @@ -2,19 +2,11 @@ import asyncio from unittest.mock import patch -import pytest - from homeassistant.const import EVENT_COMPONENT_LOADED from homeassistant.setup import async_setup_component, ATTR_COMPONENT from homeassistant.components import config -from tests.common import mock_http_component, mock_coro, mock_component - - -@pytest.fixture(autouse=True) -def stub_http(hass): - """Stub the HTTP component.""" - mock_http_component(hass) +from tests.common import mock_coro, mock_component @asyncio.coroutine diff --git a/tests/components/config/test_zwave.py b/tests/components/config/test_zwave.py index 81800d709e3..c98385a3c32 100644 --- a/tests/components/config/test_zwave.py +++ b/tests/components/config/test_zwave.py @@ -3,28 +3,30 @@ import asyncio import json from unittest.mock import MagicMock, patch +import pytest + from homeassistant.bootstrap import async_setup_component from homeassistant.components import config from homeassistant.components.zwave import DATA_NETWORK, const -from homeassistant.components.config.zwave import ( - ZWaveNodeValueView, ZWaveNodeGroupView, ZWaveNodeConfigView, - ZWaveUserCodeView, ZWaveConfigWriteView) -from tests.common import mock_http_component_app from tests.mock.zwave import MockNode, MockValue, MockEntityValues VIEW_NAME = 'api:config:zwave:device_config' -@asyncio.coroutine -def test_get_device_config(hass, test_client): - """Test getting device config.""" +@pytest.fixture +def client(loop, hass, test_client): + """Client to communicate with Z-Wave config views.""" with patch.object(config, 'SECTIONS', ['zwave']): - yield from async_setup_component(hass, 'config', {}) + loop.run_until_complete(async_setup_component(hass, 'config', {})) - client = yield from test_client(hass.http.app) + return loop.run_until_complete(test_client(hass.http.app)) + +@asyncio.coroutine +def test_get_device_config(client): + """Test getting device config.""" def mock_read(path): """Mock reading data.""" return { @@ -47,13 +49,8 @@ def test_get_device_config(hass, test_client): @asyncio.coroutine -def test_update_device_config(hass, test_client): +def test_update_device_config(client): """Test updating device config.""" - with patch.object(config, 'SECTIONS', ['zwave']): - yield from async_setup_component(hass, 'config', {}) - - client = yield from test_client(hass.http.app) - orig_data = { 'hello.beer': { 'ignored': True, @@ -90,13 +87,8 @@ def test_update_device_config(hass, test_client): @asyncio.coroutine -def test_update_device_config_invalid_key(hass, test_client): +def test_update_device_config_invalid_key(client): """Test updating device config.""" - with patch.object(config, 'SECTIONS', ['zwave']): - yield from async_setup_component(hass, 'config', {}) - - client = yield from test_client(hass.http.app) - resp = yield from client.post( '/api/config/zwave/device_config/invalid_entity', data=json.dumps({ 'polling_intensity': 2 @@ -106,13 +98,8 @@ def test_update_device_config_invalid_key(hass, test_client): @asyncio.coroutine -def test_update_device_config_invalid_data(hass, test_client): +def test_update_device_config_invalid_data(client): """Test updating device config.""" - with patch.object(config, 'SECTIONS', ['zwave']): - yield from async_setup_component(hass, 'config', {}) - - client = yield from test_client(hass.http.app) - resp = yield from client.post( '/api/config/zwave/device_config/hello.beer', data=json.dumps({ 'invalid_option': 2 @@ -122,13 +109,8 @@ def test_update_device_config_invalid_data(hass, test_client): @asyncio.coroutine -def test_update_device_config_invalid_json(hass, test_client): +def test_update_device_config_invalid_json(client): """Test updating device config.""" - with patch.object(config, 'SECTIONS', ['zwave']): - yield from async_setup_component(hass, 'config', {}) - - client = yield from test_client(hass.http.app) - resp = yield from client.post( '/api/config/zwave/device_config/hello.beer', data='not json') @@ -136,11 +118,8 @@ def test_update_device_config_invalid_json(hass, test_client): @asyncio.coroutine -def test_get_values(hass, test_client): +def test_get_values(hass, client): """Test getting values on node.""" - app = mock_http_component_app(hass) - ZWaveNodeValueView().register(app.router) - node = MockNode(node_id=1) value = MockValue(value_id=123456, node=node, label='Test Label', instance=1, index=2, poll_intensity=4) @@ -150,8 +129,6 @@ def test_get_values(hass, test_client): values2 = MockEntityValues(primary=value2) hass.data[const.DATA_ENTITY_VALUES] = [values, values2] - client = yield from test_client(app) - resp = yield from client.get('/api/zwave/values/1') assert resp.status == 200 @@ -168,11 +145,8 @@ def test_get_values(hass, test_client): @asyncio.coroutine -def test_get_groups(hass, test_client): +def test_get_groups(hass, client): """Test getting groupdata on node.""" - app = mock_http_component_app(hass) - ZWaveNodeGroupView().register(app.router) - network = hass.data[DATA_NETWORK] = MagicMock() node = MockNode(node_id=2) node.groups.associations = 'assoc' @@ -182,8 +156,6 @@ def test_get_groups(hass, test_client): node.groups = {1: node.groups} network.nodes = {2: node} - client = yield from test_client(app) - resp = yield from client.get('/api/zwave/groups/2') assert resp.status == 200 @@ -200,18 +172,13 @@ def test_get_groups(hass, test_client): @asyncio.coroutine -def test_get_groups_nogroups(hass, test_client): +def test_get_groups_nogroups(hass, client): """Test getting groupdata on node with no groups.""" - app = mock_http_component_app(hass) - ZWaveNodeGroupView().register(app.router) - network = hass.data[DATA_NETWORK] = MagicMock() node = MockNode(node_id=2) network.nodes = {2: node} - client = yield from test_client(app) - resp = yield from client.get('/api/zwave/groups/2') assert resp.status == 200 @@ -221,16 +188,11 @@ def test_get_groups_nogroups(hass, test_client): @asyncio.coroutine -def test_get_groups_nonode(hass, test_client): +def test_get_groups_nonode(hass, client): """Test getting groupdata on nonexisting node.""" - app = mock_http_component_app(hass) - ZWaveNodeGroupView().register(app.router) - network = hass.data[DATA_NETWORK] = MagicMock() network.nodes = {1: 1, 5: 5} - client = yield from test_client(app) - resp = yield from client.get('/api/zwave/groups/2') assert resp.status == 404 @@ -240,11 +202,8 @@ def test_get_groups_nonode(hass, test_client): @asyncio.coroutine -def test_get_config(hass, test_client): +def test_get_config(hass, client): """Test getting config on node.""" - app = mock_http_component_app(hass) - ZWaveNodeConfigView().register(app.router) - network = hass.data[DATA_NETWORK] = MagicMock() node = MockNode(node_id=2) value = MockValue( @@ -261,8 +220,6 @@ def test_get_config(hass, test_client): network.nodes = {2: node} node.get_values.return_value = node.values - client = yield from test_client(app) - resp = yield from client.get('/api/zwave/config/2') assert resp.status == 200 @@ -278,19 +235,14 @@ def test_get_config(hass, test_client): @asyncio.coroutine -def test_get_config_noconfig_node(hass, test_client): +def test_get_config_noconfig_node(hass, client): """Test getting config on node without config.""" - app = mock_http_component_app(hass) - ZWaveNodeConfigView().register(app.router) - network = hass.data[DATA_NETWORK] = MagicMock() node = MockNode(node_id=2) network.nodes = {2: node} node.get_values.return_value = node.values - client = yield from test_client(app) - resp = yield from client.get('/api/zwave/config/2') assert resp.status == 200 @@ -300,16 +252,11 @@ def test_get_config_noconfig_node(hass, test_client): @asyncio.coroutine -def test_get_config_nonode(hass, test_client): +def test_get_config_nonode(hass, client): """Test getting config on nonexisting node.""" - app = mock_http_component_app(hass) - ZWaveNodeConfigView().register(app.router) - network = hass.data[DATA_NETWORK] = MagicMock() network.nodes = {1: 1, 5: 5} - client = yield from test_client(app) - resp = yield from client.get('/api/zwave/config/2') assert resp.status == 404 @@ -319,16 +266,11 @@ def test_get_config_nonode(hass, test_client): @asyncio.coroutine -def test_get_usercodes_nonode(hass, test_client): +def test_get_usercodes_nonode(hass, client): """Test getting usercodes on nonexisting node.""" - app = mock_http_component_app(hass) - ZWaveUserCodeView().register(app.router) - network = hass.data[DATA_NETWORK] = MagicMock() network.nodes = {1: 1, 5: 5} - client = yield from test_client(app) - resp = yield from client.get('/api/zwave/usercodes/2') assert resp.status == 404 @@ -338,11 +280,8 @@ def test_get_usercodes_nonode(hass, test_client): @asyncio.coroutine -def test_get_usercodes(hass, test_client): +def test_get_usercodes(hass, client): """Test getting usercodes on node.""" - app = mock_http_component_app(hass) - ZWaveUserCodeView().register(app.router) - network = hass.data[DATA_NETWORK] = MagicMock() node = MockNode(node_id=18, command_classes=[const.COMMAND_CLASS_USER_CODE]) @@ -356,8 +295,6 @@ def test_get_usercodes(hass, test_client): network.nodes = {18: node} node.get_values.return_value = node.values - client = yield from test_client(app) - resp = yield from client.get('/api/zwave/usercodes/18') assert resp.status == 200 @@ -369,19 +306,14 @@ def test_get_usercodes(hass, test_client): @asyncio.coroutine -def test_get_usercode_nousercode_node(hass, test_client): +def test_get_usercode_nousercode_node(hass, client): """Test getting usercodes on node without usercodes.""" - app = mock_http_component_app(hass) - ZWaveUserCodeView().register(app.router) - network = hass.data[DATA_NETWORK] = MagicMock() node = MockNode(node_id=18) network.nodes = {18: node} node.get_values.return_value = node.values - client = yield from test_client(app) - resp = yield from client.get('/api/zwave/usercodes/18') assert resp.status == 200 @@ -391,11 +323,8 @@ def test_get_usercode_nousercode_node(hass, test_client): @asyncio.coroutine -def test_get_usercodes_no_genreuser(hass, test_client): +def test_get_usercodes_no_genreuser(hass, client): """Test getting usercodes on node missing genre user.""" - app = mock_http_component_app(hass) - ZWaveUserCodeView().register(app.router) - network = hass.data[DATA_NETWORK] = MagicMock() node = MockNode(node_id=18, command_classes=[const.COMMAND_CLASS_USER_CODE]) @@ -409,8 +338,6 @@ def test_get_usercodes_no_genreuser(hass, test_client): network.nodes = {18: node} node.get_values.return_value = node.values - client = yield from test_client(app) - resp = yield from client.get('/api/zwave/usercodes/18') assert resp.status == 200 @@ -420,13 +347,8 @@ def test_get_usercodes_no_genreuser(hass, test_client): @asyncio.coroutine -def test_save_config_no_network(hass, test_client): +def test_save_config_no_network(hass, client): """Test saving configuration without network data.""" - app = mock_http_component_app(hass) - ZWaveConfigWriteView().register(app.router) - - client = yield from test_client(app) - resp = yield from client.post('/api/zwave/saveconfig') assert resp.status == 404 @@ -435,15 +357,10 @@ def test_save_config_no_network(hass, test_client): @asyncio.coroutine -def test_save_config(hass, test_client): +def test_save_config(hass, client): """Test saving configuration.""" - app = mock_http_component_app(hass) - ZWaveConfigWriteView().register(app.router) - network = hass.data[DATA_NETWORK] = MagicMock() - client = yield from test_client(app) - resp = yield from client.post('/api/zwave/saveconfig') assert resp.status == 200 diff --git a/tests/components/cover/test_mqtt.py b/tests/components/cover/test_mqtt.py index 0b49e21674e..23a7b32fc28 100644 --- a/tests/components/cover/test_mqtt.py +++ b/tests/components/cover/test_mqtt.py @@ -116,16 +116,17 @@ class TestCoverMQTT(unittest.TestCase): cover.open_cover(self.hass, 'cover.test') self.hass.block_till_done() - self.assertEqual(('command-topic', 'OPEN', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'command-topic', 'OPEN', 0, False) + self.mock_publish.async_publish.reset_mock() state = self.hass.states.get('cover.test') self.assertEqual(STATE_OPEN, state.state) cover.close_cover(self.hass, 'cover.test') self.hass.block_till_done() - self.assertEqual(('command-topic', 'CLOSE', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'command-topic', 'CLOSE', 0, False) state = self.hass.states.get('cover.test') self.assertEqual(STATE_CLOSED, state.state) @@ -147,8 +148,8 @@ class TestCoverMQTT(unittest.TestCase): cover.open_cover(self.hass, 'cover.test') self.hass.block_till_done() - self.assertEqual(('command-topic', 'OPEN', 2, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'command-topic', 'OPEN', 2, False) state = self.hass.states.get('cover.test') self.assertEqual(STATE_UNKNOWN, state.state) @@ -170,8 +171,8 @@ class TestCoverMQTT(unittest.TestCase): cover.close_cover(self.hass, 'cover.test') self.hass.block_till_done() - self.assertEqual(('command-topic', 'CLOSE', 2, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'command-topic', 'CLOSE', 2, False) state = self.hass.states.get('cover.test') self.assertEqual(STATE_UNKNOWN, state.state) @@ -193,8 +194,8 @@ class TestCoverMQTT(unittest.TestCase): cover.stop_cover(self.hass, 'cover.test') self.hass.block_till_done() - self.assertEqual(('command-topic', 'STOP', 2, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'command-topic', 'STOP', 2, False) state = self.hass.states.get('cover.test') self.assertEqual(STATE_UNKNOWN, state.state) @@ -295,8 +296,8 @@ class TestCoverMQTT(unittest.TestCase): cover.set_cover_position(self.hass, 100, 'cover.test') self.hass.block_till_done() - self.assertEqual(('position-topic', '38', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'position-topic', '38', 0, False) def test_set_position_untemplated(self): """Test setting cover position via template.""" @@ -316,8 +317,8 @@ class TestCoverMQTT(unittest.TestCase): cover.set_cover_position(self.hass, 62, 'cover.test') self.hass.block_till_done() - self.assertEqual(('position-topic', 62, 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'position-topic', 62, 0, False) def test_no_command_topic(self): """Test with no command topic.""" @@ -401,14 +402,15 @@ class TestCoverMQTT(unittest.TestCase): cover.open_cover_tilt(self.hass, 'cover.test') self.hass.block_till_done() - self.assertEqual(('tilt-command-topic', 100, 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'tilt-command-topic', 100, 0, False) + self.mock_publish.async_publish.reset_mock() cover.close_cover_tilt(self.hass, 'cover.test') self.hass.block_till_done() - self.assertEqual(('tilt-command-topic', 0, 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'tilt-command-topic', 0, 0, False) def test_tilt_given_value(self): """Test tilting to a given value.""" @@ -432,14 +434,15 @@ class TestCoverMQTT(unittest.TestCase): cover.open_cover_tilt(self.hass, 'cover.test') self.hass.block_till_done() - self.assertEqual(('tilt-command-topic', 400, 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'tilt-command-topic', 400, 0, False) + self.mock_publish.async_publish.reset_mock() cover.close_cover_tilt(self.hass, 'cover.test') self.hass.block_till_done() - self.assertEqual(('tilt-command-topic', 125, 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'tilt-command-topic', 125, 0, False) def test_tilt_via_topic(self): """Test tilt by updating status via MQTT.""" @@ -538,8 +541,8 @@ class TestCoverMQTT(unittest.TestCase): cover.set_cover_tilt_position(self.hass, 50, 'cover.test') self.hass.block_till_done() - self.assertEqual(('tilt-command-topic', 50, 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'tilt-command-topic', 50, 0, False) def test_tilt_position_altered_range(self): """Test tilt via method invocation with altered range.""" @@ -565,8 +568,8 @@ class TestCoverMQTT(unittest.TestCase): cover.set_cover_tilt_position(self.hass, 50, 'cover.test') self.hass.block_till_done() - self.assertEqual(('tilt-command-topic', 25, 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'tilt-command-topic', 25, 0, False) def test_find_percentage_in_range_defaults(self): """Test find percentage in range with default range.""" diff --git a/tests/components/cover/test_zwave.py b/tests/components/cover/test_zwave.py index 4b9be9f5e00..b870075d39f 100644 --- a/tests/components/cover/test_zwave.py +++ b/tests/components/cover/test_zwave.py @@ -118,7 +118,7 @@ def test_roller_commands(hass, mock_openzwave): device = zwave.get_device(hass=hass, node=node, values=values, node_config={}) - device.set_cover_position(25) + device.set_cover_position(position=25) assert node.set_dimmer.called value_id, brightness = node.set_dimmer.mock_calls[0][1] assert value_id == value.value_id diff --git a/tests/components/device_tracker/test_asuswrt.py b/tests/components/device_tracker/test_asuswrt.py index f8d3fdf128b..48ddf1d3692 100644 --- a/tests/components/device_tracker/test_asuswrt.py +++ b/tests/components/device_tracker/test_asuswrt.py @@ -52,8 +52,8 @@ WL_DEVICES = { ARP_DATA = [ '? (123.123.123.125) at 01:02:03:04:06:08 [ether] on eth0\r', - '? (123.123.123.126) at 08:09:10:11:12:14 [ether] on br0\r' - '? (123.123.123.127) at on br0\r' + '? (123.123.123.126) at 08:09:10:11:12:14 [ether] on br0\r', + '? (123.123.123.127) at on br0\r', ] ARP_DEVICES = { @@ -65,8 +65,10 @@ ARP_DEVICES = { NEIGH_DATA = [ '123.123.123.125 dev eth0 lladdr 01:02:03:04:06:08 REACHABLE\r', - '123.123.123.126 dev br0 lladdr 08:09:10:11:12:14 STALE\r' - '123.123.123.127 dev br0 FAILED\r' + '123.123.123.126 dev br0 lladdr 08:09:10:11:12:14 REACHABLE\r', + '123.123.123.127 dev br0 FAILED\r', + '123.123.123.128 dev br0 lladdr 08:09:15:15:15:15 DELAY\r', + 'fe80::feff:a6ff:feff:12ff dev br0 lladdr fc:ff:a6:ff:12:ff STALE\r', ] NEIGH_DEVICES = { @@ -473,7 +475,7 @@ class TestSshConnection(unittest.TestCase): def setUp(self): """Setup test env.""" self.connection = SshConnection( - 'fake', 'fake', 'fake', 'fake', 'fake', 'fake') + 'fake', 'fake', 'fake', 'fake', 'fake') self.connection._connected = True def test_run_command_exception_eof(self): @@ -513,7 +515,7 @@ class TestTelnetConnection(unittest.TestCase): def setUp(self): """Setup test env.""" self.connection = TelnetConnection( - 'fake', 'fake', 'fake', 'fake', 'fake') + 'fake', 'fake', 'fake', 'fake') self.connection._connected = True def test_run_command_exception_eof(self): diff --git a/tests/components/device_tracker/test_automatic.py b/tests/components/device_tracker/test_automatic.py index d40c1518ffa..d90b5c0dd62 100644 --- a/tests/components/device_tracker/test_automatic.py +++ b/tests/components/device_tracker/test_automatic.py @@ -5,11 +5,10 @@ import logging from unittest.mock import patch, MagicMock import aioautomatic +from homeassistant.setup import async_setup_component from homeassistant.components.device_tracker.automatic import ( async_setup_scanner) -from tests.common import mock_http_component - _LOGGER = logging.getLogger(__name__) @@ -23,8 +22,7 @@ def test_invalid_credentials( mock_open, mock_isfile, mock_makedirs, mock_json_dump, mock_json_load, mock_create_session, hass): """Test with invalid credentials.""" - mock_http_component(hass) - + hass.loop.run_until_complete(async_setup_component(hass, 'http', {})) mock_json_load.return_value = {'refresh_token': 'bad_token'} @asyncio.coroutine @@ -59,8 +57,7 @@ def test_valid_credentials( mock_open, mock_isfile, mock_makedirs, mock_json_dump, mock_json_load, mock_ws_connect, mock_create_session, hass): """Test with valid credentials.""" - mock_http_component(hass) - + hass.loop.run_until_complete(async_setup_component(hass, 'http', {})) mock_json_load.return_value = {'refresh_token': 'good_token'} session = MagicMock() diff --git a/tests/components/device_tracker/test_unifi.py b/tests/components/device_tracker/test_unifi.py index 083315b4c71..ccc58d728ed 100644 --- a/tests/components/device_tracker/test_unifi.py +++ b/tests/components/device_tracker/test_unifi.py @@ -53,7 +53,8 @@ def test_config_valid_verify_ssl(hass, mock_scanner, mock_ctrl): assert mock_scanner.call_count == 1 assert mock_scanner.call_args == mock.call(mock_ctrl.return_value, - DEFAULT_DETECTION_TIME) + DEFAULT_DETECTION_TIME, + None) def test_config_minimal(hass, mock_scanner, mock_ctrl): @@ -74,7 +75,8 @@ def test_config_minimal(hass, mock_scanner, mock_ctrl): assert mock_scanner.call_count == 1 assert mock_scanner.call_args == mock.call(mock_ctrl.return_value, - DEFAULT_DETECTION_TIME) + DEFAULT_DETECTION_TIME, + None) def test_config_full(hass, mock_scanner, mock_ctrl): @@ -100,7 +102,8 @@ def test_config_full(hass, mock_scanner, mock_ctrl): assert mock_scanner.call_count == 1 assert mock_scanner.call_args == mock.call(mock_ctrl.return_value, - DEFAULT_DETECTION_TIME) + DEFAULT_DETECTION_TIME, + None) def test_config_error(): @@ -148,11 +151,13 @@ def test_scanner_update(): """Test the scanner update.""" ctrl = mock.MagicMock() fake_clients = [ - {'mac': '123', 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, - {'mac': '234', 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, + {'mac': '123', 'essid': 'barnet', + 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, + {'mac': '234', 'essid': 'barnet', + 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, ] ctrl.get_clients.return_value = fake_clients - unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME) + unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, None) assert ctrl.get_clients.call_count == 1 assert ctrl.get_clients.call_args == mock.call() @@ -162,36 +167,61 @@ def test_scanner_update_error(): ctrl = mock.MagicMock() ctrl.get_clients.side_effect = APIError( '/', 500, 'foo', {}, None) - unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME) + unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, None) def test_scan_devices(): """Test the scanning for devices.""" ctrl = mock.MagicMock() fake_clients = [ - {'mac': '123', 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, - {'mac': '234', 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, + {'mac': '123', 'essid': 'barnet', + 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, + {'mac': '234', 'essid': 'barnet', + 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, ] ctrl.get_clients.return_value = fake_clients - scanner = unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME) + scanner = unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, None) assert set(scanner.scan_devices()) == set(['123', '234']) +def test_scan_devices_filtered(): + """Test the scanning for devices based on SSID.""" + ctrl = mock.MagicMock() + fake_clients = [ + {'mac': '123', 'essid': 'foonet', + 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, + {'mac': '234', 'essid': 'foonet', + 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, + {'mac': '567', 'essid': 'notnet', + 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, + {'mac': '890', 'essid': 'barnet', + 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, + ] + + ssid_filter = ['foonet', 'barnet'] + ctrl.get_clients.return_value = fake_clients + scanner = unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, ssid_filter) + assert set(scanner.scan_devices()) == set(['123', '234', '890']) + + def test_get_device_name(): """Test the getting of device names.""" ctrl = mock.MagicMock() fake_clients = [ {'mac': '123', 'hostname': 'foobar', + 'essid': 'barnet', 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, {'mac': '234', 'name': 'Nice Name', + 'essid': 'barnet', 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, {'mac': '456', + 'essid': 'barnet', 'last_seen': '1504786810'}, ] ctrl.get_clients.return_value = fake_clients - scanner = unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME) + scanner = unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, None) assert scanner.get_device_name('123') == 'foobar' assert scanner.get_device_name('234') == 'Nice Name' assert scanner.get_device_name('456') is None diff --git a/tests/components/hassio/__init__.py b/tests/components/hassio/__init__.py new file mode 100644 index 00000000000..6fcd9d2229f --- /dev/null +++ b/tests/components/hassio/__init__.py @@ -0,0 +1,4 @@ +"""Tests for Hassio component.""" + +API_PASSWORD = 'pass1234' +HASSIO_TOKEN = '123456' diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py new file mode 100644 index 00000000000..56d6cbe666e --- /dev/null +++ b/tests/components/hassio/conftest.py @@ -0,0 +1,50 @@ +"""Fixtures for Hass.io.""" +import os +from unittest.mock import patch, Mock + +import pytest + +from homeassistant.setup import async_setup_component +from homeassistant.components.hassio.handler import HassIO + +from tests.common import mock_coro +from . import API_PASSWORD, HASSIO_TOKEN + + +@pytest.fixture +def hassio_env(): + """Fixture to inject hassio env.""" + with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}), \ + patch('homeassistant.components.hassio.HassIO.is_connected', + Mock(return_value=mock_coro( + {"result": "ok", "data": {}}))), \ + patch.dict(os.environ, {'HASSIO_TOKEN': "123456"}), \ + patch('homeassistant.components.hassio.HassIO.' + 'get_homeassistant_info', + Mock(return_value=mock_coro(None))): + yield + + +@pytest.fixture +def hassio_client(hassio_env, hass, test_client): + """Create mock hassio http client.""" + with patch('homeassistant.components.hassio.HassIO.update_hass_api', + Mock(return_value=mock_coro({"result": "ok"}))), \ + patch('homeassistant.components.hassio.HassIO.' + 'get_homeassistant_info', + Mock(return_value=mock_coro(None))): + hass.loop.run_until_complete(async_setup_component(hass, 'hassio', { + 'http': { + 'api_password': API_PASSWORD + } + })) + yield hass.loop.run_until_complete(test_client(hass.http.app)) + + +@pytest.fixture +def hassio_handler(hass, aioclient_mock): + """Create mock hassio handler.""" + websession = hass.helpers.aiohttp_client.async_get_clientsession() + + with patch.dict(os.environ, {'HASSIO_TOKEN': HASSIO_TOKEN}): + yield HassIO(hass.loop, websession, "127.0.0.1") diff --git a/tests/components/hassio/test_handler.py b/tests/components/hassio/test_handler.py new file mode 100644 index 00000000000..78745489a78 --- /dev/null +++ b/tests/components/hassio/test_handler.py @@ -0,0 +1,90 @@ +"""The tests for the hassio component.""" +import asyncio + +import aiohttp + + +@asyncio.coroutine +def test_api_ping(hassio_handler, aioclient_mock): + """Test setup with API ping.""" + aioclient_mock.get( + "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) + + assert (yield from hassio_handler.is_connected()) + assert aioclient_mock.call_count == 1 + + +@asyncio.coroutine +def test_api_ping_error(hassio_handler, aioclient_mock): + """Test setup with API ping error.""" + aioclient_mock.get( + "http://127.0.0.1/supervisor/ping", json={'result': 'error'}) + + assert not (yield from hassio_handler.is_connected()) + assert aioclient_mock.call_count == 1 + + +@asyncio.coroutine +def test_api_ping_exeption(hassio_handler, aioclient_mock): + """Test setup with API ping exception.""" + aioclient_mock.get( + "http://127.0.0.1/supervisor/ping", exc=aiohttp.ClientError()) + + assert not (yield from hassio_handler.is_connected()) + assert aioclient_mock.call_count == 1 + + +@asyncio.coroutine +def test_api_homeassistant_info(hassio_handler, aioclient_mock): + """Test setup with API homeassistant info.""" + aioclient_mock.get( + "http://127.0.0.1/homeassistant/info", json={ + 'result': 'ok', 'data': {'last_version': '10.0'}}) + + data = yield from hassio_handler.get_homeassistant_info() + assert aioclient_mock.call_count == 1 + assert data['last_version'] == "10.0" + + +@asyncio.coroutine +def test_api_homeassistant_info_error(hassio_handler, aioclient_mock): + """Test setup with API homeassistant info error.""" + aioclient_mock.get( + "http://127.0.0.1/homeassistant/info", json={ + 'result': 'error', 'message': None}) + + data = yield from hassio_handler.get_homeassistant_info() + assert aioclient_mock.call_count == 1 + assert data is None + + +@asyncio.coroutine +def test_api_homeassistant_stop(hassio_handler, aioclient_mock): + """Test setup with API HomeAssistant stop.""" + aioclient_mock.post( + "http://127.0.0.1/homeassistant/stop", json={'result': 'ok'}) + + assert (yield from hassio_handler.stop_homeassistant()) + assert aioclient_mock.call_count == 1 + + +@asyncio.coroutine +def test_api_homeassistant_restart(hassio_handler, aioclient_mock): + """Test setup with API HomeAssistant restart.""" + aioclient_mock.post( + "http://127.0.0.1/homeassistant/restart", json={'result': 'ok'}) + + assert (yield from hassio_handler.restart_homeassistant()) + assert aioclient_mock.call_count == 1 + + +@asyncio.coroutine +def test_api_homeassistant_config(hassio_handler, aioclient_mock): + """Test setup with API HomeAssistant restart.""" + aioclient_mock.post( + "http://127.0.0.1/homeassistant/check", json={ + 'result': 'ok', 'data': {'test': 'bla'}}) + + data = yield from hassio_handler.check_homeassistant_config() + assert data['data']['test'] == 'bla' + assert aioclient_mock.call_count == 1 diff --git a/tests/components/hassio/test_http.py b/tests/components/hassio/test_http.py new file mode 100644 index 00000000000..ed425ad8cca --- /dev/null +++ b/tests/components/hassio/test_http.py @@ -0,0 +1,133 @@ +"""The tests for the hassio component.""" +import asyncio +from unittest.mock import patch, Mock, MagicMock + +import pytest + +from homeassistant.const import HTTP_HEADER_HA_AUTH + +from tests.common import mock_coro +from . import API_PASSWORD + + +@asyncio.coroutine +def test_forward_request(hassio_client): + """Test fetching normal path.""" + response = MagicMock() + response.read.return_value = mock_coro('data') + + with patch('homeassistant.components.hassio.HassIOView._command_proxy', + Mock(return_value=mock_coro(response))), \ + patch('homeassistant.components.hassio.http' + '._create_response') as mresp: + mresp.return_value = 'response' + resp = yield from hassio_client.post('/api/hassio/beer', headers={ + HTTP_HEADER_HA_AUTH: API_PASSWORD + }) + + # Check we got right response + assert resp.status == 200 + body = yield from resp.text() + assert body == 'response' + + # Check we forwarded command + assert len(mresp.mock_calls) == 1 + assert mresp.mock_calls[0][1] == (response, 'data') + + +@asyncio.coroutine +def test_auth_required_forward_request(hassio_client): + """Test auth required for normal request.""" + resp = yield from hassio_client.post('/api/hassio/beer') + + # Check we got right response + assert resp.status == 401 + + +@asyncio.coroutine +@pytest.mark.parametrize( + 'build_type', [ + 'es5/index.html', 'es5/hassio-app.html', 'latest/index.html', + 'latest/hassio-app.html' + ]) +def test_forward_request_no_auth_for_panel(hassio_client, build_type): + """Test no auth needed for .""" + response = MagicMock() + response.read.return_value = mock_coro('data') + + with patch('homeassistant.components.hassio.HassIOView._command_proxy', + Mock(return_value=mock_coro(response))), \ + patch('homeassistant.components.hassio.http.' + '_create_response') as mresp: + mresp.return_value = 'response' + resp = yield from hassio_client.get( + '/api/hassio/app-{}'.format(build_type)) + + # Check we got right response + assert resp.status == 200 + body = yield from resp.text() + assert body == 'response' + + # Check we forwarded command + assert len(mresp.mock_calls) == 1 + assert mresp.mock_calls[0][1] == (response, 'data') + + +@asyncio.coroutine +def test_forward_request_no_auth_for_logo(hassio_client): + """Test no auth needed for .""" + response = MagicMock() + response.read.return_value = mock_coro('data') + + with patch('homeassistant.components.hassio.HassIOView._command_proxy', + Mock(return_value=mock_coro(response))), \ + patch('homeassistant.components.hassio.http.' + '_create_response') as mresp: + mresp.return_value = 'response' + resp = yield from hassio_client.get('/api/hassio/addons/bl_b392/logo') + + # Check we got right response + assert resp.status == 200 + body = yield from resp.text() + assert body == 'response' + + # Check we forwarded command + assert len(mresp.mock_calls) == 1 + assert mresp.mock_calls[0][1] == (response, 'data') + + +@asyncio.coroutine +def test_forward_log_request(hassio_client): + """Test fetching normal log path.""" + response = MagicMock() + response.read.return_value = mock_coro('data') + + with patch('homeassistant.components.hassio.HassIOView._command_proxy', + Mock(return_value=mock_coro(response))), \ + patch('homeassistant.components.hassio.http.' + '_create_response_log') as mresp: + mresp.return_value = 'response' + resp = yield from hassio_client.get('/api/hassio/beer/logs', headers={ + HTTP_HEADER_HA_AUTH: API_PASSWORD + }) + + # Check we got right response + assert resp.status == 200 + body = yield from resp.text() + assert body == 'response' + + # Check we forwarded command + assert len(mresp.mock_calls) == 1 + assert mresp.mock_calls[0][1] == (response, 'data') + + +@asyncio.coroutine +def test_bad_gateway_when_cannot_find_supervisor(hassio_client): + """Test we get a bad gateway error if we can't find supervisor.""" + with patch('homeassistant.components.hassio.http.async_timeout.timeout', + side_effect=asyncio.TimeoutError): + resp = yield from hassio_client.get( + '/api/hassio/addons/test/info', headers={ + HTTP_HEADER_HA_AUTH: API_PASSWORD + }) + assert resp.status == 502 diff --git a/tests/components/test_hassio.py b/tests/components/hassio/test_init.py similarity index 67% rename from tests/components/test_hassio.py rename to tests/components/hassio/test_init.py index 8fb017309de..e17419e7fd5 100644 --- a/tests/components/test_hassio.py +++ b/tests/components/hassio/test_init.py @@ -1,68 +1,13 @@ """The tests for the hassio component.""" import asyncio import os -from unittest.mock import patch, Mock, MagicMock +from unittest.mock import patch, Mock -import pytest - -from homeassistant.const import HTTP_HEADER_HA_AUTH from homeassistant.setup import async_setup_component from homeassistant.components.hassio import async_check_config from tests.common import mock_coro -API_PASSWORD = 'pass1234' - - -@pytest.fixture -def hassio_env(): - """Fixture to inject hassio env.""" - with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}), \ - patch('homeassistant.components.hassio.HassIO.is_connected', - Mock(return_value=mock_coro( - {"result": "ok", "data": {}}))), \ - patch.dict(os.environ, {'HASSIO_TOKEN': "123456"}), \ - patch('homeassistant.components.hassio.HassIO.' - 'get_homeassistant_info', - Mock(return_value=mock_coro(None))): - yield - - -@pytest.fixture -def hassio_client(hassio_env, hass, test_client): - """Create mock hassio http client.""" - with patch('homeassistant.components.hassio.HassIO.update_hass_api', - Mock(return_value=mock_coro({"result": "ok"}))), \ - patch('homeassistant.components.hassio.HassIO.' - 'get_homeassistant_info', - Mock(return_value=mock_coro(None))): - hass.loop.run_until_complete(async_setup_component(hass, 'hassio', { - 'http': { - 'api_password': API_PASSWORD - } - })) - yield hass.loop.run_until_complete(test_client(hass.http.app)) - - -@asyncio.coroutine -def test_fail_setup_without_environ_var(hass): - """Fail setup if no environ variable set.""" - with patch.dict(os.environ, {}, clear=True): - result = yield from async_setup_component(hass, 'hassio', {}) - assert not result - - -@asyncio.coroutine -def test_fail_setup_cannot_connect(hass): - """Fail setup if cannot connect.""" - with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}), \ - patch('homeassistant.components.hassio.HassIO.is_connected', - Mock(return_value=mock_coro(None))): - result = yield from async_setup_component(hass, 'hassio', {}) - assert not result - - assert not hass.components.hassio.is_hassio() - @asyncio.coroutine def test_setup_api_ping(hass, aioclient_mock): @@ -209,6 +154,26 @@ def test_setup_hassio_no_additional_data(hass, aioclient_mock): assert aioclient_mock.mock_calls[-1][3]['X-HASSIO-KEY'] == "123456" +@asyncio.coroutine +def test_fail_setup_without_environ_var(hass): + """Fail setup if no environ variable set.""" + with patch.dict(os.environ, {}, clear=True): + result = yield from async_setup_component(hass, 'hassio', {}) + assert not result + + +@asyncio.coroutine +def test_fail_setup_cannot_connect(hass): + """Fail setup if cannot connect.""" + with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}), \ + patch('homeassistant.components.hassio.HassIO.is_connected', + Mock(return_value=mock_coro(None))): + result = yield from async_setup_component(hass, 'hassio', {}) + assert not result + + assert not hass.components.hassio.is_hassio() + + @asyncio.coroutine def test_service_register(hassio_env, hass): """Check if service will be setup.""" @@ -276,12 +241,13 @@ def test_service_calls(hassio_env, hass, aioclient_mock): yield from hass.services.async_call('hassio', 'snapshot_partial', { 'addons': ['test'], 'folders': ['ssl'], + 'password': "123456", }) yield from hass.async_block_till_done() assert aioclient_mock.call_count == 8 assert aioclient_mock.mock_calls[-1][2] == { - 'addons': ['test'], 'folders': ['ssl']} + 'addons': ['test'], 'folders': ['ssl'], 'password': "123456"} yield from hass.services.async_call('hassio', 'restore_full', { 'snapshot': 'test', @@ -291,12 +257,15 @@ def test_service_calls(hassio_env, hass, aioclient_mock): 'homeassistant': False, 'addons': ['test'], 'folders': ['ssl'], + 'password': "123456", }) yield from hass.async_block_till_done() assert aioclient_mock.call_count == 10 assert aioclient_mock.mock_calls[-1][2] == { - 'addons': ['test'], 'folders': ['ssl'], 'homeassistant': False} + 'addons': ['test'], 'folders': ['ssl'], 'homeassistant': False, + 'password': "123456" + } @asyncio.coroutine @@ -348,123 +317,3 @@ def test_check_config_fail(hassio_env, hass, aioclient_mock): 'result': 'error', 'message': "Error"}) assert (yield from async_check_config(hass)) == "Error" - - -@asyncio.coroutine -def test_forward_request(hassio_client): - """Test fetching normal path.""" - response = MagicMock() - response.read.return_value = mock_coro('data') - - with patch('homeassistant.components.hassio.HassIO.command_proxy', - Mock(return_value=mock_coro(response))), \ - patch('homeassistant.components.hassio._create_response') as mresp: - mresp.return_value = 'response' - resp = yield from hassio_client.post('/api/hassio/beer', headers={ - HTTP_HEADER_HA_AUTH: API_PASSWORD - }) - - # Check we got right response - assert resp.status == 200 - body = yield from resp.text() - assert body == 'response' - - # Check we forwarded command - assert len(mresp.mock_calls) == 1 - assert mresp.mock_calls[0][1] == (response, 'data') - - -@asyncio.coroutine -def test_auth_required_forward_request(hassio_client): - """Test auth required for normal request.""" - resp = yield from hassio_client.post('/api/hassio/beer') - - # Check we got right response - assert resp.status == 401 - - -@asyncio.coroutine -@pytest.mark.parametrize( - 'build_type', [ - 'es5/index.html', 'es5/hassio-app.html', 'latest/index.html', - 'latest/hassio-app.html' - ]) -def test_forward_request_no_auth_for_panel(hassio_client, build_type): - """Test no auth needed for .""" - response = MagicMock() - response.read.return_value = mock_coro('data') - - with patch('homeassistant.components.hassio.HassIO.command_proxy', - Mock(return_value=mock_coro(response))), \ - patch('homeassistant.components.hassio._create_response') as mresp: - mresp.return_value = 'response' - resp = yield from hassio_client.get( - '/api/hassio/app-{}'.format(build_type)) - - # Check we got right response - assert resp.status == 200 - body = yield from resp.text() - assert body == 'response' - - # Check we forwarded command - assert len(mresp.mock_calls) == 1 - assert mresp.mock_calls[0][1] == (response, 'data') - - -@asyncio.coroutine -def test_forward_request_no_auth_for_logo(hassio_client): - """Test no auth needed for .""" - response = MagicMock() - response.read.return_value = mock_coro('data') - - with patch('homeassistant.components.hassio.HassIO.command_proxy', - Mock(return_value=mock_coro(response))), \ - patch('homeassistant.components.hassio._create_response') as mresp: - mresp.return_value = 'response' - resp = yield from hassio_client.get('/api/hassio/addons/bl_b392/logo') - - # Check we got right response - assert resp.status == 200 - body = yield from resp.text() - assert body == 'response' - - # Check we forwarded command - assert len(mresp.mock_calls) == 1 - assert mresp.mock_calls[0][1] == (response, 'data') - - -@asyncio.coroutine -def test_forward_log_request(hassio_client): - """Test fetching normal log path.""" - response = MagicMock() - response.read.return_value = mock_coro('data') - - with patch('homeassistant.components.hassio.HassIO.command_proxy', - Mock(return_value=mock_coro(response))), \ - patch('homeassistant.components.hassio.' - '_create_response_log') as mresp: - mresp.return_value = 'response' - resp = yield from hassio_client.get('/api/hassio/beer/logs', headers={ - HTTP_HEADER_HA_AUTH: API_PASSWORD - }) - - # Check we got right response - assert resp.status == 200 - body = yield from resp.text() - assert body == 'response' - - # Check we forwarded command - assert len(mresp.mock_calls) == 1 - assert mresp.mock_calls[0][1] == (response, 'data') - - -@asyncio.coroutine -def test_bad_gateway_when_cannot_find_supervisor(hassio_client): - """Test we get a bad gateway error if we can't find supervisor.""" - with patch('homeassistant.components.hassio.async_timeout.timeout', - side_effect=asyncio.TimeoutError): - resp = yield from hassio_client.get( - '/api/hassio/addons/test/info', headers={ - HTTP_HEADER_HA_AUTH: API_PASSWORD - }) - assert resp.status == 502 diff --git a/tests/components/homekit/__init__.py b/tests/components/homekit/__init__.py new file mode 100644 index 00000000000..61a60cee2ac --- /dev/null +++ b/tests/components/homekit/__init__.py @@ -0,0 +1 @@ +"""The tests for the homekit component.""" diff --git a/tests/components/homekit/test_covers.py b/tests/components/homekit/test_covers.py new file mode 100644 index 00000000000..b6e8334346a --- /dev/null +++ b/tests/components/homekit/test_covers.py @@ -0,0 +1,85 @@ +"""Test different accessory types: Covers.""" +import unittest + +from homeassistant.core import callback +from homeassistant.components.cover import ( + ATTR_POSITION, ATTR_CURRENT_POSITION) +from homeassistant.components.homekit.covers import Window +from homeassistant.const import ( + STATE_UNKNOWN, STATE_OPEN, + ATTR_SERVICE, ATTR_SERVICE_DATA, EVENT_CALL_SERVICE) + +from tests.common import get_test_home_assistant + + +class TestHomekitSensors(unittest.TestCase): + """Test class for all accessory types regarding covers.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.events = [] + + @callback + def record_event(event): + """Track called event.""" + self.events.append(event) + + self.hass.bus.listen(EVENT_CALL_SERVICE, record_event) + + def tearDown(self): + """Stop down everthing that was started.""" + self.hass.stop() + + def test_window_set_cover_position(self): + """Test if accessory and HA are updated accordingly.""" + window_cover = 'cover.window' + + acc = Window(self.hass, window_cover, 'Cover') + acc.run() + + self.assertEqual(acc.char_current_position.value, 0) + self.assertEqual(acc.char_target_position.value, 0) + # Temporarily disabled due to bug in HAP-python==1.15 with py3.5 + # self.assertEqual(acc.char_position_state.value, 0) + + self.hass.states.set(window_cover, STATE_UNKNOWN, + {ATTR_CURRENT_POSITION: None}) + self.hass.block_till_done() + + self.assertEqual(acc.char_current_position.value, 0) + self.assertEqual(acc.char_target_position.value, 0) + # Temporarily disabled due to bug in HAP-python==1.15 with py3.5 + # self.assertEqual(acc.char_position_state.value, 0) + + self.hass.states.set(window_cover, STATE_OPEN, + {ATTR_CURRENT_POSITION: 50}) + self.hass.block_till_done() + + self.assertEqual(acc.char_current_position.value, 50) + self.assertEqual(acc.char_target_position.value, 50) + self.assertEqual(acc.char_position_state.value, 2) + + # Set from HomeKit + acc.char_target_position.set_value(25) + self.hass.block_till_done() + self.assertEqual( + self.events[0].data[ATTR_SERVICE], 'set_cover_position') + self.assertEqual( + self.events[0].data[ATTR_SERVICE_DATA][ATTR_POSITION], 25) + + self.assertEqual(acc.char_current_position.value, 50) + self.assertEqual(acc.char_target_position.value, 25) + self.assertEqual(acc.char_position_state.value, 0) + + # Set from HomeKit + acc.char_target_position.set_value(75) + self.hass.block_till_done() + self.assertEqual( + self.events[0].data[ATTR_SERVICE], 'set_cover_position') + self.assertEqual( + self.events[1].data[ATTR_SERVICE_DATA][ATTR_POSITION], 75) + + self.assertEqual(acc.char_current_position.value, 50) + self.assertEqual(acc.char_target_position.value, 75) + self.assertEqual(acc.char_position_state.value, 1) diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py new file mode 100644 index 00000000000..e20e87871b8 --- /dev/null +++ b/tests/components/homekit/test_get_accessories.py @@ -0,0 +1,46 @@ +"""Package to test the get_accessory method.""" +from unittest.mock import patch, MagicMock + +from homeassistant.core import State +from homeassistant.components.homekit import ( + TYPES, get_accessory, import_types) +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, ATTR_SUPPORTED_FEATURES, + TEMP_CELSIUS, STATE_UNKNOWN) + + +def test_import_types(): + """Test if all type files are imported correctly.""" + try: + import_types() + assert True + # pylint: disable=broad-except + except Exception: + assert False + + +def test_component_not_supported(): + """Test with unsupported component.""" + state = State('demo.unsupported', STATE_UNKNOWN) + + assert True if get_accessory(None, state) is None else False + + +def test_sensor_temperatur_celsius(): + """Test temperature sensor with celsius as unit.""" + mock_type = MagicMock() + with patch.dict(TYPES, {'TemperatureSensor': mock_type}): + state = State('sensor.temperatur', '23', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + get_accessory(None, state) + assert len(mock_type.mock_calls) == 1 + + +def test_cover_set_position(): + """Test cover with support for set_cover_position.""" + mock_type = MagicMock() + with patch.dict(TYPES, {'Window': mock_type}): + state = State('cover.setposition', 'open', + {ATTR_SUPPORTED_FEATURES: 4}) + get_accessory(None, state) + assert len(mock_type.mock_calls) == 1 diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py new file mode 100644 index 00000000000..06cb8096140 --- /dev/null +++ b/tests/components/homekit/test_homekit.py @@ -0,0 +1,124 @@ +"""Tests for the homekit component.""" + +import unittest +from unittest.mock import patch + +import voluptuous as vol + +from homeassistant import setup +from homeassistant.core import Event +from homeassistant.components.homekit import ( + CONF_PIN_CODE, BRIDGE_NAME, Homekit, valid_pin) +from homeassistant.components.homekit.covers import Window +from homeassistant.components.homekit.sensors import TemperatureSensor +from homeassistant.const import ( + CONF_PORT, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) + +from tests.common import get_test_home_assistant + +HOMEKIT_PATH = 'homeassistant.components.homekit' + +CONFIG_MIN = {'homekit': {}} +CONFIG = { + 'homekit': { + CONF_PORT: 11111, + CONF_PIN_CODE: '987-65-432', + } +} + + +class TestHomekit(unittest.TestCase): + """Test the Multicover component.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def tearDown(self): + """Stop down everthing that was started.""" + self.hass.stop() + + @patch(HOMEKIT_PATH + '.Homekit.start_driver') + @patch(HOMEKIT_PATH + '.Homekit.setup_bridge') + @patch(HOMEKIT_PATH + '.Homekit.__init__') + def test_setup_min(self, mock_homekit, mock_setup_bridge, + mock_start_driver): + """Test async_setup with minimal config option.""" + mock_homekit.return_value = None + + self.assertTrue(setup.setup_component( + self.hass, 'homekit', CONFIG_MIN)) + + mock_homekit.assert_called_once_with(self.hass, 51826) + mock_setup_bridge.assert_called_with(b'123-45-678') + mock_start_driver.assert_not_called() + + self.hass.start() + self.hass.block_till_done() + self.assertEqual(mock_start_driver.call_count, 1) + + @patch(HOMEKIT_PATH + '.Homekit.start_driver') + @patch(HOMEKIT_PATH + '.Homekit.setup_bridge') + @patch(HOMEKIT_PATH + '.Homekit.__init__') + def test_setup_parameters(self, mock_homekit, mock_setup_bridge, + mock_start_driver): + """Test async_setup with full config option.""" + mock_homekit.return_value = None + + self.assertTrue(setup.setup_component( + self.hass, 'homekit', CONFIG)) + + mock_homekit.assert_called_once_with(self.hass, 11111) + mock_setup_bridge.assert_called_with(b'987-65-432') + + def test_validate_pincode(self): + """Test async_setup with invalid config option.""" + schema = vol.Schema(valid_pin) + + for value in ('', '123-456-78', 'a23-45-678', '12345678'): + with self.assertRaises(vol.MultipleInvalid): + schema(value) + + for value in ('123-45-678', '234-56-789'): + self.assertTrue(schema(value)) + + @patch('pyhap.accessory_driver.AccessoryDriver.persist') + @patch('pyhap.accessory_driver.AccessoryDriver.stop') + @patch('pyhap.accessory_driver.AccessoryDriver.start') + @patch(HOMEKIT_PATH + '.import_types') + @patch(HOMEKIT_PATH + '.get_accessory') + def test_homekit_pyhap_interaction( + self, mock_get_accessory, mock_import_types, + mock_driver_start, mock_driver_stop, mock_file_persist): + """Test the interaction between the homekit class and pyhap.""" + acc1 = TemperatureSensor(self.hass, 'sensor.temp', 'Temperature') + acc2 = Window(self.hass, 'cover.hall_window', 'Cover') + mock_get_accessory.side_effect = [acc1, acc2] + + homekit = Homekit(self.hass, 51826) + homekit.setup_bridge(b'123-45-678') + + self.assertEqual(homekit.bridge.display_name, BRIDGE_NAME) + + self.hass.states.set('demo.demo1', 'on') + self.hass.states.set('demo.demo2', 'off') + + self.hass.start() + self.hass.block_till_done() + + homekit.start_driver(Event(EVENT_HOMEASSISTANT_START)) + + self.assertEqual(mock_get_accessory.call_count, 2) + self.assertEqual(mock_import_types.call_count, 1) + self.assertEqual(mock_driver_start.call_count, 1) + + accessories = homekit.bridge.accessories + self.assertEqual(accessories[2], acc1) + self.assertEqual(accessories[3], acc2) + + mock_driver_stop.assert_not_called() + + self.hass.bus.fire(EVENT_HOMEASSISTANT_STOP) + self.hass.block_till_done() + + self.assertEqual(mock_driver_stop.call_count, 1) diff --git a/tests/components/homekit/test_sensors.py b/tests/components/homekit/test_sensors.py new file mode 100644 index 00000000000..b7d3de4e90b --- /dev/null +++ b/tests/components/homekit/test_sensors.py @@ -0,0 +1,37 @@ +"""Test different accessory types: Sensors.""" +import unittest + +from homeassistant.components.homekit.sensors import TemperatureSensor +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, STATE_UNKNOWN) + +from tests.common import get_test_home_assistant + + +class TestHomekitSensors(unittest.TestCase): + """Test class for all accessory types regarding sensors.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def tearDown(self): + """Stop down everthing that was started.""" + self.hass.stop() + + def test_temperature_celsius(self): + """Test if accessory is updated after state change.""" + temperature_sensor = 'sensor.temperature' + + acc = TemperatureSensor(self.hass, temperature_sensor, 'Temperature') + acc.run() + + self.assertEqual(acc.char_temp.value, 0.0) + + self.hass.states.set(temperature_sensor, STATE_UNKNOWN, + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.block_till_done() + + self.hass.states.set(temperature_sensor, '20') + self.hass.block_till_done() + self.assertEqual(acc.char_temp.value, 20) diff --git a/tests/components/http/__init__.py b/tests/components/http/__init__.py index 869e80fff75..ef9817a2f1b 100644 --- a/tests/components/http/__init__.py +++ b/tests/components/http/__init__.py @@ -1 +1,38 @@ """Tests for the HTTP component.""" +import asyncio +from ipaddress import ip_address + +from aiohttp import web + +from homeassistant.components.http.const import KEY_REAL_IP + + +def mock_real_ip(app): + """Inject middleware to mock real IP. + + Returns a function to set the real IP. + """ + ip_to_mock = None + + def set_ip_to_mock(value): + nonlocal ip_to_mock + ip_to_mock = value + + @asyncio.coroutine + @web.middleware + def mock_real_ip(request, handler): + """Mock Real IP middleware.""" + nonlocal ip_to_mock + + request[KEY_REAL_IP] = ip_address(ip_to_mock) + + return (yield from handler(request)) + + @asyncio.coroutine + def real_ip_startup(app): + """Startup of real ip.""" + app.middlewares.insert(0, mock_real_ip) + + app.on_startup.append(real_ip_startup) + + return set_ip_to_mock diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index ef9c63ad09e..c2687c05a8f 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -1,195 +1,156 @@ """The tests for the Home Assistant HTTP component.""" # pylint: disable=protected-access import asyncio -from ipaddress import ip_address, ip_network +from ipaddress import ip_network from unittest.mock import patch -import aiohttp +from aiohttp import BasicAuth, web +from aiohttp.web_exceptions import HTTPUnauthorized import pytest -from homeassistant import const +from homeassistant.const import HTTP_HEADER_HA_AUTH from homeassistant.setup import async_setup_component -import homeassistant.components.http as http -from homeassistant.components.http.const import ( - KEY_TRUSTED_NETWORKS, KEY_USE_X_FORWARDED_FOR, HTTP_HEADER_X_FORWARDED_FOR) +from homeassistant.components.http.auth import setup_auth +from homeassistant.components.http.real_ip import setup_real_ip +from homeassistant.components.http.const import KEY_AUTHENTICATED + +from . import mock_real_ip API_PASSWORD = 'test1234' # Don't add 127.0.0.1/::1 as trusted, as it may interfere with other test cases -TRUSTED_NETWORKS = ['192.0.2.0/24', '2001:DB8:ABCD::/48', '100.64.0.1', - 'FD01:DB8::1'] +TRUSTED_NETWORKS = [ + ip_network('192.0.2.0/24'), + ip_network('2001:DB8:ABCD::/48'), + ip_network('100.64.0.1'), + ip_network('FD01:DB8::1'), +] TRUSTED_ADDRESSES = ['100.64.0.1', '192.0.2.100', 'FD01:DB8::1', '2001:DB8:ABCD::1'] UNTRUSTED_ADDRESSES = ['198.51.100.1', '2001:DB8:FA1::1', '127.0.0.1', '::1'] -@pytest.fixture -def mock_api_client(hass, test_client): - """Start the Hass HTTP component.""" - hass.loop.run_until_complete(async_setup_component(hass, 'api', { - 'http': { - http.CONF_API_PASSWORD: API_PASSWORD, - } - })) - return hass.loop.run_until_complete(test_client(hass.http.app)) +@asyncio.coroutine +def mock_handler(request): + """Return if request was authenticated.""" + if not request[KEY_AUTHENTICATED]: + raise HTTPUnauthorized + return web.Response(status=200) @pytest.fixture -def mock_trusted_networks(hass, mock_api_client): - """Mock trusted networks.""" - hass.http.app[KEY_TRUSTED_NETWORKS] = [ - ip_network(trusted_network) - for trusted_network in TRUSTED_NETWORKS] +def app(): + """Fixture to setup a web.Application.""" + app = web.Application() + app.router.add_get('/', mock_handler) + setup_real_ip(app, False) + return app @asyncio.coroutine -def test_access_denied_without_password(mock_api_client): +def test_auth_middleware_loaded_by_default(hass): + """Test accessing to server from banned IP when feature is off.""" + with patch('homeassistant.components.http.setup_auth') as mock_setup: + yield from async_setup_component(hass, 'http', { + 'http': {} + }) + + assert len(mock_setup.mock_calls) == 1 + + +@asyncio.coroutine +def test_access_without_password(app, test_client): """Test access without password.""" - resp = yield from mock_api_client.get(const.URL_API) + setup_auth(app, [], None) + client = yield from test_client(app) + + resp = yield from client.get('/') + assert resp.status == 200 + + +@asyncio.coroutine +def test_access_with_password_in_header(app, test_client): + """Test access with password in URL.""" + setup_auth(app, [], API_PASSWORD) + client = yield from test_client(app) + + req = yield from client.get( + '/', headers={HTTP_HEADER_HA_AUTH: API_PASSWORD}) + assert req.status == 200 + + req = yield from client.get( + '/', headers={HTTP_HEADER_HA_AUTH: 'wrong-pass'}) + assert req.status == 401 + + +@asyncio.coroutine +def test_access_with_password_in_query(app, test_client): + """Test access without password.""" + setup_auth(app, [], API_PASSWORD) + client = yield from test_client(app) + + resp = yield from client.get('/', params={ + 'api_password': API_PASSWORD + }) + assert resp.status == 200 + + resp = yield from client.get('/') assert resp.status == 401 - -@asyncio.coroutine -def test_access_denied_with_wrong_password_in_header(mock_api_client): - """Test access with wrong password.""" - resp = yield from mock_api_client.get(const.URL_API, headers={ - const.HTTP_HEADER_HA_AUTH: 'wrongpassword' + resp = yield from client.get('/', params={ + 'api_password': 'wrong-password' }) assert resp.status == 401 @asyncio.coroutine -def test_access_denied_with_x_forwarded_for(hass, mock_api_client, - mock_trusted_networks): - """Test access denied through the X-Forwarded-For http header.""" - hass.http.use_x_forwarded_for = True - for remote_addr in UNTRUSTED_ADDRESSES: - resp = yield from mock_api_client.get(const.URL_API, headers={ - HTTP_HEADER_X_FORWARDED_FOR: remote_addr}) - - assert resp.status == 401, \ - "{} shouldn't be trusted".format(remote_addr) - - -@asyncio.coroutine -def test_access_denied_with_untrusted_ip(mock_api_client, - mock_trusted_networks): - """Test access with an untrusted ip address.""" - for remote_addr in UNTRUSTED_ADDRESSES: - with patch('homeassistant.components.http.' - 'util.get_real_ip', - return_value=ip_address(remote_addr)): - resp = yield from mock_api_client.get( - const.URL_API, params={'api_password': ''}) - - assert resp.status == 401, \ - "{} shouldn't be trusted".format(remote_addr) - - -@asyncio.coroutine -def test_access_with_password_in_header(mock_api_client, caplog): - """Test access with password in URL.""" - # Hide logging from requests package that we use to test logging - req = yield from mock_api_client.get( - const.URL_API, headers={const.HTTP_HEADER_HA_AUTH: API_PASSWORD}) - - assert req.status == 200 - - logs = caplog.text - - assert const.URL_API in logs - assert API_PASSWORD not in logs - - -@asyncio.coroutine -def test_access_denied_with_wrong_password_in_url(mock_api_client): - """Test access with wrong password.""" - resp = yield from mock_api_client.get( - const.URL_API, params={'api_password': 'wrongpassword'}) - - assert resp.status == 401 - - -@asyncio.coroutine -def test_access_with_password_in_url(mock_api_client, caplog): - """Test access with password in URL.""" - req = yield from mock_api_client.get( - const.URL_API, params={'api_password': API_PASSWORD}) - - assert req.status == 200 - - logs = caplog.text - - assert const.URL_API in logs - assert API_PASSWORD not in logs - - -@asyncio.coroutine -def test_access_granted_with_x_forwarded_for(hass, mock_api_client, caplog, - mock_trusted_networks): - """Test access denied through the X-Forwarded-For http header.""" - hass.http.app[KEY_USE_X_FORWARDED_FOR] = True - for remote_addr in TRUSTED_ADDRESSES: - resp = yield from mock_api_client.get(const.URL_API, headers={ - HTTP_HEADER_X_FORWARDED_FOR: remote_addr}) - - assert resp.status == 200, \ - "{} should be trusted".format(remote_addr) - - -@asyncio.coroutine -def test_access_granted_with_trusted_ip(mock_api_client, caplog, - mock_trusted_networks): - """Test access with trusted addresses.""" - for remote_addr in TRUSTED_ADDRESSES: - with patch('homeassistant.components.http.' - 'auth.get_real_ip', - return_value=ip_address(remote_addr)): - resp = yield from mock_api_client.get( - const.URL_API, params={'api_password': ''}) - - assert resp.status == 200, \ - '{} should be trusted'.format(remote_addr) - - -@asyncio.coroutine -def test_basic_auth_works(mock_api_client, caplog): +def test_basic_auth_works(app, test_client): """Test access with basic authentication.""" - req = yield from mock_api_client.get( - const.URL_API, - auth=aiohttp.BasicAuth('homeassistant', API_PASSWORD)) + setup_auth(app, [], API_PASSWORD) + client = yield from test_client(app) + req = yield from client.get( + '/', + auth=BasicAuth('homeassistant', API_PASSWORD)) assert req.status == 200 - assert const.URL_API in caplog.text - - -@asyncio.coroutine -def test_basic_auth_username_homeassistant(mock_api_client, caplog): - """Test access with basic auth requires username homeassistant.""" - req = yield from mock_api_client.get( - const.URL_API, - auth=aiohttp.BasicAuth('wrong_username', API_PASSWORD)) + req = yield from client.get( + '/', + auth=BasicAuth('wrong_username', API_PASSWORD)) assert req.status == 401 - -@asyncio.coroutine -def test_basic_auth_wrong_password(mock_api_client, caplog): - """Test access with basic auth not allowed with wrong password.""" - req = yield from mock_api_client.get( - const.URL_API, - auth=aiohttp.BasicAuth('homeassistant', 'wrong password')) - + req = yield from client.get( + '/', + auth=BasicAuth('homeassistant', 'wrong password')) assert req.status == 401 - -@asyncio.coroutine -def test_authorization_header_must_be_basic_type(mock_api_client, caplog): - """Test only basic authorization is allowed for auth header.""" - req = yield from mock_api_client.get( - const.URL_API, + req = yield from client.get( + '/', headers={ 'authorization': 'NotBasic abcdefg' }) - assert req.status == 401 + + +@asyncio.coroutine +def test_access_with_trusted_ip(test_client): + """Test access with an untrusted ip address.""" + app = web.Application() + app.router.add_get('/', mock_handler) + + setup_auth(app, TRUSTED_NETWORKS, 'some-pass') + + set_mock_ip = mock_real_ip(app) + client = yield from test_client(app) + + for remote_addr in UNTRUSTED_ADDRESSES: + set_mock_ip(remote_addr) + resp = yield from client.get('/') + assert resp.status == 401, \ + "{} shouldn't be trusted".format(remote_addr) + + for remote_addr in TRUSTED_ADDRESSES: + set_mock_ip(remote_addr) + resp = yield from client.get('/') + assert resp.status == 200, \ + "{} should be trusted".format(remote_addr) diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index c9147367c10..bd6df4f4e73 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -1,91 +1,96 @@ """The tests for the Home Assistant HTTP component.""" # pylint: disable=protected-access import asyncio -from ipaddress import ip_address from unittest.mock import patch, mock_open -import pytest +from aiohttp import web +from aiohttp.web_exceptions import HTTPUnauthorized -from homeassistant import const from homeassistant.setup import async_setup_component import homeassistant.components.http as http -from homeassistant.components.http.const import ( - KEY_BANS_ENABLED, KEY_LOGIN_THRESHOLD, KEY_BANNED_IPS) -from homeassistant.components.http.ban import IpBan, IP_BANS_FILE +from homeassistant.components.http.ban import ( + IpBan, IP_BANS_FILE, setup_bans, KEY_BANNED_IPS) + +from . import mock_real_ip -API_PASSWORD = 'test1234' BANNED_IPS = ['200.201.202.203', '100.64.0.2'] -@pytest.fixture -def mock_api_client(hass, test_client): - """Start the Hass HTTP component.""" - hass.loop.run_until_complete(async_setup_component(hass, 'api', { - 'http': { - http.CONF_API_PASSWORD: API_PASSWORD, - } - })) - hass.http.app[KEY_BANNED_IPS] = [IpBan(banned_ip) for banned_ip - in BANNED_IPS] - return hass.loop.run_until_complete(test_client(hass.http.app)) - - @asyncio.coroutine -def test_access_from_banned_ip(hass, mock_api_client): +def test_access_from_banned_ip(hass, test_client): """Test accessing to server from banned IP. Both trusted and not.""" - hass.http.app[KEY_BANS_ENABLED] = True + app = web.Application() + setup_bans(hass, app, 5) + set_real_ip = mock_real_ip(app) + + with patch('homeassistant.components.http.ban.load_ip_bans_config', + return_value=[IpBan(banned_ip) for banned_ip + in BANNED_IPS]): + client = yield from test_client(app) + for remote_addr in BANNED_IPS: - with patch('homeassistant.components.http.' - 'ban.get_real_ip', - return_value=ip_address(remote_addr)): - resp = yield from mock_api_client.get( - const.URL_API) - assert resp.status == 403 + set_real_ip(remote_addr) + resp = yield from client.get('/') + assert resp.status == 403 @asyncio.coroutine -def test_access_from_banned_ip_when_ban_is_off(hass, mock_api_client): +def test_ban_middleware_not_loaded_by_config(hass): """Test accessing to server from banned IP when feature is off.""" - hass.http.app[KEY_BANS_ENABLED] = False - for remote_addr in BANNED_IPS: - with patch('homeassistant.components.http.' - 'ban.get_real_ip', - return_value=ip_address(remote_addr)): - resp = yield from mock_api_client.get( - const.URL_API, - headers={const.HTTP_HEADER_HA_AUTH: API_PASSWORD}) - assert resp.status == 200 + with patch('homeassistant.components.http.setup_bans') as mock_setup: + yield from async_setup_component(hass, 'http', { + 'http': { + http.CONF_IP_BAN_ENABLED: False, + } + }) + + assert len(mock_setup.mock_calls) == 0 @asyncio.coroutine -def test_ip_bans_file_creation(hass, mock_api_client): +def test_ban_middleware_loaded_by_default(hass): + """Test accessing to server from banned IP when feature is off.""" + with patch('homeassistant.components.http.setup_bans') as mock_setup: + yield from async_setup_component(hass, 'http', { + 'http': {} + }) + + assert len(mock_setup.mock_calls) == 1 + + +@asyncio.coroutine +def test_ip_bans_file_creation(hass, test_client): """Testing if banned IP file created.""" - hass.http.app[KEY_BANS_ENABLED] = True - hass.http.app[KEY_LOGIN_THRESHOLD] = 1 + app = web.Application() + app['hass'] = hass + + @asyncio.coroutine + def unauth_handler(request): + """Return a mock web response.""" + raise HTTPUnauthorized + + app.router.add_get('/', unauth_handler) + setup_bans(hass, app, 1) + mock_real_ip(app)("200.201.202.204") + + with patch('homeassistant.components.http.ban.load_ip_bans_config', + return_value=[IpBan(banned_ip) for banned_ip + in BANNED_IPS]): + client = yield from test_client(app) m = mock_open() - @asyncio.coroutine - def call_server(): - with patch('homeassistant.components.http.' - 'ban.get_real_ip', - return_value=ip_address("200.201.202.204")): - resp = yield from mock_api_client.get( - const.URL_API, - headers={const.HTTP_HEADER_HA_AUTH: 'Wrong password'}) - return resp - with patch('homeassistant.components.http.ban.open', m, create=True): - resp = yield from call_server() + resp = yield from client.get('/') assert resp.status == 401 - assert len(hass.http.app[KEY_BANNED_IPS]) == len(BANNED_IPS) + assert len(app[KEY_BANNED_IPS]) == len(BANNED_IPS) assert m.call_count == 0 - resp = yield from call_server() + resp = yield from client.get('/') assert resp.status == 401 - assert len(hass.http.app[KEY_BANNED_IPS]) == len(BANNED_IPS) + 1 + assert len(app[KEY_BANNED_IPS]) == len(BANNED_IPS) + 1 m.assert_called_once_with(hass.config.path(IP_BANS_FILE), 'a') - resp = yield from call_server() + resp = yield from client.get('/') assert resp.status == 403 assert m.call_count == 1 diff --git a/tests/components/http/test_cors.py b/tests/components/http/test_cors.py new file mode 100644 index 00000000000..22b70e1c0c5 --- /dev/null +++ b/tests/components/http/test_cors.py @@ -0,0 +1,104 @@ +"""Test cors for the HTTP component.""" +import asyncio +from unittest.mock import patch + +from aiohttp import web +from aiohttp.hdrs import ( + ACCESS_CONTROL_ALLOW_ORIGIN, + ACCESS_CONTROL_ALLOW_HEADERS, + ACCESS_CONTROL_REQUEST_HEADERS, + ACCESS_CONTROL_REQUEST_METHOD, + ORIGIN +) +import pytest + +from homeassistant.const import HTTP_HEADER_HA_AUTH +from homeassistant.setup import async_setup_component +from homeassistant.components.http.cors import setup_cors + + +TRUSTED_ORIGIN = 'https://home-assistant.io' + + +@asyncio.coroutine +def test_cors_middleware_not_loaded_by_default(hass): + """Test accessing to server from banned IP when feature is off.""" + with patch('homeassistant.components.http.setup_cors') as mock_setup: + yield from async_setup_component(hass, 'http', { + 'http': {} + }) + + assert len(mock_setup.mock_calls) == 0 + + +@asyncio.coroutine +def test_cors_middleware_loaded_from_config(hass): + """Test accessing to server from banned IP when feature is off.""" + with patch('homeassistant.components.http.setup_cors') as mock_setup: + yield from async_setup_component(hass, 'http', { + 'http': { + 'cors_allowed_origins': ['http://home-assistant.io'] + } + }) + + assert len(mock_setup.mock_calls) == 1 + + +@asyncio.coroutine +def mock_handler(request): + """Return if request was authenticated.""" + return web.Response(status=200) + + +@pytest.fixture +def client(loop, test_client): + """Fixture to setup a web.Application.""" + app = web.Application() + app.router.add_get('/', mock_handler) + setup_cors(app, [TRUSTED_ORIGIN]) + return loop.run_until_complete(test_client(app)) + + +@asyncio.coroutine +def test_cors_requests(client): + """Test cross origin requests.""" + req = yield from client.get('/', headers={ + ORIGIN: TRUSTED_ORIGIN + }) + assert req.status == 200 + assert req.headers[ACCESS_CONTROL_ALLOW_ORIGIN] == \ + TRUSTED_ORIGIN + + # With password in URL + req = yield from client.get('/', params={ + 'api_password': 'some-pass' + }, headers={ + ORIGIN: TRUSTED_ORIGIN + }) + assert req.status == 200 + assert req.headers[ACCESS_CONTROL_ALLOW_ORIGIN] == \ + TRUSTED_ORIGIN + + # With password in headers + req = yield from client.get('/', headers={ + HTTP_HEADER_HA_AUTH: 'some-pass', + ORIGIN: TRUSTED_ORIGIN + }) + assert req.status == 200 + assert req.headers[ACCESS_CONTROL_ALLOW_ORIGIN] == \ + TRUSTED_ORIGIN + + +@asyncio.coroutine +def test_cors_preflight_allowed(client): + """Test cross origin resource sharing preflight (OPTIONS) request.""" + req = yield from client.options('/', headers={ + ORIGIN: TRUSTED_ORIGIN, + ACCESS_CONTROL_REQUEST_METHOD: 'GET', + ACCESS_CONTROL_REQUEST_HEADERS: 'x-ha-access' + }) + + assert req.status == 200 + assert req.headers[ACCESS_CONTROL_ALLOW_ORIGIN] == TRUSTED_ORIGIN + assert req.headers[ACCESS_CONTROL_ALLOW_HEADERS] == \ + HTTP_HEADER_HA_AUTH.upper() diff --git a/tests/components/http/test_data_validator.py b/tests/components/http/test_data_validator.py new file mode 100644 index 00000000000..f00be4fc6f9 --- /dev/null +++ b/tests/components/http/test_data_validator.py @@ -0,0 +1,77 @@ +"""Test data validator decorator.""" +import asyncio +from unittest.mock import Mock + +from aiohttp import web +import voluptuous as vol + +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http.data_validator import RequestDataValidator + + +@asyncio.coroutine +def get_client(test_client, validator): + """Generate a client that hits a view decorated with validator.""" + app = web.Application() + app['hass'] = Mock(is_running=True) + + class TestView(HomeAssistantView): + url = '/' + name = 'test' + requires_auth = False + + @asyncio.coroutine + @validator + def post(self, request, data): + """Test method.""" + return b'' + + TestView().register(app.router) + client = yield from test_client(app) + return client + + +@asyncio.coroutine +def test_validator(test_client): + """Test the validator.""" + client = yield from get_client( + test_client, RequestDataValidator(vol.Schema({ + vol.Required('test'): str + }))) + + resp = yield from client.post('/', json={ + 'test': 'bla' + }) + assert resp.status == 200 + + resp = yield from client.post('/', json={ + 'test': 100 + }) + assert resp.status == 400 + + resp = yield from client.post('/') + assert resp.status == 400 + + +@asyncio.coroutine +def test_validator_allow_empty(test_client): + """Test the validator with empty data.""" + client = yield from get_client( + test_client, RequestDataValidator(vol.Schema({ + # Although we allow empty, our schema should still be able + # to validate an empty dict. + vol.Optional('test'): str + }), allow_empty=True)) + + resp = yield from client.post('/', json={ + 'test': 'bla' + }) + assert resp.status == 200 + + resp = yield from client.post('/', json={ + 'test': 100 + }) + assert resp.status == 400 + + resp = yield from client.post('/') + assert resp.status == 200 diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 4ff87efd137..ab06b48043e 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -1,124 +1,10 @@ """The tests for the Home Assistant HTTP component.""" import asyncio -from aiohttp.hdrs import ( - ORIGIN, ACCESS_CONTROL_ALLOW_ORIGIN, ACCESS_CONTROL_ALLOW_HEADERS, - ACCESS_CONTROL_REQUEST_METHOD, ACCESS_CONTROL_REQUEST_HEADERS, - CONTENT_TYPE) -import requests -from tests.common import get_test_instance_port, get_test_home_assistant +from homeassistant.setup import async_setup_component -from homeassistant import const, setup import homeassistant.components.http as http -API_PASSWORD = 'test1234' -SERVER_PORT = get_test_instance_port() -HTTP_BASE = '127.0.0.1:{}'.format(SERVER_PORT) -HTTP_BASE_URL = 'http://{}'.format(HTTP_BASE) -HA_HEADERS = { - const.HTTP_HEADER_HA_AUTH: API_PASSWORD, - CONTENT_TYPE: const.CONTENT_TYPE_JSON, -} -CORS_ORIGINS = [HTTP_BASE_URL, HTTP_BASE] - -hass = None - - -def _url(path=''): - """Helper method to generate URLs.""" - return HTTP_BASE_URL + path - - -# pylint: disable=invalid-name -def setUpModule(): - """Initialize a Home Assistant server.""" - global hass - - hass = get_test_home_assistant() - - setup.setup_component( - hass, http.DOMAIN, { - http.DOMAIN: { - http.CONF_API_PASSWORD: API_PASSWORD, - http.CONF_SERVER_PORT: SERVER_PORT, - http.CONF_CORS_ORIGINS: CORS_ORIGINS, - } - } - ) - - setup.setup_component(hass, 'api') - - # Registering static path as it caused CORS to blow up - hass.http.register_static_path( - '/custom_components', hass.config.path('custom_components')) - - hass.start() - - -# pylint: disable=invalid-name -def tearDownModule(): - """Stop the Home Assistant server.""" - hass.stop() - - -class TestCors: - """Test HTTP component.""" - - def test_cors_allowed_with_password_in_url(self): - """Test cross origin resource sharing with password in url.""" - req = requests.get(_url(const.URL_API), - params={'api_password': API_PASSWORD}, - headers={ORIGIN: HTTP_BASE_URL}) - - allow_origin = ACCESS_CONTROL_ALLOW_ORIGIN - - assert req.status_code == 200 - assert req.headers.get(allow_origin) == HTTP_BASE_URL - - def test_cors_allowed_with_password_in_header(self): - """Test cross origin resource sharing with password in header.""" - headers = { - const.HTTP_HEADER_HA_AUTH: API_PASSWORD, - ORIGIN: HTTP_BASE_URL - } - req = requests.get(_url(const.URL_API), headers=headers) - - allow_origin = ACCESS_CONTROL_ALLOW_ORIGIN - - assert req.status_code == 200 - assert req.headers.get(allow_origin) == HTTP_BASE_URL - - def test_cors_denied_without_origin_header(self): - """Test cross origin resource sharing with password in header.""" - headers = { - const.HTTP_HEADER_HA_AUTH: API_PASSWORD - } - req = requests.get(_url(const.URL_API), headers=headers) - - allow_origin = ACCESS_CONTROL_ALLOW_ORIGIN - allow_headers = ACCESS_CONTROL_ALLOW_HEADERS - - assert req.status_code == 200 - assert allow_origin not in req.headers - assert allow_headers not in req.headers - - def test_cors_preflight_allowed(self): - """Test cross origin resource sharing preflight (OPTIONS) request.""" - headers = { - ORIGIN: HTTP_BASE_URL, - ACCESS_CONTROL_REQUEST_METHOD: 'GET', - ACCESS_CONTROL_REQUEST_HEADERS: 'x-ha-access' - } - req = requests.options(_url(const.URL_API), headers=headers) - - allow_origin = ACCESS_CONTROL_ALLOW_ORIGIN - allow_headers = ACCESS_CONTROL_ALLOW_HEADERS - - assert req.status_code == 200 - assert req.headers.get(allow_origin) == HTTP_BASE_URL - assert req.headers.get(allow_headers) == \ - const.HTTP_HEADER_HA_AUTH.upper() - class TestView(http.HomeAssistantView): """Test the HTTP views.""" @@ -133,12 +19,12 @@ class TestView(http.HomeAssistantView): @asyncio.coroutine -def test_registering_view_while_running(hass, test_client): +def test_registering_view_while_running(hass, test_client, unused_port): """Test that we can register a view while the server is running.""" - yield from setup.async_setup_component( + yield from async_setup_component( hass, http.DOMAIN, { http.DOMAIN: { - http.CONF_SERVER_PORT: get_test_instance_port(), + http.CONF_SERVER_PORT: unused_port(), } } ) @@ -151,7 +37,7 @@ def test_registering_view_while_running(hass, test_client): @asyncio.coroutine def test_api_base_url_with_domain(hass): """Test setting API URL.""" - result = yield from setup.async_setup_component(hass, 'http', { + result = yield from async_setup_component(hass, 'http', { 'http': { 'base_url': 'example.com' } @@ -163,7 +49,7 @@ def test_api_base_url_with_domain(hass): @asyncio.coroutine def test_api_base_url_with_ip(hass): """Test setting api url.""" - result = yield from setup.async_setup_component(hass, 'http', { + result = yield from async_setup_component(hass, 'http', { 'http': { 'server_host': '1.1.1.1' } @@ -175,7 +61,7 @@ def test_api_base_url_with_ip(hass): @asyncio.coroutine def test_api_base_url_with_ip_port(hass): """Test setting api url.""" - result = yield from setup.async_setup_component(hass, 'http', { + result = yield from async_setup_component(hass, 'http', { 'http': { 'base_url': '1.1.1.1:8124' } @@ -187,9 +73,34 @@ def test_api_base_url_with_ip_port(hass): @asyncio.coroutine def test_api_no_base_url(hass): """Test setting api url.""" - result = yield from setup.async_setup_component(hass, 'http', { + result = yield from async_setup_component(hass, 'http', { 'http': { } }) assert result assert hass.config.api.base_url == 'http://127.0.0.1:8123' + + +@asyncio.coroutine +def test_not_log_password(hass, unused_port, test_client, caplog): + """Test access with password doesn't get logged.""" + result = yield from async_setup_component(hass, 'api', { + 'http': { + http.CONF_SERVER_PORT: unused_port(), + http.CONF_API_PASSWORD: 'some-pass' + } + }) + assert result + + client = yield from test_client(hass.http.app) + + resp = yield from client.get('/api/', params={ + 'api_password': 'some-pass' + }) + + assert resp.status == 200 + logs = caplog.text + + # Ensure we don't log API passwords + assert '/api/' in logs + assert 'some-pass' not in logs diff --git a/tests/components/http/test_real_ip.py b/tests/components/http/test_real_ip.py new file mode 100644 index 00000000000..90201ab4c10 --- /dev/null +++ b/tests/components/http/test_real_ip.py @@ -0,0 +1,48 @@ +"""Test real IP middleware.""" +import asyncio + +from aiohttp import web +from aiohttp.hdrs import X_FORWARDED_FOR + +from homeassistant.components.http.real_ip import setup_real_ip +from homeassistant.components.http.const import KEY_REAL_IP + + +@asyncio.coroutine +def mock_handler(request): + """Handler that returns the real IP as text.""" + return web.Response(text=str(request[KEY_REAL_IP])) + + +@asyncio.coroutine +def test_ignore_x_forwarded_for(test_client): + """Test that we get the IP from the transport.""" + app = web.Application() + app.router.add_get('/', mock_handler) + setup_real_ip(app, False) + + mock_api_client = yield from test_client(app) + + resp = yield from mock_api_client.get('/', headers={ + X_FORWARDED_FOR: '255.255.255.255' + }) + assert resp.status == 200 + text = yield from resp.text() + assert text != '255.255.255.255' + + +@asyncio.coroutine +def test_use_x_forwarded_for(test_client): + """Test that we get the IP from the transport.""" + app = web.Application() + app.router.add_get('/', mock_handler) + setup_real_ip(app, True) + + mock_api_client = yield from test_client(app) + + resp = yield from mock_api_client.get('/', headers={ + X_FORWARDED_FOR: '255.255.255.255' + }) + assert resp.status == 200 + text = yield from resp.text() + assert text == '255.255.255.255' diff --git a/tests/components/light/test_mqtt.py b/tests/components/light/test_mqtt.py index 7ef33aad2d9..6c56564df69 100644 --- a/tests/components/light/test_mqtt.py +++ b/tests/components/light/test_mqtt.py @@ -492,16 +492,18 @@ class TestLightMQTT(unittest.TestCase): light.turn_on(self.hass, 'light.test') self.hass.block_till_done() - self.assertEqual(('test_light_rgb/set', 'on', 2, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'test_light_rgb/set', 'on', 2, False) + self.mock_publish.async_publish.reset_mock() state = self.hass.states.get('light.test') self.assertEqual(STATE_ON, state.state) light.turn_off(self.hass, 'light.test') self.hass.block_till_done() - self.assertEqual(('test_light_rgb/set', 'off', 2, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'test_light_rgb/set', 'off', 2, False) + self.mock_publish.async_publish.reset_mock() state = self.hass.states.get('light.test') self.assertEqual(STATE_OFF, state.state) @@ -512,7 +514,7 @@ class TestLightMQTT(unittest.TestCase): white_value=80) self.hass.block_till_done() - self.mock_publish().async_publish.assert_has_calls([ + self.mock_publish.async_publish.assert_has_calls([ mock.call('test_light_rgb/set', 'on', 2, False), mock.call('test_light_rgb/rgb/set', '75,75,75', 2, False), mock.call('test_light_rgb/brightness/set', 50, 2, False), @@ -550,7 +552,7 @@ class TestLightMQTT(unittest.TestCase): light.turn_on(self.hass, 'light.test', rgb_color=[255, 128, 64]) self.hass.block_till_done() - self.mock_publish().async_publish.assert_has_calls([ + self.mock_publish.async_publish.assert_has_calls([ mock.call('test_light_rgb/set', 'on', 0, False), mock.call('test_light_rgb/rgb/set', '#ff8040', 0, False), ], any_order=True) @@ -701,16 +703,17 @@ class TestLightMQTT(unittest.TestCase): # Should get the following MQTT messages. # test_light/set: 'ON' # test_light/bright: 50 - self.assertEqual(('test_light/set', 'ON', 0, False), - self.mock_publish.mock_calls[-4][1]) - self.assertEqual(('test_light/bright', 50, 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_has_calls([ + mock.call('test_light/set', 'ON', 0, False), + mock.call('test_light/bright', 50, 0, False), + ], any_order=True) + self.mock_publish.async_publish.reset_mock() light.turn_off(self.hass, 'light.test') self.hass.block_till_done() - self.assertEqual(('test_light/set', 'OFF', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'test_light/set', 'OFF', 0, False) def test_on_command_last(self): """Test on command being sent after brightness.""" @@ -733,16 +736,17 @@ class TestLightMQTT(unittest.TestCase): # Should get the following MQTT messages. # test_light/bright: 50 # test_light/set: 'ON' - self.assertEqual(('test_light/bright', 50, 0, False), - self.mock_publish.mock_calls[-4][1]) - self.assertEqual(('test_light/set', 'ON', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_has_calls([ + mock.call('test_light/bright', 50, 0, False), + mock.call('test_light/set', 'ON', 0, False), + ], any_order=True) + self.mock_publish.async_publish.reset_mock() light.turn_off(self.hass, 'light.test') self.hass.block_till_done() - self.assertEqual(('test_light/set', 'OFF', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'test_light/set', 'OFF', 0, False) def test_on_command_brightness(self): """Test on command being sent as only brightness.""" @@ -767,21 +771,24 @@ class TestLightMQTT(unittest.TestCase): # Should get the following MQTT messages. # test_light/bright: 255 - self.assertEqual(('test_light/bright', 255, 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'test_light/bright', 255, 0, False) + self.mock_publish.async_publish.reset_mock() light.turn_off(self.hass, 'light.test') self.hass.block_till_done() - self.assertEqual(('test_light/set', 'OFF', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'test_light/set', 'OFF', 0, False) + self.mock_publish.async_publish.reset_mock() # Turn on w/ brightness light.turn_on(self.hass, 'light.test', brightness=50) self.hass.block_till_done() - self.assertEqual(('test_light/bright', 50, 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'test_light/bright', 50, 0, False) + self.mock_publish.async_publish.reset_mock() light.turn_off(self.hass, 'light.test') self.hass.block_till_done() @@ -791,10 +798,10 @@ class TestLightMQTT(unittest.TestCase): light.turn_on(self.hass, 'light.test', rgb_color=[75, 75, 75]) self.hass.block_till_done() - self.assertEqual(('test_light/rgb', '75,75,75', 0, False), - self.mock_publish.mock_calls[-4][1]) - self.assertEqual(('test_light/bright', 50, 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_has_calls([ + mock.call('test_light/rgb', '75,75,75', 0, False), + mock.call('test_light/bright', 50, 0, False) + ], any_order=True) def test_default_availability_payload(self): """Test availability by default payload with defined topic.""" diff --git a/tests/components/light/test_mqtt_json.py b/tests/components/light/test_mqtt_json.py index a06f8e7d093..ba306a81a34 100644 --- a/tests/components/light/test_mqtt_json.py +++ b/tests/components/light/test_mqtt_json.py @@ -293,16 +293,18 @@ class TestLightMQTTJSON(unittest.TestCase): light.turn_on(self.hass, 'light.test') self.hass.block_till_done() - self.assertEqual(('test_light_rgb/set', '{"state": "ON"}', 2, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'test_light_rgb/set', '{"state": "ON"}', 2, False) + self.mock_publish.async_publish.reset_mock() state = self.hass.states.get('light.test') self.assertEqual(STATE_ON, state.state) light.turn_off(self.hass, 'light.test') self.hass.block_till_done() - self.assertEqual(('test_light_rgb/set', '{"state": "OFF"}', 2, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'test_light_rgb/set', '{"state": "OFF"}', 2, False) + self.mock_publish.async_publish.reset_mock() state = self.hass.states.get('light.test') self.assertEqual(STATE_OFF, state.state) @@ -312,11 +314,14 @@ class TestLightMQTTJSON(unittest.TestCase): self.hass.block_till_done() self.assertEqual('test_light_rgb/set', - self.mock_publish.mock_calls[-2][1][0]) - self.assertEqual(2, self.mock_publish.mock_calls[-2][1][2]) - self.assertEqual(False, self.mock_publish.mock_calls[-2][1][3]) + self.mock_publish.async_publish.mock_calls[0][1][0]) + self.assertEqual(2, + self.mock_publish.async_publish.mock_calls[0][1][2]) + self.assertEqual(False, + self.mock_publish.async_publish.mock_calls[0][1][3]) # Get the sent message - message_json = json.loads(self.mock_publish.mock_calls[-2][1][1]) + message_json = json.loads( + self.mock_publish.async_publish.mock_calls[0][1][1]) self.assertEqual(50, message_json["brightness"]) self.assertEqual(155, message_json["color_temp"]) self.assertEqual('colorloop', message_json["effect"]) @@ -353,23 +358,30 @@ class TestLightMQTTJSON(unittest.TestCase): self.hass.block_till_done() self.assertEqual('test_light_rgb/set', - self.mock_publish.mock_calls[-2][1][0]) - self.assertEqual(0, self.mock_publish.mock_calls[-2][1][2]) - self.assertEqual(False, self.mock_publish.mock_calls[-2][1][3]) + self.mock_publish.async_publish.mock_calls[0][1][0]) + self.assertEqual(0, + self.mock_publish.async_publish.mock_calls[0][1][2]) + self.assertEqual(False, + self.mock_publish.async_publish.mock_calls[0][1][3]) # Get the sent message - message_json = json.loads(self.mock_publish.mock_calls[-2][1][1]) + message_json = json.loads( + self.mock_publish.async_publish.mock_calls[0][1][1]) self.assertEqual(5, message_json["flash"]) self.assertEqual("ON", message_json["state"]) + self.mock_publish.async_publish.reset_mock() light.turn_on(self.hass, 'light.test', flash="long") self.hass.block_till_done() self.assertEqual('test_light_rgb/set', - self.mock_publish.mock_calls[-2][1][0]) - self.assertEqual(0, self.mock_publish.mock_calls[-2][1][2]) - self.assertEqual(False, self.mock_publish.mock_calls[-2][1][3]) + self.mock_publish.async_publish.mock_calls[0][1][0]) + self.assertEqual(0, + self.mock_publish.async_publish.mock_calls[0][1][2]) + self.assertEqual(False, + self.mock_publish.async_publish.mock_calls[0][1][3]) # Get the sent message - message_json = json.loads(self.mock_publish.mock_calls[-2][1][1]) + message_json = json.loads( + self.mock_publish.async_publish.mock_calls[0][1][1]) self.assertEqual(15, message_json["flash"]) self.assertEqual("ON", message_json["state"]) @@ -393,11 +405,14 @@ class TestLightMQTTJSON(unittest.TestCase): self.hass.block_till_done() self.assertEqual('test_light_rgb/set', - self.mock_publish.mock_calls[-2][1][0]) - self.assertEqual(0, self.mock_publish.mock_calls[-2][1][2]) - self.assertEqual(False, self.mock_publish.mock_calls[-2][1][3]) + self.mock_publish.async_publish.mock_calls[0][1][0]) + self.assertEqual(0, + self.mock_publish.async_publish.mock_calls[0][1][2]) + self.assertEqual(False, + self.mock_publish.async_publish.mock_calls[0][1][3]) # Get the sent message - message_json = json.loads(self.mock_publish.mock_calls[-2][1][1]) + message_json = json.loads( + self.mock_publish.async_publish.mock_calls[0][1][1]) self.assertEqual(10, message_json["transition"]) self.assertEqual("ON", message_json["state"]) @@ -406,11 +421,14 @@ class TestLightMQTTJSON(unittest.TestCase): self.hass.block_till_done() self.assertEqual('test_light_rgb/set', - self.mock_publish.mock_calls[-2][1][0]) - self.assertEqual(0, self.mock_publish.mock_calls[-2][1][2]) - self.assertEqual(False, self.mock_publish.mock_calls[-2][1][3]) + self.mock_publish.async_publish.mock_calls[1][1][0]) + self.assertEqual(0, + self.mock_publish.async_publish.mock_calls[1][1][2]) + self.assertEqual(False, + self.mock_publish.async_publish.mock_calls[1][1][3]) # Get the sent message - message_json = json.loads(self.mock_publish.mock_calls[-2][1][1]) + message_json = json.loads( + self.mock_publish.async_publish.mock_calls[1][1][1]) self.assertEqual(10, message_json["transition"]) self.assertEqual("OFF", message_json["state"]) diff --git a/tests/components/light/test_mqtt_template.py b/tests/components/light/test_mqtt_template.py index 0df9d8136e1..5a01aa15fa2 100644 --- a/tests/components/light/test_mqtt_template.py +++ b/tests/components/light/test_mqtt_template.py @@ -232,8 +232,9 @@ class TestLightMQTTTemplate(unittest.TestCase): light.turn_on(self.hass, 'light.test') self.hass.block_till_done() - self.assertEqual(('test_light_rgb/set', 'on,,,,--', 2, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'test_light_rgb/set', 'on,,,,--', 2, False) + self.mock_publish.async_publish.reset_mock() state = self.hass.states.get('light.test') self.assertEqual(STATE_ON, state.state) @@ -241,8 +242,9 @@ class TestLightMQTTTemplate(unittest.TestCase): light.turn_off(self.hass, 'light.test') self.hass.block_till_done() - self.assertEqual(('test_light_rgb/set', 'off', 2, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'test_light_rgb/set', 'off', 2, False) + self.mock_publish.async_publish.reset_mock() state = self.hass.states.get('light.test') self.assertEqual(STATE_OFF, state.state) @@ -251,22 +253,16 @@ class TestLightMQTTTemplate(unittest.TestCase): rgb_color=[75, 75, 75]) self.hass.block_till_done() - self.assertEqual('test_light_rgb/set', - self.mock_publish.mock_calls[-2][1][0]) - - # check the payload - payload = self.mock_publish.mock_calls[-2][1][1] - self.assertEqual('on,50,,,75-75-75', payload) + self.mock_publish.async_publish.assert_called_once_with( + 'test_light_rgb/set', 'on,50,,,75-75-75', 2, False) + self.mock_publish.async_publish.reset_mock() # turn on the light with color temp and white val light.turn_on(self.hass, 'light.test', color_temp=200, white_value=139) self.hass.block_till_done() - payload = self.mock_publish.mock_calls[-2][1][1] - self.assertEqual('on,,200,139,--', payload) - - self.assertEqual(2, self.mock_publish.mock_calls[-2][1][2]) - self.assertEqual(False, self.mock_publish.mock_calls[-2][1][3]) + self.mock_publish.async_publish.assert_called_once_with( + 'test_light_rgb/set', 'on,,200,139,--', 2, False) # check the state state = self.hass.states.get('light.test') @@ -298,27 +294,16 @@ class TestLightMQTTTemplate(unittest.TestCase): light.turn_on(self.hass, 'light.test', flash='short') self.hass.block_till_done() - self.assertEqual('test_light_rgb/set', - self.mock_publish.mock_calls[-2][1][0]) - self.assertEqual(0, self.mock_publish.mock_calls[-2][1][2]) - self.assertEqual(False, self.mock_publish.mock_calls[-2][1][3]) - - # check the payload - payload = self.mock_publish.mock_calls[-2][1][1] - self.assertEqual('on,short', payload) + self.mock_publish.async_publish.assert_called_once_with( + 'test_light_rgb/set', 'on,short', 0, False) + self.mock_publish.async_publish.reset_mock() # long flash light.turn_on(self.hass, 'light.test', flash='long') self.hass.block_till_done() - self.assertEqual('test_light_rgb/set', - self.mock_publish.mock_calls[-2][1][0]) - self.assertEqual(0, self.mock_publish.mock_calls[-2][1][2]) - self.assertEqual(False, self.mock_publish.mock_calls[-2][1][3]) - - # check the payload - payload = self.mock_publish.mock_calls[-2][1][1] - self.assertEqual('on,long', payload) + self.mock_publish.async_publish.assert_called_once_with( + 'test_light_rgb/set', 'on,long', 0, False) def test_transition(self): """Test for transition time being sent when included.""" @@ -340,27 +325,16 @@ class TestLightMQTTTemplate(unittest.TestCase): light.turn_on(self.hass, 'light.test', transition=10) self.hass.block_till_done() - self.assertEqual('test_light_rgb/set', - self.mock_publish.mock_calls[-2][1][0]) - self.assertEqual(0, self.mock_publish.mock_calls[-2][1][2]) - self.assertEqual(False, self.mock_publish.mock_calls[-2][1][3]) - - # check the payload - payload = self.mock_publish.mock_calls[-2][1][1] - self.assertEqual('on,10', payload) + self.mock_publish.async_publish.assert_called_once_with( + 'test_light_rgb/set', 'on,10', 0, False) + self.mock_publish.async_publish.reset_mock() # transition off light.turn_off(self.hass, 'light.test', transition=4) self.hass.block_till_done() - self.assertEqual('test_light_rgb/set', - self.mock_publish.mock_calls[-2][1][0]) - self.assertEqual(0, self.mock_publish.mock_calls[-2][1][2]) - self.assertEqual(False, self.mock_publish.mock_calls[-2][1][3]) - - # check the payload - payload = self.mock_publish.mock_calls[-2][1][1] - self.assertEqual('off,4', payload) + self.mock_publish.async_publish.assert_called_once_with( + 'test_light_rgb/set', 'off,4', 0, False) def test_invalid_values(self): \ # pylint: disable=invalid-name diff --git a/tests/components/lock/test_mqtt.py b/tests/components/lock/test_mqtt.py index 0f4df75d1a2..f87b8f8b74b 100644 --- a/tests/components/lock/test_mqtt.py +++ b/tests/components/lock/test_mqtt.py @@ -70,16 +70,17 @@ class TestLockMQTT(unittest.TestCase): lock.lock(self.hass, 'lock.test') self.hass.block_till_done() - self.assertEqual(('command-topic', 'LOCK', 2, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'command-topic', 'LOCK', 2, False) + self.mock_publish.async_publish.reset_mock() state = self.hass.states.get('lock.test') self.assertEqual(STATE_LOCKED, state.state) lock.unlock(self.hass, 'lock.test') self.hass.block_till_done() - self.assertEqual(('command-topic', 'UNLOCK', 2, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'command-topic', 'UNLOCK', 2, False) state = self.hass.states.get('lock.test') self.assertEqual(STATE_UNLOCKED, state.state) diff --git a/tests/components/media_player/test_cast.py b/tests/components/media_player/test_cast.py index 0bcfc9b9a1a..6eeb9136b07 100644 --- a/tests/components/media_player/test_cast.py +++ b/tests/components/media_player/test_cast.py @@ -1,12 +1,15 @@ """The tests for the Cast Media player platform.""" # pylint: disable=protected-access -import unittest -from unittest.mock import patch, MagicMock +import asyncio +from typing import Optional +from unittest.mock import patch, MagicMock, Mock +from uuid import UUID import pytest +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.components.media_player import cast -from tests.common import get_test_home_assistant @pytest.fixture(autouse=True) @@ -18,83 +21,221 @@ def cast_mock(): yield -class FakeChromeCast(object): - """A fake Chrome Cast.""" - - def __init__(self, host, port): - """Initialize the fake Chrome Cast.""" - self.host = host - self.port = port +# pylint: disable=invalid-name +FakeUUID = UUID('57355bce-9364-4aa6-ac1e-eb849dccf9e2') -class TestCastMediaPlayer(unittest.TestCase): - """Test the media_player module.""" +def get_fake_chromecast(host='192.168.178.42', port=8009, + uuid: Optional[UUID] = FakeUUID): + """Generate a Fake Chromecast object with the specified arguments.""" + return MagicMock(host=host, port=port, uuid=uuid) - def setUp(self): - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - def tearDown(self): - """Stop everything that was started.""" - self.hass.stop() +@asyncio.coroutine +def async_setup_cast(hass, config=None, discovery_info=None): + """Helper to setup the cast platform.""" + if config is None: + config = {} + add_devices = Mock() - @patch('homeassistant.components.media_player.cast.CastDevice') - @patch('pychromecast.get_chromecasts') - def test_filter_duplicates(self, mock_get_chromecasts, mock_device): - """Test filtering of duplicates.""" - mock_get_chromecasts.return_value = [ - FakeChromeCast('some_host', cast.DEFAULT_PORT) - ] + yield from cast.async_setup_platform(hass, config, add_devices, + discovery_info=discovery_info) + yield from hass.async_block_till_done() - # Test chromecasts as if they were hardcoded in configuration.yaml - cast.setup_platform(self.hass, { - 'host': 'some_host' - }, lambda _: _) + return add_devices - assert mock_device.called - mock_device.reset_mock() - assert not mock_device.called +@asyncio.coroutine +def async_setup_cast_internal_discovery(hass, config=None, + discovery_info=None, + no_from_host_patch=False): + """Setup the cast platform and the discovery.""" + listener = MagicMock(services={}) - # Test chromecasts as if they were automatically discovered - cast.setup_platform(self.hass, {}, lambda _: _, { - 'host': 'some_host', - 'port': cast.DEFAULT_PORT, - }) - assert not mock_device.called + with patch('pychromecast.start_discovery', + return_value=(listener, None)) as start_discovery: + add_devices = yield from async_setup_cast(hass, config, discovery_info) + yield from hass.async_block_till_done() + yield from hass.async_block_till_done() - @patch('homeassistant.components.media_player.cast.CastDevice') - @patch('pychromecast.get_chromecasts') - @patch('pychromecast.Chromecast') - def test_fallback_cast(self, mock_chromecast, mock_get_chromecasts, - mock_device): - """Test falling back to creating Chromecast when not discovered.""" - mock_get_chromecasts.return_value = [ - FakeChromeCast('some_host', cast.DEFAULT_PORT) - ] + assert start_discovery.call_count == 1 - # Test chromecasts as if they were hardcoded in configuration.yaml - cast.setup_platform(self.hass, { - 'host': 'some_other_host' - }, lambda _: _) + discovery_callback = start_discovery.call_args[0][0] - assert mock_chromecast.called - assert mock_device.called + def discover_chromecast(service_name, chromecast): + """Discover a chromecast device.""" + listener.services[service_name] = ( + chromecast.host, chromecast.port, chromecast.uuid, None, None) + if no_from_host_patch: + discovery_callback(service_name) + else: + with patch('pychromecast._get_chromecast_from_host', + return_value=chromecast): + discovery_callback(service_name) - @patch('homeassistant.components.media_player.cast.CastDevice') - @patch('pychromecast.get_chromecasts') - @patch('pychromecast.Chromecast') - def test_fallback_cast_group(self, mock_chromecast, mock_get_chromecasts, - mock_device): - """Test not creating Cast Group when not discovered.""" - mock_get_chromecasts.return_value = [ - FakeChromeCast('some_host', cast.DEFAULT_PORT) - ] + return discover_chromecast, add_devices - # Test chromecasts as if they were automatically discovered - cast.setup_platform(self.hass, {}, lambda _: _, { - 'host': 'some_other_host', - 'port': 43546, - }) - assert not mock_chromecast.called - assert not mock_device.called + +@asyncio.coroutine +def test_start_discovery_called_once(hass): + """Test pychromecast.start_discovery called exactly once.""" + with patch('pychromecast.start_discovery', + return_value=(None, None)) as start_discovery: + yield from async_setup_cast(hass) + + assert start_discovery.call_count == 1 + + yield from async_setup_cast(hass) + assert start_discovery.call_count == 1 + + +@asyncio.coroutine +def test_stop_discovery_called_on_stop(hass): + """Test pychromecast.stop_discovery called on shutdown.""" + with patch('pychromecast.start_discovery', + return_value=(None, 'the-browser')) as start_discovery: + yield from async_setup_cast(hass) + + assert start_discovery.call_count == 1 + + with patch('pychromecast.stop_discovery') as stop_discovery: + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + yield from hass.async_block_till_done() + + stop_discovery.assert_called_once_with('the-browser') + + with patch('pychromecast.start_discovery', + return_value=(None, 'the-browser')) as start_discovery: + yield from async_setup_cast(hass) + + assert start_discovery.call_count == 1 + + +@asyncio.coroutine +def test_internal_discovery_callback_only_generates_once(hass): + """Test _get_chromecast_from_host only called once per device.""" + discover_cast, _ = yield from async_setup_cast_internal_discovery( + hass, no_from_host_patch=True) + chromecast = get_fake_chromecast() + + with patch('pychromecast._get_chromecast_from_host', + return_value=chromecast) as gen_chromecast: + discover_cast('the-service', chromecast) + mdns = (chromecast.host, chromecast.port, chromecast.uuid, None, None) + gen_chromecast.assert_called_once_with(mdns, blocking=True) + + discover_cast('the-service', chromecast) + gen_chromecast.reset_mock() + assert gen_chromecast.call_count == 0 + + +@asyncio.coroutine +def test_internal_discovery_callback_calls_dispatcher(hass): + """Test internal discovery calls dispatcher.""" + discover_cast, _ = yield from async_setup_cast_internal_discovery(hass) + chromecast = get_fake_chromecast() + + with patch('pychromecast._get_chromecast_from_host', + return_value=chromecast): + signal = MagicMock() + + async_dispatcher_connect(hass, 'cast_discovered', signal) + discover_cast('the-service', chromecast) + yield from hass.async_block_till_done() + + signal.assert_called_once_with(chromecast) + + +@asyncio.coroutine +def test_internal_discovery_callback_with_connection_error(hass): + """Test internal discovery not calling dispatcher on ConnectionError.""" + import pychromecast # imports mock pychromecast + + pychromecast.ChromecastConnectionError = IOError + + discover_cast, _ = yield from async_setup_cast_internal_discovery( + hass, no_from_host_patch=True) + chromecast = get_fake_chromecast() + + with patch('pychromecast._get_chromecast_from_host', + side_effect=pychromecast.ChromecastConnectionError): + signal = MagicMock() + + async_dispatcher_connect(hass, 'cast_discovered', signal) + discover_cast('the-service', chromecast) + yield from hass.async_block_till_done() + + assert signal.call_count == 0 + + +def test_create_cast_device_without_uuid(hass): + """Test create a cast device without a UUID.""" + chromecast = get_fake_chromecast(uuid=None) + cast_device = cast._async_create_cast_device(hass, chromecast) + assert cast_device is not None + + +def test_create_cast_device_with_uuid(hass): + """Test create cast devices with UUID.""" + added_casts = hass.data[cast.ADDED_CAST_DEVICES_KEY] = {} + chromecast = get_fake_chromecast() + cast_device = cast._async_create_cast_device(hass, chromecast) + assert cast_device is not None + assert chromecast.uuid in added_casts + + with patch.object(cast_device, 'async_set_chromecast') as mock_set: + assert cast._async_create_cast_device(hass, chromecast) is None + assert mock_set.call_count == 0 + + chromecast = get_fake_chromecast(host='192.168.178.1') + assert cast._async_create_cast_device(hass, chromecast) is None + assert mock_set.call_count == 1 + mock_set.assert_called_once_with(chromecast) + + +@asyncio.coroutine +def test_normal_chromecast_not_starting_discovery(hass): + """Test cast platform not starting discovery when not required.""" + chromecast = get_fake_chromecast() + + with patch('pychromecast.Chromecast', return_value=chromecast): + add_devices = yield from async_setup_cast(hass, {'host': 'host1'}) + assert add_devices.call_count == 1 + + # Same entity twice + add_devices = yield from async_setup_cast(hass, {'host': 'host1'}) + assert add_devices.call_count == 0 + + hass.data[cast.ADDED_CAST_DEVICES_KEY] = {} + add_devices = yield from async_setup_cast( + hass, discovery_info={'host': 'host1', 'port': 8009}) + assert add_devices.call_count == 1 + + hass.data[cast.ADDED_CAST_DEVICES_KEY] = {} + add_devices = yield from async_setup_cast( + hass, discovery_info={'host': 'host1', 'port': 42}) + assert add_devices.call_count == 0 + + +@asyncio.coroutine +def test_replay_past_chromecasts(hass): + """Test cast platform re-playing past chromecasts when adding new one.""" + cast_group1 = get_fake_chromecast(host='host1', port=42) + cast_group2 = get_fake_chromecast(host='host2', port=42, uuid=UUID( + '9462202c-e747-4af5-a66b-7dce0e1ebc09')) + + discover_cast, add_dev1 = yield from async_setup_cast_internal_discovery( + hass, discovery_info={'host': 'host1', 'port': 42}) + discover_cast('service2', cast_group2) + yield from hass.async_block_till_done() + assert add_dev1.call_count == 0 + + discover_cast('service1', cast_group1) + yield from hass.async_block_till_done() + yield from hass.async_block_till_done() # having jobs that add jobs + assert add_dev1.call_count == 1 + + add_dev2 = yield from async_setup_cast( + hass, discovery_info={'host': 'host2', 'port': 42}) + yield from hass.async_block_till_done() + assert add_dev2.call_count == 1 diff --git a/tests/components/media_player/test_sonos.py b/tests/components/media_player/test_sonos.py index d3ebc67931f..f1a0f4a82fc 100644 --- a/tests/components/media_player/test_sonos.py +++ b/tests/components/media_player/test_sonos.py @@ -41,6 +41,14 @@ class AvTransportMock(): } +class MusicLibraryMock(): + """Mock class for the music_library property on soco.SoCo object.""" + + def get_sonos_favorites(self): + """Return favorites.""" + return [] + + class SoCoMock(): """Mock class for the soco.SoCo object.""" @@ -48,6 +56,12 @@ class SoCoMock(): """Initialize soco object.""" self.ip_address = ip self.is_visible = True + self.volume = 50 + self.mute = False + self.play_mode = 'NORMAL' + self.night_mode = False + self.dialog_mode = False + self.music_library = MusicLibraryMock() self.avTransport = AvTransportMock() def get_sonos_favorites(self): @@ -62,6 +76,7 @@ class SoCoMock(): 'zone_icon': 'x-rincon-roomicon:kitchen', 'mac_address': 'B8:E9:37:BO:OC:BA', 'zone_name': 'Kitchen', + 'model_name': 'Sonos PLAY:1', 'hardware_version': '1.8.1.2-1'} def get_current_transport_info(self): @@ -145,8 +160,9 @@ class TestSonosMediaPlayer(unittest.TestCase): 'host': '192.0.2.1' }) - self.assertEqual(len(self.hass.data[sonos.DATA_SONOS]), 1) - self.assertEqual(self.hass.data[sonos.DATA_SONOS][0].name, 'Kitchen') + devices = self.hass.data[sonos.DATA_SONOS].devices + self.assertEqual(len(devices), 1) + self.assertEqual(devices[0].name, 'Kitchen') @mock.patch('soco.SoCo', new=SoCoMock) @mock.patch('socket.create_connection', side_effect=socket.error()) @@ -164,7 +180,7 @@ class TestSonosMediaPlayer(unittest.TestCase): assert setup_component(self.hass, DOMAIN, config) - self.assertEqual(len(self.hass.data[sonos.DATA_SONOS]), 1) + self.assertEqual(len(self.hass.data[sonos.DATA_SONOS].devices), 1) self.assertEqual(discover_mock.call_count, 1) @mock.patch('soco.SoCo', new=SoCoMock) @@ -184,7 +200,7 @@ class TestSonosMediaPlayer(unittest.TestCase): assert setup_component(self.hass, DOMAIN, config) - self.assertEqual(len(self.hass.data[sonos.DATA_SONOS]), 1) + self.assertEqual(len(self.hass.data[sonos.DATA_SONOS].devices), 1) self.assertEqual(discover_mock.call_count, 1) self.assertEqual(soco.config.EVENT_ADVERTISE_IP, '192.0.1.1') @@ -201,8 +217,9 @@ class TestSonosMediaPlayer(unittest.TestCase): assert setup_component(self.hass, DOMAIN, config) - self.assertEqual(len(self.hass.data[sonos.DATA_SONOS]), 1) - self.assertEqual(self.hass.data[sonos.DATA_SONOS][0].name, 'Kitchen') + devices = self.hass.data[sonos.DATA_SONOS].devices + self.assertEqual(len(devices), 1) + self.assertEqual(devices[0].name, 'Kitchen') @mock.patch('soco.SoCo', new=SoCoMock) @mock.patch('socket.create_connection', side_effect=socket.error()) @@ -217,8 +234,9 @@ class TestSonosMediaPlayer(unittest.TestCase): assert setup_component(self.hass, DOMAIN, config) - self.assertEqual(len(self.hass.data[sonos.DATA_SONOS]), 2) - self.assertEqual(self.hass.data[sonos.DATA_SONOS][0].name, 'Kitchen') + devices = self.hass.data[sonos.DATA_SONOS].devices + self.assertEqual(len(devices), 2) + self.assertEqual(devices[0].name, 'Kitchen') @mock.patch('soco.SoCo', new=SoCoMock) @mock.patch('socket.create_connection', side_effect=socket.error()) @@ -233,8 +251,9 @@ class TestSonosMediaPlayer(unittest.TestCase): assert setup_component(self.hass, DOMAIN, config) - self.assertEqual(len(self.hass.data[sonos.DATA_SONOS]), 2) - self.assertEqual(self.hass.data[sonos.DATA_SONOS][0].name, 'Kitchen') + devices = self.hass.data[sonos.DATA_SONOS].devices + self.assertEqual(len(devices), 2) + self.assertEqual(devices[0].name, 'Kitchen') @mock.patch('soco.SoCo', new=SoCoMock) @mock.patch.object(soco, 'discover', new=socoDiscoverMock.discover) @@ -242,58 +261,9 @@ class TestSonosMediaPlayer(unittest.TestCase): def test_ensure_setup_sonos_discovery(self, *args): """Test a single device using the autodiscovery provided by Sonos.""" sonos.setup_platform(self.hass, {}, fake_add_device) - self.assertEqual(len(self.hass.data[sonos.DATA_SONOS]), 1) - self.assertEqual(self.hass.data[sonos.DATA_SONOS][0].name, 'Kitchen') - - @mock.patch('soco.SoCo', new=SoCoMock) - @mock.patch('socket.create_connection', side_effect=socket.error()) - @mock.patch.object(SoCoMock, 'join') - def test_sonos_group_players(self, join_mock, *args): - """Ensuring soco methods called for sonos_group_players service.""" - sonos.setup_platform(self.hass, {}, fake_add_device, { - 'host': '192.0.2.1' - }) - device = self.hass.data[sonos.DATA_SONOS][-1] - device.hass = self.hass - - device_master = mock.MagicMock() - device_master.entity_id = "media_player.test" - device_master.soco_device = mock.MagicMock() - self.hass.data[sonos.DATA_SONOS].append(device_master) - - join_mock.return_value = True - device.join("media_player.test") - self.assertEqual(join_mock.call_count, 1) - - @mock.patch('soco.SoCo', new=SoCoMock) - @mock.patch('socket.create_connection', side_effect=socket.error()) - @mock.patch.object(SoCoMock, 'unjoin') - def test_sonos_unjoin(self, unjoinMock, *args): - """Ensuring soco methods called for sonos_unjoin service.""" - sonos.setup_platform(self.hass, {}, fake_add_device, { - 'host': '192.0.2.1' - }) - device = self.hass.data[sonos.DATA_SONOS][-1] - device.hass = self.hass - - unjoinMock.return_value = True - device.unjoin() - self.assertEqual(unjoinMock.call_count, 1) - self.assertEqual(unjoinMock.call_args, mock.call()) - - @mock.patch('soco.SoCo', new=SoCoMock) - @mock.patch('socket.create_connection', side_effect=socket.error()) - def test_set_shuffle(self, shuffle_set_mock, *args): - """Ensuring soco methods called for sonos_snapshot service.""" - sonos.setup_platform(self.hass, {}, fake_add_device, { - 'host': '192.0.2.1' - }) - device = self.hass.data[sonos.DATA_SONOS][-1] - device.hass = self.hass - - device.set_shuffle(True) - self.assertEqual(shuffle_set_mock.call_count, 1) - self.assertEqual(device._player.play_mode, 'SHUFFLE') + devices = self.hass.data[sonos.DATA_SONOS].devices + self.assertEqual(len(devices), 1) + self.assertEqual(devices[0].name, 'Kitchen') @mock.patch('soco.SoCo', new=SoCoMock) @mock.patch('socket.create_connection', side_effect=socket.error()) @@ -303,7 +273,7 @@ class TestSonosMediaPlayer(unittest.TestCase): sonos.setup_platform(self.hass, {}, fake_add_device, { 'host': '192.0.2.1' }) - device = self.hass.data[sonos.DATA_SONOS][-1] + device = self.hass.data[sonos.DATA_SONOS].devices[-1] device.hass = self.hass device.set_sleep_timer(30) @@ -317,7 +287,7 @@ class TestSonosMediaPlayer(unittest.TestCase): sonos.setup_platform(self.hass, {}, mock.MagicMock(), { 'host': '192.0.2.1' }) - device = self.hass.data[sonos.DATA_SONOS][-1] + device = self.hass.data[sonos.DATA_SONOS].devices[-1] device.hass = self.hass device.set_sleep_timer(None) @@ -331,7 +301,7 @@ class TestSonosMediaPlayer(unittest.TestCase): sonos.setup_platform(self.hass, {}, fake_add_device, { 'host': '192.0.2.1' }) - device = self.hass.data[sonos.DATA_SONOS][-1] + device = self.hass.data[sonos.DATA_SONOS].devices[-1] device.hass = self.hass alarm1 = alarms.Alarm(soco_mock) alarm1.configure_mock(_alarm_id="1", start_time=None, enabled=False, @@ -361,7 +331,7 @@ class TestSonosMediaPlayer(unittest.TestCase): sonos.setup_platform(self.hass, {}, fake_add_device, { 'host': '192.0.2.1' }) - device = self.hass.data[sonos.DATA_SONOS][-1] + device = self.hass.data[sonos.DATA_SONOS].devices[-1] device.hass = self.hass snapshotMock.return_value = True @@ -379,7 +349,7 @@ class TestSonosMediaPlayer(unittest.TestCase): sonos.setup_platform(self.hass, {}, fake_add_device, { 'host': '192.0.2.1' }) - device = self.hass.data[sonos.DATA_SONOS][-1] + device = self.hass.data[sonos.DATA_SONOS].devices[-1] device.hass = self.hass restoreMock.return_value = True @@ -389,21 +359,3 @@ class TestSonosMediaPlayer(unittest.TestCase): device.restore() self.assertEqual(restoreMock.call_count, 1) self.assertEqual(restoreMock.call_args, mock.call(False)) - - @mock.patch('soco.SoCo', new=SoCoMock) - @mock.patch('socket.create_connection', side_effect=socket.error()) - def test_sonos_set_option(self, option_mock, *args): - """Ensuring soco methods called for sonos_set_option service.""" - sonos.setup_platform(self.hass, {}, fake_add_device, { - 'host': '192.0.2.1' - }) - device = self.hass.data[sonos.DATA_SONOS][-1] - device.hass = self.hass - - option_mock.return_value = True - device._snapshot_coordinator = mock.MagicMock() - device._snapshot_coordinator.soco_device = SoCoMock('192.0.2.17') - - device.update_option(night_sound=True, speech_enhance=True) - - self.assertEqual(option_mock.call_count, 1) diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index d0704aac227..995f7e891f9 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -18,7 +18,7 @@ def test_subscribing_config_topic(hass, mqtt_mock): assert mqtt_mock.async_subscribe.called call_args = mqtt_mock.async_subscribe.mock_calls[0][1] assert call_args[0] == discovery_topic + '/#' - assert call_args[1] == 0 + assert call_args[2] == 0 @asyncio.coroutine diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 55ff0e9ff05..24308bc9a7e 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -1,6 +1,5 @@ """The tests for the MQTT component.""" import asyncio -from collections import namedtuple, OrderedDict import unittest from unittest import mock import socket @@ -9,26 +8,27 @@ import ssl import voluptuous as vol from homeassistant.core import callback -from homeassistant.setup import setup_component, async_setup_component +from homeassistant.setup import async_setup_component import homeassistant.components.mqtt as mqtt -from homeassistant.const import ( - EVENT_CALL_SERVICE, ATTR_DOMAIN, ATTR_SERVICE, EVENT_HOMEASSISTANT_STOP) -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.const import (EVENT_CALL_SERVICE, ATTR_DOMAIN, ATTR_SERVICE, + EVENT_HOMEASSISTANT_STOP) -from tests.common import ( - get_test_home_assistant, mock_mqtt_component, fire_mqtt_message, mock_coro) +from tests.common import (get_test_home_assistant, mock_coro, + mock_mqtt_component, + threadsafe_coroutine_factory, fire_mqtt_message, + async_fire_mqtt_message) @asyncio.coroutine -def mock_mqtt_client(hass, config=None): +def async_mock_mqtt_client(hass, config=None): """Mock the MQTT paho client.""" if config is None: - config = { - mqtt.CONF_BROKER: 'mock-broker' - } + config = {mqtt.CONF_BROKER: 'mock-broker'} with mock.patch('paho.mqtt.client.Client') as mock_client: - mock_client().connect = lambda *args: 0 + mock_client().connect.return_value = 0 + mock_client().subscribe.return_value = (0, 0) + mock_client().publish.return_value = (0, 0) result = yield from async_setup_component(hass, mqtt.DOMAIN, { mqtt.DOMAIN: config }) @@ -36,8 +36,11 @@ def mock_mqtt_client(hass, config=None): return mock_client() +mock_mqtt_client = threadsafe_coroutine_factory(async_mock_mqtt_client) + + # pylint: disable=invalid-name -class TestMQTT(unittest.TestCase): +class TestMQTTComponent(unittest.TestCase): """Test the MQTT component.""" def setUp(self): # pylint: disable=invalid-name @@ -55,12 +58,8 @@ class TestMQTT(unittest.TestCase): """Helper for recording calls.""" self.calls.append(args) - def test_client_starts_on_home_assistant_mqtt_setup(self): - """Test if client is connect after mqtt init on bootstrap.""" - assert self.hass.data['mqtt'].async_connect.called - def test_client_stops_on_home_assistant_start(self): - """Test if client stops on HA launch.""" + """Test if client stops on HA stop.""" self.hass.bus.fire(EVENT_HOMEASSISTANT_STOP) self.hass.block_till_done() self.assertTrue(self.hass.data['mqtt'].async_disconnect.called) @@ -131,6 +130,59 @@ class TestMQTT(unittest.TestCase): self.hass.data['mqtt'].async_publish.call_args[0][2], 2) self.assertFalse(self.hass.data['mqtt'].async_publish.call_args[0][3]) + def test_invalid_mqtt_topics(self): + """Test invalid topics.""" + self.assertRaises(vol.Invalid, mqtt.valid_publish_topic, 'bad+topic') + self.assertRaises(vol.Invalid, mqtt.valid_subscribe_topic, 'bad\0one') + + +# pylint: disable=invalid-name +class TestMQTTCallbacks(unittest.TestCase): + """Test the MQTT callbacks.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + mock_mqtt_client(self.hass) + self.calls = [] + + def tearDown(self): # pylint: disable=invalid-name + """Stop everything that was started.""" + self.hass.stop() + + @callback + def record_calls(self, *args): + """Helper for recording calls.""" + self.calls.append(args) + + def test_client_starts_on_home_assistant_mqtt_setup(self): + """Test if client is connected after mqtt init on bootstrap.""" + self.assertEqual(self.hass.data['mqtt']._mqttc.connect.call_count, 1) + + def test_receiving_non_utf8_message_gets_logged(self): + """Test receiving a non utf8 encoded message.""" + mqtt.subscribe(self.hass, 'test-topic', self.record_calls) + + with self.assertLogs(level='WARNING') as test_handle: + fire_mqtt_message(self.hass, 'test-topic', b'\x9a') + + self.hass.block_till_done() + self.assertIn( + "WARNING:homeassistant.components.mqtt:Can't decode payload " + "b'\\x9a' on test-topic with encoding utf-8", + test_handle.output[0]) + + def test_all_subscriptions_run_when_decode_fails(self): + """Test all other subscriptions still run when decode fails for one.""" + mqtt.subscribe(self.hass, 'test-topic', self.record_calls, + encoding='ascii') + mqtt.subscribe(self.hass, 'test-topic', self.record_calls) + + fire_mqtt_message(self.hass, 'test-topic', '°C') + + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) + def test_subscribe_topic(self): """Test the subscription of a topic.""" unsub = mqtt.subscribe(self.hass, 'test-topic', self.record_calls) @@ -296,82 +348,6 @@ class TestMQTT(unittest.TestCase): self.assertEqual(topic, self.calls[0][0]) self.assertEqual(payload, self.calls[0][1]) - def test_subscribe_binary_topic(self): - """Test the subscription to a binary topic.""" - mqtt.subscribe(self.hass, 'test-topic', self.record_calls, - 0, None) - - fire_mqtt_message(self.hass, 'test-topic', 0x9a) - - self.hass.block_till_done() - self.assertEqual(1, len(self.calls)) - self.assertEqual('test-topic', self.calls[0][0]) - self.assertEqual(0x9a, self.calls[0][1]) - - def test_receiving_non_utf8_message_gets_logged(self): - """Test receiving a non utf8 encoded message.""" - mqtt.subscribe(self.hass, 'test-topic', self.record_calls) - - with self.assertLogs(level='ERROR') as test_handle: - fire_mqtt_message(self.hass, 'test-topic', 0x9a) - self.hass.block_till_done() - self.assertIn( - "ERROR:homeassistant.components.mqtt:Illegal payload " - "encoding utf-8 from MQTT " - "topic: test-topic, Payload: 154", - test_handle.output[0]) - - -class TestMQTTCallbacks(unittest.TestCase): - """Test the MQTT callbacks.""" - - def setUp(self): # pylint: disable=invalid-name - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - - with mock.patch('paho.mqtt.client.Client') as client: - client().connect = lambda *args: 0 - assert setup_component(self.hass, mqtt.DOMAIN, { - mqtt.DOMAIN: { - mqtt.CONF_BROKER: 'mock-broker', - } - }) - - def tearDown(self): # pylint: disable=invalid-name - """Stop everything that was started.""" - self.hass.stop() - - def test_receiving_mqtt_message_fires_hass_event(self): - """Test if receiving triggers an event.""" - calls = [] - - @callback - def record(topic, payload, qos): - """Helper to record calls.""" - data = { - 'topic': topic, - 'payload': payload, - 'qos': qos, - } - calls.append(data) - - async_dispatcher_connect( - self.hass, mqtt.SIGNAL_MQTT_MESSAGE_RECEIVED, record) - - MQTTMessage = namedtuple('MQTTMessage', ['topic', 'qos', 'payload']) - message = MQTTMessage('test_topic', 1, 'Hello World!'.encode('utf-8')) - - self.hass.data['mqtt']._mqtt_on_message( - None, {'hass': self.hass}, message) - self.hass.block_till_done() - - self.assertEqual(1, len(calls)) - last_event = calls[0] - self.assertEqual(bytearray('Hello World!', 'utf-8'), - last_event['payload']) - self.assertEqual(message.topic, last_event['topic']) - self.assertEqual(message.qos, last_event['qos']) - def test_mqtt_failed_connection_results_in_disconnect(self): """Test if connection failure leads to disconnect.""" for result_code in range(1, 6): @@ -388,16 +364,11 @@ class TestMQTTCallbacks(unittest.TestCase): @mock.patch('homeassistant.components.mqtt.time.sleep') def test_mqtt_disconnect_tries_reconnect(self, mock_sleep): """Test the re-connect tries.""" - self.hass.data['mqtt'].subscribed_topics = { - 'test/topic': 1, - } - self.hass.data['mqtt'].wanted_topics = { - 'test/progress': 0, - 'test/topic': 2, - } - self.hass.data['mqtt'].progress = { - 1: 'test/progress' - } + self.hass.data['mqtt'].subscriptions = [ + mqtt.Subscription('test/progress', None, 0), + mqtt.Subscription('test/progress', None, 1), + mqtt.Subscription('test/topic', None, 2), + ] self.hass.data['mqtt']._mqttc.reconnect.side_effect = [1, 1, 1, 0] self.hass.data['mqtt']._mqtt_on_disconnect(None, None, 1) self.assertTrue(self.hass.data['mqtt']._mqttc.reconnect.called) @@ -406,15 +377,77 @@ class TestMQTTCallbacks(unittest.TestCase): self.assertEqual([1, 2, 4], [call[1][0] for call in mock_sleep.mock_calls]) - self.assertEqual({'test/topic': 2, 'test/progress': 0}, - self.hass.data['mqtt'].wanted_topics) - self.assertEqual({}, self.hass.data['mqtt'].subscribed_topics) - self.assertEqual({}, self.hass.data['mqtt'].progress) + def test_retained_message_on_subscribe_received(self): + """Test every subscriber receives retained message on subscribe.""" + def side_effect(*args): + async_fire_mqtt_message(self.hass, 'test/state', 'online') + return 0, 0 - def test_invalid_mqtt_topics(self): - """Test invalid topics.""" - self.assertRaises(vol.Invalid, mqtt.valid_publish_topic, 'bad+topic') - self.assertRaises(vol.Invalid, mqtt.valid_subscribe_topic, 'bad\0one') + self.hass.data['mqtt']._mqttc.subscribe.side_effect = side_effect + + calls_a = mock.MagicMock() + mqtt.subscribe(self.hass, 'test/state', calls_a) + self.hass.block_till_done() + self.assertTrue(calls_a.called) + + calls_b = mock.MagicMock() + mqtt.subscribe(self.hass, 'test/state', calls_b) + self.hass.block_till_done() + self.assertTrue(calls_b.called) + + def test_not_calling_unsubscribe_with_active_subscribers(self): + """Test not calling unsubscribe() when other subscribers are active.""" + unsub = mqtt.subscribe(self.hass, 'test/state', None) + mqtt.subscribe(self.hass, 'test/state', None) + self.hass.block_till_done() + self.assertTrue(self.hass.data['mqtt']._mqttc.subscribe.called) + + unsub() + self.hass.block_till_done() + self.assertFalse(self.hass.data['mqtt']._mqttc.unsubscribe.called) + + def test_restore_subscriptions_on_reconnect(self): + """Test subscriptions are restored on reconnect.""" + mqtt.subscribe(self.hass, 'test/state', None) + self.hass.block_till_done() + self.assertEqual(self.hass.data['mqtt']._mqttc.subscribe.call_count, 1) + + self.hass.data['mqtt']._mqtt_on_disconnect(None, None, 0) + self.hass.data['mqtt']._mqtt_on_connect(None, None, None, 0) + self.hass.block_till_done() + self.assertEqual(self.hass.data['mqtt']._mqttc.subscribe.call_count, 2) + + def test_restore_all_active_subscriptions_on_reconnect(self): + """Test active subscriptions are restored correctly on reconnect.""" + self.hass.data['mqtt']._mqttc.subscribe.side_effect = ( + (0, 1), (0, 2), (0, 3), (0, 4) + ) + + unsub = mqtt.subscribe(self.hass, 'test/state', None, qos=2) + mqtt.subscribe(self.hass, 'test/state', None) + mqtt.subscribe(self.hass, 'test/state', None, qos=1) + self.hass.block_till_done() + + expected = [ + mock.call('test/state', 2), + mock.call('test/state', 0), + mock.call('test/state', 1) + ] + self.assertEqual(self.hass.data['mqtt']._mqttc.subscribe.mock_calls, + expected) + + unsub() + self.hass.block_till_done() + self.assertEqual(self.hass.data['mqtt']._mqttc.unsubscribe.call_count, + 0) + + self.hass.data['mqtt']._mqtt_on_disconnect(None, None, 0) + self.hass.data['mqtt']._mqtt_on_connect(None, None, None, 0) + self.hass.block_till_done() + + expected.append(mock.call('test/state', 1)) + self.assertEqual(self.hass.data['mqtt']._mqttc.subscribe.mock_calls, + expected) @asyncio.coroutine @@ -426,7 +459,7 @@ def test_setup_embedded_starts_with_no_config(hass): return_value=mock_coro( return_value=(True, client_config)) ) as _start: - yield from mock_mqtt_client(hass, {}) + yield from async_mock_mqtt_client(hass, {}) assert _start.call_count == 1 @@ -440,7 +473,7 @@ def test_setup_embedded_with_embedded(hass): return_value=(True, client_config)) ) as _start: _start.return_value = mock_coro(return_value=(True, client_config)) - yield from mock_mqtt_client(hass, {'embedded': None}) + yield from async_mock_mqtt_client(hass, {'embedded': None}) assert _start.call_count == 1 @@ -544,13 +577,13 @@ def test_setup_with_tls_config_of_v1_under_python36_only_uses_v1(hass): @asyncio.coroutine def test_birth_message(hass): """Test sending birth message.""" - mqtt_client = yield from mock_mqtt_client(hass, { + mqtt_client = yield from async_mock_mqtt_client(hass, { mqtt.CONF_BROKER: 'mock-broker', mqtt.CONF_BIRTH_MESSAGE: {mqtt.ATTR_TOPIC: 'birth', mqtt.ATTR_PAYLOAD: 'birth'} }) calls = [] - mqtt_client.publish = lambda *args: calls.append(args) + mqtt_client.publish.side_effect = lambda *args: calls.append(args) hass.data['mqtt']._mqtt_on_connect(None, None, 0, 0) yield from hass.async_block_till_done() assert calls[-1] == ('birth', 'birth', 0, False) @@ -559,30 +592,26 @@ def test_birth_message(hass): @asyncio.coroutine def test_mqtt_subscribes_topics_on_connect(hass): """Test subscription to topic on connect.""" - mqtt_client = yield from mock_mqtt_client(hass) + mqtt_client = yield from async_mock_mqtt_client(hass) - subscribed_topics = OrderedDict() - subscribed_topics['topic/test'] = 1 - subscribed_topics['home/sensor'] = 2 - - wanted_topics = subscribed_topics.copy() - wanted_topics['still/pending'] = 0 - - hass.data['mqtt'].wanted_topics = wanted_topics - hass.data['mqtt'].subscribed_topics = subscribed_topics - hass.data['mqtt'].progress = {1: 'still/pending'} - - # Return values for subscribe calls (rc, mid) - mqtt_client.subscribe.side_effect = ((0, 2), (0, 3)) + hass.data['mqtt'].subscriptions = [ + mqtt.Subscription('topic/test', None), + mqtt.Subscription('home/sensor', None, 2), + mqtt.Subscription('still/pending', None), + mqtt.Subscription('still/pending', None, 1), + ] hass.add_job = mock.MagicMock() hass.data['mqtt']._mqtt_on_connect(None, None, 0, 0) yield from hass.async_block_till_done() - assert not mqtt_client.disconnect.called + assert mqtt_client.disconnect.call_count == 0 - expected = [(topic, qos) for topic, qos in wanted_topics.items()] - - assert [call[1][1:] for call in hass.add_job.mock_calls] == expected - assert hass.data['mqtt'].progress == {} + expected = { + 'topic/test': 0, + 'home/sensor': 2, + 'still/pending': 1 + } + calls = {call[1][1]: call[1][2] for call in hass.add_job.mock_calls} + assert calls == expected diff --git a/tests/components/mqtt/test_server.py b/tests/components/mqtt/test_server.py index 7ce9ec00797..9b4c0c69ac6 100644 --- a/tests/components/mqtt/test_server.py +++ b/tests/components/mqtt/test_server.py @@ -4,8 +4,7 @@ from unittest.mock import Mock, MagicMock, patch from homeassistant.setup import setup_component import homeassistant.components.mqtt as mqtt -from tests.common import ( - get_test_home_assistant, mock_coro, mock_http_component) +from tests.common import get_test_home_assistant, mock_coro class TestMQTT: @@ -14,7 +13,9 @@ class TestMQTT: def setup_method(self, method): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - mock_http_component(self.hass, 'super_secret') + setup_component(self.hass, 'http', { + 'api_password': 'super_secret' + }) def teardown_method(self, method): """Stop everything that was started.""" diff --git a/tests/components/notify/test_html5.py b/tests/components/notify/test_html5.py index 6fb2e6454de..d6c06f77d93 100644 --- a/tests/components/notify/test_html5.py +++ b/tests/components/notify/test_html5.py @@ -4,12 +4,10 @@ import json from unittest.mock import patch, MagicMock, mock_open from aiohttp.hdrs import AUTHORIZATION +from homeassistant.setup import async_setup_component from homeassistant.exceptions import HomeAssistantError -from homeassistant.util.json import save_json from homeassistant.components.notify import html5 -from tests.common import mock_http_component_app - CONFIG_FILE = 'file.conf' SUBSCRIPTION_1 = { @@ -52,6 +50,23 @@ REGISTER_URL = '/api/notify.html5' PUBLISH_URL = '/api/notify.html5/callback' +@asyncio.coroutine +def mock_client(hass, test_client, registrations=None): + """Create a test client for HTML5 views.""" + if registrations is None: + registrations = {} + + with patch('homeassistant.components.notify.html5._load_config', + return_value=registrations): + yield from async_setup_component(hass, 'notify', { + 'notify': { + 'platform': 'html5' + } + }) + + return (yield from test_client(hass.http.app)) + + class TestHtml5Notify(object): """Tests for HTML5 notify platform.""" @@ -89,8 +104,6 @@ class TestHtml5Notify(object): service.send_message('Hello', target=['device', 'non_existing'], data={'icon': 'beer.png'}) - print(mock_wp.mock_calls) - assert len(mock_wp.mock_calls) == 3 # WebPusher constructor @@ -104,421 +117,224 @@ class TestHtml5Notify(object): assert payload['body'] == 'Hello' assert payload['icon'] == 'beer.png' - @asyncio.coroutine - def test_registering_new_device_view(self, loop, test_client): - """Test that the HTML view works.""" - hass = MagicMock() - expected = { - 'unnamed device': SUBSCRIPTION_1, - } - hass.config.path.return_value = CONFIG_FILE - service = html5.get_service(hass, {}) +@asyncio.coroutine +def test_registering_new_device_view(hass, test_client): + """Test that the HTML view works.""" + client = yield from mock_client(hass, test_client) - assert service is not None - - assert len(hass.mock_calls) == 3 - - view = hass.mock_calls[1][1][0] - assert view.json_path == hass.config.path.return_value - assert view.registrations == {} - - hass.loop = loop - app = mock_http_component_app(hass) - view.register(app.router) - client = yield from test_client(app) - hass.http.is_banned_ip.return_value = False + with patch('homeassistant.components.notify.html5.save_json') as mock_save: resp = yield from client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_1)) - content = yield from resp.text() - assert resp.status == 200, content - assert view.registrations == expected + assert resp.status == 200 + assert len(mock_save.mock_calls) == 1 + assert mock_save.mock_calls[0][1][1] == { + 'unnamed device': SUBSCRIPTION_1, + } - hass.async_add_job.assert_called_with(save_json, CONFIG_FILE, expected) - @asyncio.coroutine - def test_registering_new_device_expiration_view(self, loop, test_client): - """Test that the HTML view works.""" - hass = MagicMock() - expected = { - 'unnamed device': SUBSCRIPTION_4, - } +@asyncio.coroutine +def test_registering_new_device_expiration_view(hass, test_client): + """Test that the HTML view works.""" + client = yield from mock_client(hass, test_client) - hass.config.path.return_value = CONFIG_FILE - service = html5.get_service(hass, {}) - - assert service is not None - - # assert hass.called - assert len(hass.mock_calls) == 3 - - view = hass.mock_calls[1][1][0] - assert view.json_path == hass.config.path.return_value - assert view.registrations == {} - - hass.loop = loop - app = mock_http_component_app(hass) - view.register(app.router) - client = yield from test_client(app) - hass.http.is_banned_ip.return_value = False + with patch('homeassistant.components.notify.html5.save_json') as mock_save: resp = yield from client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_4)) - content = yield from resp.text() - assert resp.status == 200, content - assert view.registrations == expected + assert resp.status == 200 + assert mock_save.mock_calls[0][1][1] == { + 'unnamed device': SUBSCRIPTION_4, + } - hass.async_add_job.assert_called_with(save_json, CONFIG_FILE, expected) - @asyncio.coroutine - def test_registering_new_device_fails_view(self, loop, test_client): - """Test subs. are not altered when registering a new device fails.""" - hass = MagicMock() - expected = {} - - hass.config.path.return_value = CONFIG_FILE - html5.get_service(hass, {}) - view = hass.mock_calls[1][1][0] - - hass.loop = loop - app = mock_http_component_app(hass) - view.register(app.router) - client = yield from test_client(app) - hass.http.is_banned_ip.return_value = False - - hass.async_add_job.side_effect = HomeAssistantError() +@asyncio.coroutine +def test_registering_new_device_fails_view(hass, test_client): + """Test subs. are not altered when registering a new device fails.""" + registrations = {} + client = yield from mock_client(hass, test_client, registrations) + with patch('homeassistant.components.notify.html5.save_json', + side_effect=HomeAssistantError()): resp = yield from client.post(REGISTER_URL, - data=json.dumps(SUBSCRIPTION_1)) + data=json.dumps(SUBSCRIPTION_4)) - content = yield from resp.text() - assert resp.status == 500, content - assert view.registrations == expected + assert resp.status == 500 + assert registrations == {} - @asyncio.coroutine - def test_registering_existing_device_view(self, loop, test_client): - """Test subscription is updated when registering existing device.""" - hass = MagicMock() - expected = { - 'unnamed device': SUBSCRIPTION_4, - } - hass.config.path.return_value = CONFIG_FILE - html5.get_service(hass, {}) - view = hass.mock_calls[1][1][0] - - hass.loop = loop - app = mock_http_component_app(hass) - view.register(app.router) - client = yield from test_client(app) - hass.http.is_banned_ip.return_value = False +@asyncio.coroutine +def test_registering_existing_device_view(hass, test_client): + """Test subscription is updated when registering existing device.""" + registrations = {} + client = yield from mock_client(hass, test_client, registrations) + with patch('homeassistant.components.notify.html5.save_json') as mock_save: yield from client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_1)) resp = yield from client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_4)) - content = yield from resp.text() - assert resp.status == 200, content - assert view.registrations == expected + assert resp.status == 200 + assert mock_save.mock_calls[0][1][1] == { + 'unnamed device': SUBSCRIPTION_4, + } + assert registrations == { + 'unnamed device': SUBSCRIPTION_4, + } - hass.async_add_job.assert_called_with(save_json, CONFIG_FILE, expected) - @asyncio.coroutine - def test_registering_existing_device_fails_view(self, loop, test_client): - """Test sub. is not updated when registering existing device fails.""" - hass = MagicMock() - expected = { - 'unnamed device': SUBSCRIPTION_1, - } - - hass.config.path.return_value = CONFIG_FILE - html5.get_service(hass, {}) - view = hass.mock_calls[1][1][0] - - hass.loop = loop - app = mock_http_component_app(hass) - view.register(app.router) - client = yield from test_client(app) - hass.http.is_banned_ip.return_value = False +@asyncio.coroutine +def test_registering_existing_device_fails_view(hass, test_client): + """Test sub. is not updated when registering existing device fails.""" + registrations = {} + client = yield from mock_client(hass, test_client, registrations) + with patch('homeassistant.components.notify.html5.save_json') as mock_save: yield from client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_1)) - - hass.async_add_job.side_effect = HomeAssistantError() + mock_save.side_effect = HomeAssistantError resp = yield from client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_4)) - content = yield from resp.text() - assert resp.status == 500, content - assert view.registrations == expected + assert resp.status == 500 + assert registrations == { + 'unnamed device': SUBSCRIPTION_1, + } - @asyncio.coroutine - def test_registering_new_device_validation(self, loop, test_client): - """Test various errors when registering a new device.""" - hass = MagicMock() - m = mock_open() - with patch( - 'homeassistant.util.json.open', - m, create=True - ): - hass.config.path.return_value = CONFIG_FILE - service = html5.get_service(hass, {}) +@asyncio.coroutine +def test_registering_new_device_validation(hass, test_client): + """Test various errors when registering a new device.""" + client = yield from mock_client(hass, test_client) - assert service is not None + resp = yield from client.post(REGISTER_URL, data=json.dumps({ + 'browser': 'invalid browser', + 'subscription': 'sub info', + })) + assert resp.status == 400 - # assert hass.called - assert len(hass.mock_calls) == 3 + resp = yield from client.post(REGISTER_URL, data=json.dumps({ + 'browser': 'chrome', + })) + assert resp.status == 400 - view = hass.mock_calls[1][1][0] + with patch('homeassistant.components.notify.html5.save_json', + return_value=False): + resp = yield from client.post(REGISTER_URL, data=json.dumps({ + 'browser': 'chrome', + 'subscription': 'sub info', + })) + assert resp.status == 400 - hass.loop = loop - app = mock_http_component_app(hass) - view.register(app.router) - client = yield from test_client(app) - hass.http.is_banned_ip.return_value = False - resp = yield from client.post(REGISTER_URL, data=json.dumps({ - 'browser': 'invalid browser', - 'subscription': 'sub info', - })) - assert resp.status == 400 +@asyncio.coroutine +def test_unregistering_device_view(hass, test_client): + """Test that the HTML unregister view works.""" + registrations = { + 'some device': SUBSCRIPTION_1, + 'other device': SUBSCRIPTION_2, + } + client = yield from mock_client(hass, test_client, registrations) - resp = yield from client.post(REGISTER_URL, data=json.dumps({ - 'browser': 'chrome', - })) - assert resp.status == 400 + with patch('homeassistant.components.notify.html5.save_json') as mock_save: + resp = yield from client.delete(REGISTER_URL, data=json.dumps({ + 'subscription': SUBSCRIPTION_1['subscription'], + })) - with patch('homeassistant.components.notify.html5.save_json', - return_value=False): - # resp = view.post(Request(builder.get_environ())) - resp = yield from client.post(REGISTER_URL, data=json.dumps({ - 'browser': 'chrome', - 'subscription': 'sub info', - })) + assert resp.status == 200 + assert len(mock_save.mock_calls) == 1 + assert registrations == { + 'other device': SUBSCRIPTION_2 + } - assert resp.status == 400 - @asyncio.coroutine - def test_unregistering_device_view(self, loop, test_client): - """Test that the HTML unregister view works.""" - hass = MagicMock() +@asyncio.coroutine +def test_unregister_device_view_handle_unknown_subscription(hass, test_client): + """Test that the HTML unregister view handles unknown subscriptions.""" + registrations = {} + client = yield from mock_client(hass, test_client, registrations) - config = { - 'some device': SUBSCRIPTION_1, - 'other device': SUBSCRIPTION_2, - } + with patch('homeassistant.components.notify.html5.save_json') as mock_save: + resp = yield from client.delete(REGISTER_URL, data=json.dumps({ + 'subscription': SUBSCRIPTION_3['subscription'] + })) - m = mock_open(read_data=json.dumps(config)) - with patch( - 'homeassistant.util.json.open', - m, create=True - ): - hass.config.path.return_value = CONFIG_FILE - service = html5.get_service(hass, {}) + assert resp.status == 200, resp.response + assert registrations == {} + assert len(mock_save.mock_calls) == 0 - assert service is not None - # assert hass.called - assert len(hass.mock_calls) == 3 +@asyncio.coroutine +def test_unregistering_device_view_handles_save_error(hass, test_client): + """Test that the HTML unregister view handles save errors.""" + registrations = { + 'some device': SUBSCRIPTION_1, + 'other device': SUBSCRIPTION_2, + } + client = yield from mock_client(hass, test_client, registrations) - view = hass.mock_calls[1][1][0] - assert view.json_path == hass.config.path.return_value - assert view.registrations == config + with patch('homeassistant.components.notify.html5.save_json', + side_effect=HomeAssistantError()): + resp = yield from client.delete(REGISTER_URL, data=json.dumps({ + 'subscription': SUBSCRIPTION_1['subscription'], + })) - hass.loop = loop - app = mock_http_component_app(hass) - view.register(app.router) - client = yield from test_client(app) - hass.http.is_banned_ip.return_value = False + assert resp.status == 500, resp.response + assert registrations == { + 'some device': SUBSCRIPTION_1, + 'other device': SUBSCRIPTION_2, + } - resp = yield from client.delete(REGISTER_URL, data=json.dumps({ - 'subscription': SUBSCRIPTION_1['subscription'], - })) - config.pop('some device') +@asyncio.coroutine +def test_callback_view_no_jwt(hass, test_client): + """Test that the notification callback view works without JWT.""" + client = yield from mock_client(hass, test_client) + resp = yield from client.post(PUBLISH_URL, data=json.dumps({ + 'type': 'push', + 'tag': '3bc28d69-0921-41f1-ac6a-7a627ba0aa72' + })) - assert resp.status == 200, resp.response - assert view.registrations == config + assert resp.status == 401, resp.response - hass.async_add_job.assert_called_with(save_json, CONFIG_FILE, - config) - @asyncio.coroutine - def test_unregister_device_view_handle_unknown_subscription( - self, loop, test_client): - """Test that the HTML unregister view handles unknown subscriptions.""" - hass = MagicMock() +@asyncio.coroutine +def test_callback_view_with_jwt(hass, test_client): + """Test that the notification callback view works with JWT.""" + registrations = { + 'device': SUBSCRIPTION_1 + } + client = yield from mock_client(hass, test_client, registrations) - config = { - 'some device': SUBSCRIPTION_1, - 'other device': SUBSCRIPTION_2, - } + with patch('pywebpush.WebPusher') as mock_wp: + yield from hass.services.async_call('notify', 'notify', { + 'message': 'Hello', + 'target': ['device'], + 'data': {'icon': 'beer.png'} + }, blocking=True) - m = mock_open(read_data=json.dumps(config)) - with patch( - 'homeassistant.util.json.open', - m, create=True - ): - hass.config.path.return_value = CONFIG_FILE - service = html5.get_service(hass, {}) + assert len(mock_wp.mock_calls) == 3 - assert service is not None + # WebPusher constructor + assert mock_wp.mock_calls[0][1][0] == \ + SUBSCRIPTION_1['subscription'] + # Third mock_call checks the status_code of the response. + assert mock_wp.mock_calls[2][0] == '().send().status_code.__eq__' - # assert hass.called - assert len(hass.mock_calls) == 3 + # Call to send + push_payload = json.loads(mock_wp.mock_calls[1][1][0]) - view = hass.mock_calls[1][1][0] - assert view.json_path == hass.config.path.return_value - assert view.registrations == config + assert push_payload['body'] == 'Hello' + assert push_payload['icon'] == 'beer.png' - hass.loop = loop - app = mock_http_component_app(hass) - view.register(app.router) - client = yield from test_client(app) - hass.http.is_banned_ip.return_value = False + bearer_token = "Bearer {}".format(push_payload['data']['jwt']) - resp = yield from client.delete(REGISTER_URL, data=json.dumps({ - 'subscription': SUBSCRIPTION_3['subscription'] - })) + resp = yield from client.post(PUBLISH_URL, json={ + 'type': 'push', + }, headers={AUTHORIZATION: bearer_token}) - assert resp.status == 200, resp.response - assert view.registrations == config - - hass.async_add_job.assert_not_called() - - @asyncio.coroutine - def test_unregistering_device_view_handles_save_error( - self, loop, test_client): - """Test that the HTML unregister view handles save errors.""" - hass = MagicMock() - - config = { - 'some device': SUBSCRIPTION_1, - 'other device': SUBSCRIPTION_2, - } - - m = mock_open(read_data=json.dumps(config)) - with patch( - 'homeassistant.util.json.open', - m, create=True - ): - hass.config.path.return_value = CONFIG_FILE - service = html5.get_service(hass, {}) - - assert service is not None - - # assert hass.called - assert len(hass.mock_calls) == 3 - - view = hass.mock_calls[1][1][0] - assert view.json_path == hass.config.path.return_value - assert view.registrations == config - - hass.loop = loop - app = mock_http_component_app(hass) - view.register(app.router) - client = yield from test_client(app) - hass.http.is_banned_ip.return_value = False - - hass.async_add_job.side_effect = HomeAssistantError() - resp = yield from client.delete(REGISTER_URL, data=json.dumps({ - 'subscription': SUBSCRIPTION_1['subscription'], - })) - - assert resp.status == 500, resp.response - assert view.registrations == config - - @asyncio.coroutine - def test_callback_view_no_jwt(self, loop, test_client): - """Test that the notification callback view works without JWT.""" - hass = MagicMock() - - m = mock_open() - with patch( - 'homeassistant.util.json.open', - m, create=True - ): - hass.config.path.return_value = CONFIG_FILE - service = html5.get_service(hass, {}) - - assert service is not None - - # assert hass.called - assert len(hass.mock_calls) == 3 - - view = hass.mock_calls[2][1][0] - - hass.loop = loop - app = mock_http_component_app(hass) - view.register(app.router) - client = yield from test_client(app) - hass.http.is_banned_ip.return_value = False - - resp = yield from client.post(PUBLISH_URL, data=json.dumps({ - 'type': 'push', - 'tag': '3bc28d69-0921-41f1-ac6a-7a627ba0aa72' - })) - - assert resp.status == 401, resp.response - - @asyncio.coroutine - def test_callback_view_with_jwt(self, loop, test_client): - """Test that the notification callback view works with JWT.""" - hass = MagicMock() - - data = { - 'device': SUBSCRIPTION_1 - } - - m = mock_open(read_data=json.dumps(data)) - with patch( - 'homeassistant.util.json.open', - m, create=True - ): - hass.config.path.return_value = CONFIG_FILE - service = html5.get_service(hass, {'gcm_sender_id': '100'}) - - assert service is not None - - # assert hass.called - assert len(hass.mock_calls) == 3 - - with patch('pywebpush.WebPusher') as mock_wp: - service.send_message( - 'Hello', target=['device'], data={'icon': 'beer.png'}) - - assert len(mock_wp.mock_calls) == 3 - - # WebPusher constructor - assert mock_wp.mock_calls[0][1][0] == \ - SUBSCRIPTION_1['subscription'] - # Third mock_call checks the status_code of the response. - assert mock_wp.mock_calls[2][0] == '().send().status_code.__eq__' - - # Call to send - push_payload = json.loads(mock_wp.mock_calls[1][1][0]) - - assert push_payload['body'] == 'Hello' - assert push_payload['icon'] == 'beer.png' - - view = hass.mock_calls[2][1][0] - view.registrations = data - - bearer_token = "Bearer {}".format(push_payload['data']['jwt']) - - hass.loop = loop - app = mock_http_component_app(hass) - view.register(app.router) - client = yield from test_client(app) - hass.http.is_banned_ip.return_value = False - - resp = yield from client.post(PUBLISH_URL, data=json.dumps({ - 'type': 'push', - }), headers={AUTHORIZATION: bearer_token}) - - assert resp.status == 200 - body = yield from resp.json() - assert body == {"event": "push", "status": "ok"} + assert resp.status == 200 + body = yield from resp.json() + assert body == {"event": "push", "status": "ok"} diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 58b8dc1f839..191c0d6e733 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -42,6 +42,7 @@ class TestRecorder(unittest.TestCase): with session_scope(hass=self.hass) as session: db_states = list(session.query(States)) assert len(db_states) == 1 + assert db_states[0].event_id > 0 state = db_states[0].to_native() assert state == self.hass.states.get(entity_id) diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index c429ee2fbbb..2ae039b6712 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -16,9 +16,8 @@ class TestRecorderPurge(unittest.TestCase): def setUp(self): # pylint: disable=invalid-name """Setup things to be run when tests are started.""" - config = {'purge_keep_days': 4, 'purge_interval': 2} self.hass = get_test_home_assistant() - init_recorder_component(self.hass, config) + init_recorder_component(self.hass) self.hass.start() def tearDown(self): # pylint: disable=invalid-name @@ -29,14 +28,18 @@ class TestRecorderPurge(unittest.TestCase): """Add multiple states to the db for testing.""" now = datetime.now() five_days_ago = now - timedelta(days=5) + eleven_days_ago = now - timedelta(days=11) attributes = {'test_attr': 5, 'test_attr_10': 'nice'} self.hass.block_till_done() self.hass.data[DATA_INSTANCE].block_till_done() with recorder.session_scope(hass=self.hass) as session: - for event_id in range(5): - if event_id < 3: + for event_id in range(6): + if event_id < 2: + timestamp = eleven_days_ago + state = 'autopurgeme' + elif event_id < 4: timestamp = five_days_ago state = 'purgeme' else: @@ -65,9 +68,9 @@ class TestRecorderPurge(unittest.TestCase): domain='sensor', state='iamprotected', attributes=json.dumps(attributes), - last_changed=five_days_ago, - last_updated=five_days_ago, - created=five_days_ago, + last_changed=eleven_days_ago, + last_updated=eleven_days_ago, + created=eleven_days_ago, event_id=protected_event_id )) @@ -75,14 +78,18 @@ class TestRecorderPurge(unittest.TestCase): """Add a few events for testing.""" now = datetime.now() five_days_ago = now - timedelta(days=5) + eleven_days_ago = now - timedelta(days=11) event_data = {'test_attr': 5, 'test_attr_10': 'nice'} self.hass.block_till_done() self.hass.data[DATA_INSTANCE].block_till_done() with recorder.session_scope(hass=self.hass) as session: - for event_id in range(5): + for event_id in range(6): if event_id < 2: + timestamp = eleven_days_ago + event_type = 'EVENT_TEST_AUTOPURGE' + elif event_id < 4: timestamp = five_days_ago event_type = 'EVENT_TEST_PURGE' else: @@ -102,8 +109,8 @@ class TestRecorderPurge(unittest.TestCase): event_type='EVENT_TEST_FOR_PROTECTED', event_data=json.dumps(event_data), origin='LOCAL', - created=five_days_ago, - time_fired=five_days_ago, + created=eleven_days_ago, + time_fired=eleven_days_ago, ) session.add(protected_event) session.flush() @@ -113,13 +120,13 @@ class TestRecorderPurge(unittest.TestCase): def test_purge_old_states(self): """Test deleting old states.""" self._add_test_states() - # make sure we start with 6 states + # make sure we start with 7 states with session_scope(hass=self.hass) as session: states = session.query(States) - self.assertEqual(states.count(), 6) + self.assertEqual(states.count(), 7) # run purge_old_data() - purge_old_data(self.hass.data[DATA_INSTANCE], 4) + purge_old_data(self.hass.data[DATA_INSTANCE], 4, repack=False) # we should only have 3 states left after purging self.assertEqual(states.count(), 3) @@ -131,13 +138,13 @@ class TestRecorderPurge(unittest.TestCase): with session_scope(hass=self.hass) as session: events = session.query(Events).filter( Events.event_type.like("EVENT_TEST%")) - self.assertEqual(events.count(), 6) + self.assertEqual(events.count(), 7) # run purge_old_data() - purge_old_data(self.hass.data[DATA_INSTANCE], 4) + purge_old_data(self.hass.data[DATA_INSTANCE], 4, repack=False) - # now we should only have 3 events left - self.assertEqual(events.count(), 3) + # no state to protect, now we should only have 2 events left + self.assertEqual(events.count(), 2) def test_purge_method(self): """Test purge method.""" @@ -148,24 +155,24 @@ class TestRecorderPurge(unittest.TestCase): # make sure we start with 6 states with session_scope(hass=self.hass) as session: states = session.query(States) - self.assertEqual(states.count(), 6) + self.assertEqual(states.count(), 7) events = session.query(Events).filter( Events.event_type.like("EVENT_TEST%")) - self.assertEqual(events.count(), 6) + self.assertEqual(events.count(), 7) self.hass.data[DATA_INSTANCE].block_till_done() - # run purge method - no service data, should not work + # run purge method - no service data, use defaults self.hass.services.call('recorder', 'purge') self.hass.async_block_till_done() # Small wait for recorder thread self.hass.data[DATA_INSTANCE].block_till_done() - # we should still have everything from before - self.assertEqual(states.count(), 6) - self.assertEqual(events.count(), 6) + # only purged old events + self.assertEqual(states.count(), 5) + self.assertEqual(events.count(), 5) # run purge method - correct service data self.hass.services.call('recorder', 'purge', @@ -182,11 +189,20 @@ class TestRecorderPurge(unittest.TestCase): self.assertTrue('iamprotected' in ( state.state for state in states)) - # now we should only have 4 events left - self.assertEqual(events.count(), 4) + # now we should only have 3 events left + self.assertEqual(events.count(), 3) # and the protected event is among them self.assertTrue('EVENT_TEST_FOR_PROTECTED' in ( event.event_type for event in events.all())) self.assertFalse('EVENT_TEST_PURGE' in ( event.event_type for event in events.all())) + + # run purge method - correct service data, with repack + service_data['repack'] = True + self.assertFalse(self.hass.data[DATA_INSTANCE].did_vacuum) + self.hass.services.call('recorder', 'purge', + service_data=service_data) + self.hass.async_block_till_done() + self.hass.data[DATA_INSTANCE].block_till_done() + self.assertTrue(self.hass.data[DATA_INSTANCE].did_vacuum) diff --git a/tests/components/sensor/test_command_line.py b/tests/components/sensor/test_command_line.py index 6eb97b41e11..bc073a04c47 100644 --- a/tests/components/sensor/test_command_line.py +++ b/tests/components/sensor/test_command_line.py @@ -3,7 +3,6 @@ import unittest from homeassistant.helpers.template import Template from homeassistant.components.sensor import command_line -from homeassistant import setup from tests.common import get_test_home_assistant @@ -40,16 +39,6 @@ class TestCommandSensorSensor(unittest.TestCase): self.assertEqual('in', entity.unit_of_measurement) self.assertEqual('5', entity.state) - def test_setup_bad_config(self): - """Test setup with a bad configuration.""" - config = {'name': 'test', - 'platform': 'not_command_line', - } - - self.assertFalse(setup.setup_component(self.hass, 'test', { - 'command_line': config, - })) - def test_template(self): """Test command sensor with template.""" data = command_line.CommandSensorData(self.hass, 'echo 50') diff --git a/tests/components/sensor/test_filesize.py b/tests/components/sensor/test_filesize.py new file mode 100644 index 00000000000..23ef1c6081b --- /dev/null +++ b/tests/components/sensor/test_filesize.py @@ -0,0 +1,58 @@ +"""The tests for the filesize sensor.""" +import unittest +import os + +from homeassistant.components.sensor.filesize import CONF_FILE_PATHS +from homeassistant.setup import setup_component +from tests.common import get_test_home_assistant + + +TEST_DIR = os.path.join(os.path.dirname(__file__)) +TEST_FILE = os.path.join(TEST_DIR, 'mock_file_test_filesize.txt') + + +def create_file(path): + """Create a test file.""" + with open(path, 'w') as test_file: + test_file.write("test") + + +class TestFileSensor(unittest.TestCase): + """Test the filesize sensor.""" + + def setup_method(self, method): + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.hass.config.whitelist_external_dirs = set((TEST_DIR)) + + def teardown_method(self, method): + """Stop everything that was started.""" + if os.path.isfile(TEST_FILE): + os.remove(TEST_FILE) + self.hass.stop() + + def test_invalid_path(self): + """Test that an invalid path is caught.""" + config = { + 'sensor': { + 'platform': 'filesize', + CONF_FILE_PATHS: ['invalid_path']} + } + self.assertTrue( + setup_component(self.hass, 'sensor', config)) + assert len(self.hass.states.entity_ids()) == 0 + + def test_valid_path(self): + """Test for a valid path.""" + create_file(TEST_FILE) + config = { + 'sensor': { + 'platform': 'filesize', + CONF_FILE_PATHS: [TEST_FILE]} + } + self.assertTrue( + setup_component(self.hass, 'sensor', config)) + assert len(self.hass.states.entity_ids()) == 1 + state = self.hass.states.get('sensor.mock_file_test_filesizetxt') + assert state.state == '0.0' + assert state.attributes.get('bytes') == 4 diff --git a/tests/components/sensor/test_folder.py b/tests/components/sensor/test_folder.py new file mode 100644 index 00000000000..85ae8a688e7 --- /dev/null +++ b/tests/components/sensor/test_folder.py @@ -0,0 +1,64 @@ +"""The tests for the folder sensor.""" +import unittest +import os + +from homeassistant.components.sensor.folder import CONF_FOLDER_PATHS +from homeassistant.setup import setup_component +from tests.common import get_test_home_assistant + + +CWD = os.path.join(os.path.dirname(__file__)) +TEST_FOLDER = 'test_folder' +TEST_DIR = os.path.join(CWD, TEST_FOLDER) +TEST_TXT = 'mock_test_folder.txt' +TEST_FILE = os.path.join(TEST_DIR, TEST_TXT) + + +def create_file(path): + """Create a test file.""" + with open(path, 'w') as test_file: + test_file.write("test") + + +class TestFolderSensor(unittest.TestCase): + """Test the filesize sensor.""" + + def setup_method(self, method): + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + if not os.path.isdir(TEST_DIR): + os.mkdir(TEST_DIR) + self.hass.config.whitelist_external_dirs = set((TEST_DIR)) + + def teardown_method(self, method): + """Stop everything that was started.""" + if os.path.isfile(TEST_FILE): + os.remove(TEST_FILE) + os.rmdir(TEST_DIR) + self.hass.stop() + + def test_invalid_path(self): + """Test that an invalid path is caught.""" + config = { + 'sensor': { + 'platform': 'folder', + CONF_FOLDER_PATHS: 'invalid_path'} + } + self.assertTrue( + setup_component(self.hass, 'sensor', config)) + assert len(self.hass.states.entity_ids()) == 0 + + def test_valid_path(self): + """Test for a valid path.""" + create_file(TEST_FILE) + config = { + 'sensor': { + 'platform': 'folder', + CONF_FOLDER_PATHS: TEST_DIR} + } + self.assertTrue( + setup_component(self.hass, 'sensor', config)) + assert len(self.hass.states.entity_ids()) == 1 + state = self.hass.states.get('sensor.test_folder') + assert state.state == '0.0' + assert state.attributes.get('number_of_files') == 1 diff --git a/tests/components/sensor/test_startca.py b/tests/components/sensor/test_startca.py new file mode 100644 index 00000000000..95da1c93a0c --- /dev/null +++ b/tests/components/sensor/test_startca.py @@ -0,0 +1,215 @@ +"""Tests for the Start.ca sensor platform.""" +import asyncio +from homeassistant.bootstrap import async_setup_component +from homeassistant.components.sensor.startca import StartcaData +from homeassistant.helpers.aiohttp_client import async_get_clientsession + + +@asyncio.coroutine +def test_capped_setup(hass, aioclient_mock): + """Test the default setup.""" + config = {'platform': 'startca', + 'api_key': 'NOTAKEY', + 'total_bandwidth': 400, + 'monitored_variables': [ + 'usage', + 'usage_gb', + 'limit', + 'used_download', + 'used_upload', + 'used_total', + 'grace_download', + 'grace_upload', + 'grace_total', + 'total_download', + 'total_upload', + 'used_remaining']} + + result = ''\ + ''\ + '1.1'\ + ' '\ + '304946829777'\ + '6480700153'\ + ''\ + ' '\ + '304946829777'\ + '6480700153'\ + ''\ + ' '\ + '304946829777'\ + '6480700153'\ + ''\ + '' + aioclient_mock.get('https://www.start.ca/support/usage/api?key=' + 'NOTAKEY', + text=result) + + yield from async_setup_component(hass, 'sensor', {'sensor': config}) + + state = hass.states.get('sensor.startca_usage_ratio') + assert state.attributes.get('unit_of_measurement') == '%' + assert state.state == '76.24' + + state = hass.states.get('sensor.startca_usage') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '304.95' + + state = hass.states.get('sensor.startca_data_limit') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '400' + + state = hass.states.get('sensor.startca_used_download') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '304.95' + + state = hass.states.get('sensor.startca_used_upload') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '6.48' + + state = hass.states.get('sensor.startca_used_total') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '311.43' + + state = hass.states.get('sensor.startca_grace_download') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '304.95' + + state = hass.states.get('sensor.startca_grace_upload') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '6.48' + + state = hass.states.get('sensor.startca_grace_total') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '311.43' + + state = hass.states.get('sensor.startca_total_download') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '304.95' + + state = hass.states.get('sensor.startca_total_upload') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '6.48' + + state = hass.states.get('sensor.startca_remaining') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '95.05' + + +@asyncio.coroutine +def test_unlimited_setup(hass, aioclient_mock): + """Test the default setup.""" + config = {'platform': 'startca', + 'api_key': 'NOTAKEY', + 'total_bandwidth': 0, + 'monitored_variables': [ + 'usage', + 'usage_gb', + 'limit', + 'used_download', + 'used_upload', + 'used_total', + 'grace_download', + 'grace_upload', + 'grace_total', + 'total_download', + 'total_upload', + 'used_remaining']} + + result = ''\ + ''\ + '1.1'\ + ' '\ + '304946829777'\ + '6480700153'\ + ''\ + ' '\ + '0'\ + '0'\ + ''\ + ' '\ + '304946829777'\ + '6480700153'\ + ''\ + '' + aioclient_mock.get('https://www.start.ca/support/usage/api?key=' + 'NOTAKEY', + text=result) + + yield from async_setup_component(hass, 'sensor', {'sensor': config}) + + state = hass.states.get('sensor.startca_usage_ratio') + assert state.attributes.get('unit_of_measurement') == '%' + assert state.state == '0' + + state = hass.states.get('sensor.startca_usage') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '0.0' + + state = hass.states.get('sensor.startca_data_limit') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == 'inf' + + state = hass.states.get('sensor.startca_used_download') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '0.0' + + state = hass.states.get('sensor.startca_used_upload') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '0.0' + + state = hass.states.get('sensor.startca_used_total') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '0.0' + + state = hass.states.get('sensor.startca_grace_download') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '304.95' + + state = hass.states.get('sensor.startca_grace_upload') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '6.48' + + state = hass.states.get('sensor.startca_grace_total') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '311.43' + + state = hass.states.get('sensor.startca_total_download') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '304.95' + + state = hass.states.get('sensor.startca_total_upload') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '6.48' + + state = hass.states.get('sensor.startca_remaining') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == 'inf' + + +@asyncio.coroutine +def test_bad_return_code(hass, aioclient_mock): + """Test handling a return code that isn't HTTP OK.""" + aioclient_mock.get('https://www.start.ca/support/usage/api?key=' + 'NOTAKEY', + status=404) + + scd = StartcaData(hass.loop, async_get_clientsession(hass), + 'NOTAKEY', 400) + + result = yield from scd.async_update() + assert result is False + + +@asyncio.coroutine +def test_bad_json_decode(hass, aioclient_mock): + """Test decoding invalid json result.""" + aioclient_mock.get('https://www.start.ca/support/usage/api?key=' + 'NOTAKEY', + text='this is not xml') + + scd = StartcaData(hass.loop, async_get_clientsession(hass), + 'NOTAKEY', 400) + + result = yield from scd.async_update() + assert result is False diff --git a/tests/components/sensor/test_template.py b/tests/components/sensor/test_template.py index 3033b41b142..5e258bc9245 100644 --- a/tests/components/sensor/test_template.py +++ b/tests/components/sensor/test_template.py @@ -104,6 +104,33 @@ class TestTemplateSensor: state = self.hass.states.get('sensor.test_template_sensor') assert state.attributes['entity_picture'] == '/local/sensor.png' + def test_friendly_name_template(self): + """Test friendly_name template.""" + with assert_setup_component(1): + assert setup_component(self.hass, 'sensor', { + 'sensor': { + 'platform': 'template', + 'sensors': { + 'test_template_sensor': { + 'value_template': "State", + 'friendly_name_template': + "It {{ states.sensor.test_state.state }}." + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + state = self.hass.states.get('sensor.test_template_sensor') + assert state.attributes.get('friendly_name') == 'It .' + + self.hass.states.set('sensor.test_state', 'Works') + self.hass.block_till_done() + state = self.hass.states.get('sensor.test_template_sensor') + assert state.attributes['friendly_name'] == 'It Works.' + def test_template_syntax_error(self): """Test templating syntax error.""" with assert_setup_component(0): diff --git a/tests/components/sensor/test_wunderground.py b/tests/components/sensor/test_wunderground.py index c1508f49851..27047ba0ad0 100644 --- a/tests/components/sensor/test_wunderground.py +++ b/tests/components/sensor/test_wunderground.py @@ -1,13 +1,14 @@ """The tests for the WUnderground platform.""" -import unittest +import asyncio +import aiohttp + +from pytest import raises from homeassistant.components.sensor import wunderground from homeassistant.const import TEMP_CELSIUS, LENGTH_INCHES, STATE_UNKNOWN from homeassistant.exceptions import PlatformNotReady - -from requests.exceptions import ConnectionError - -from tests.common import get_test_home_assistant +from homeassistant.setup import async_setup_component +from tests.common import load_fixture, assert_setup_component VALID_CONFIG_PWS = { 'platform': 'wunderground', @@ -21,6 +22,7 @@ VALID_CONFIG_PWS = { VALID_CONFIG = { 'platform': 'wunderground', 'api_key': 'foo', + 'lang': 'EN', 'monitored_conditions': [ 'weather', 'feelslike_c', 'alerts', 'elevation', 'location', 'weather_1d_metric', 'precip_1d_in' @@ -37,268 +39,107 @@ INVALID_CONFIG = { ] } -FEELS_LIKE = '40' -WEATHER = 'Clear' -HTTPS_ICON_URL = 'https://icons.wxug.com/i/c/k/clear.gif' -ALERT_MESSAGE = 'This is a test alert message' -ALERT_ICON = 'mdi:alert-circle-outline' -FORECAST_TEXT = 'Mostly Cloudy. Fog overnight.' -PRECIP_IN = 0.03 +URL = 'http://api.wunderground.com/api/foo/alerts/conditions/forecast/lang' \ + ':EN/q/32.87336,-117.22743.json' +PWS_URL = 'http://api.wunderground.com/api/foo/alerts/conditions/' \ + 'lang:EN/q/pws:bar.json' +INVALID_URL = 'http://api.wunderground.com/api/BOB/alerts/conditions/' \ + 'lang:foo/q/pws:bar.json' -def mocked_requests_get(*args, **kwargs): - """Mock requests.get invocations.""" - class MockResponse: - """Class to represent a mocked response.""" +@asyncio.coroutine +def test_setup(hass, aioclient_mock): + """Test that the component is loaded.""" + aioclient_mock.get(URL, text=load_fixture('wunderground-valid.json')) - def __init__(self, json_data, status_code): - """Initialize the mock response class.""" - self.json_data = json_data - self.status_code = status_code - - def json(self): - """Return the json of the response.""" - return self.json_data - - if str(args[0]).startswith('http://api.wunderground.com/api/foo/'): - return MockResponse({ - "response": { - "version": "0.1", - "termsofService": - "http://www.wunderground.com/weather/api/d/terms.html", - "features": { - "conditions": 1, - "alerts": 1, - "forecast": 1, - } - }, "current_observation": { - "image": { - "url": - 'http://icons.wxug.com/graphics/wu2/logo_130x80.png', - "title": "Weather Underground", - "link": "http://www.wunderground.com" - }, - "feelslike_c": FEELS_LIKE, - "weather": WEATHER, - "icon_url": 'http://icons.wxug.com/i/c/k/clear.gif', - "display_location": { - "city": "Holly Springs", - "country": "US", - "full": "Holly Springs, NC" - }, - "observation_location": { - "elevation": "413 ft", - "full": "Twin Lake, Holly Springs, North Carolina" - }, - }, "alerts": [ - { - "type": 'FLO', - "description": "Areal Flood Warning", - "date": "9:36 PM CDT on September 22, 2016", - "expires": "10:00 AM CDT on September 23, 2016", - "message": ALERT_MESSAGE, - }, - - ], "forecast": { - "txt_forecast": { - "date": "22:35 CEST", - "forecastday": [ - { - "period": 0, - "icon_url": - "http://icons.wxug.com/i/c/k/clear.gif", - "title": "Tuesday", - "fcttext": FORECAST_TEXT, - "fcttext_metric": FORECAST_TEXT, - "pop": "0" - }, - ], - }, "simpleforecast": { - "forecastday": [ - { - "date": { - "pretty": "19:00 CEST 4. Duben 2017", - }, - "period": 1, - "high": { - "fahrenheit": "56", - "celsius": "13", - }, - "low": { - "fahrenheit": "43", - "celsius": "6", - }, - "conditions": "Možnost deště", - "icon_url": - "http://icons.wxug.com/i/c/k/chancerain.gif", - "qpf_allday": { - "in": PRECIP_IN, - "mm": 1, - }, - "maxwind": { - "mph": 0, - "kph": 0, - "dir": "", - "degrees": 0, - }, - "avewind": { - "mph": 0, - "kph": 0, - "dir": "severní", - "degrees": 0 - } - }, - ], - }, - }, - }, 200) - else: - return MockResponse({ - "response": { - "version": "0.1", - "termsofService": - "http://www.wunderground.com/weather/api/d/terms.html", - "features": {}, - "error": { - "type": "keynotfound", - "description": "this key does not exist" - } - } - }, 200) + with assert_setup_component(1, 'sensor'): + yield from async_setup_component(hass, 'sensor', + {'sensor': VALID_CONFIG}) -def mocked_requests_get_invalid(*args, **kwargs): - """Mock requests.get invocations invalid data.""" - class MockResponse: - """Class to represent a mocked response.""" +@asyncio.coroutine +def test_setup_pws(hass, aioclient_mock): + """Test that the component is loaded with PWS id.""" + aioclient_mock.get(PWS_URL, text=load_fixture('wunderground-valid.json')) - def __init__(self, json_data, status_code): - """Initialize the mock response class.""" - self.json_data = json_data - self.status_code = status_code - - def json(self): - """Return the json of the response.""" - return self.json_data - - return MockResponse({ - "response": { - "version": "0.1", - "termsofService": - "http://www.wunderground.com/weather/api/d/terms.html", - "features": { - "conditions": 1, - "alerts": 1, - "forecast": 1, - } - }, "current_observation": { - "image": { - "url": - 'http://icons.wxug.com/graphics/wu2/logo_130x80.png', - "title": "Weather Underground", - "link": "http://www.wunderground.com" - }, - }, - }, 200) + with assert_setup_component(1, 'sensor'): + yield from async_setup_component(hass, 'sensor', + {'sensor': VALID_CONFIG_PWS}) -class TestWundergroundSetup(unittest.TestCase): - """Test the WUnderground platform.""" +@asyncio.coroutine +def test_setup_invalid(hass, aioclient_mock): + """Test that the component is not loaded with invalid config.""" + aioclient_mock.get(INVALID_URL, + text=load_fixture('wunderground-error.json')) - # pylint: disable=invalid-name - DEVICES = [] + with assert_setup_component(0, 'sensor'): + yield from async_setup_component(hass, 'sensor', + {'sensor': INVALID_CONFIG}) - def add_devices(self, devices): - """Mock add devices.""" - for device in devices: - self.DEVICES.append(device) - def setUp(self): - """Initialize values for this testcase class.""" - self.DEVICES = [] - self.hass = get_test_home_assistant() - self.key = 'foo' - self.config = VALID_CONFIG_PWS - self.lat = 37.8267 - self.lon = -122.423 - self.hass.config.latitude = self.lat - self.hass.config.longitude = self.lon +@asyncio.coroutine +def test_sensor(hass, aioclient_mock): + """Test the WUnderground sensor class and methods.""" + aioclient_mock.get(URL, text=load_fixture('wunderground-valid.json')) - def tearDown(self): # pylint: disable=invalid-name - """Stop everything that was started.""" - self.hass.stop() + yield from async_setup_component(hass, 'sensor', {'sensor': VALID_CONFIG}) - @unittest.mock.patch('requests.get', side_effect=mocked_requests_get) - def test_setup(self, req_mock): - """Test that the component is loaded if passed in PWS Id.""" - self.assertTrue( - wunderground.setup_platform(self.hass, VALID_CONFIG_PWS, - self.add_devices, None)) - self.assertTrue( - wunderground.setup_platform(self.hass, VALID_CONFIG, - self.add_devices, None)) + state = hass.states.get('sensor.pws_weather') + assert state.state == 'Clear' + assert state.name == "Weather Summary" + assert 'unit_of_measurement' not in state.attributes + assert state.attributes['entity_picture'] == \ + 'https://icons.wxug.com/i/c/k/clear.gif' - with self.assertRaises(PlatformNotReady): - wunderground.setup_platform(self.hass, INVALID_CONFIG, - self.add_devices, None) + state = hass.states.get('sensor.pws_alerts') + assert state.state == '1' + assert state.name == 'Alerts' + assert state.attributes['Message'] == \ + "This is a test alert message" + assert state.attributes['icon'] == 'mdi:alert-circle-outline' + assert 'entity_picture' not in state.attributes - @unittest.mock.patch('requests.get', side_effect=mocked_requests_get) - def test_sensor(self, req_mock): - """Test the WUnderground sensor class and methods.""" - wunderground.setup_platform(self.hass, VALID_CONFIG, self.add_devices, - None) - for device in self.DEVICES: - device.update() - entity_id = device.entity_id - friendly_name = device.name - self.assertTrue(entity_id.startswith('sensor.pws_')) - if entity_id == 'sensor.pws_weather': - self.assertEqual(HTTPS_ICON_URL, device.entity_picture) - self.assertEqual(WEATHER, device.state) - self.assertIsNone(device.unit_of_measurement) - self.assertEqual("Weather Summary", friendly_name) - elif entity_id == 'sensor.pws_alerts': - self.assertEqual(1, device.state) - self.assertEqual(ALERT_MESSAGE, - device.device_state_attributes['Message']) - self.assertEqual(ALERT_ICON, device.icon) - self.assertIsNone(device.entity_picture) - self.assertEqual('Alerts', friendly_name) - elif entity_id == 'sensor.pws_location': - self.assertEqual('Holly Springs, NC', device.state) - self.assertEqual('Location', friendly_name) - elif entity_id == 'sensor.pws_elevation': - self.assertEqual('413', device.state) - self.assertEqual('Elevation', friendly_name) - elif entity_id == 'sensor.pws_feelslike_c': - self.assertIsNone(device.entity_picture) - self.assertEqual(FEELS_LIKE, device.state) - self.assertEqual(TEMP_CELSIUS, device.unit_of_measurement) - self.assertEqual("Feels Like", friendly_name) - elif entity_id == 'sensor.pws_weather_1d_metric': - self.assertEqual(FORECAST_TEXT, device.state) - self.assertEqual('Tuesday', friendly_name) - else: - self.assertEqual(entity_id, 'sensor.pws_precip_1d_in') - self.assertEqual(PRECIP_IN, device.state) - self.assertEqual(LENGTH_INCHES, device.unit_of_measurement) - self.assertEqual('Precipitation Intensity Today', - friendly_name) + state = hass.states.get('sensor.pws_location') + assert state.state == "Holly Springs, NC" + assert state.name == 'Location' - @unittest.mock.patch('requests.get', - side_effect=ConnectionError('test exception')) - def test_connect_failed(self, req_mock): - """Test the WUnderground connection error.""" - with self.assertRaises(PlatformNotReady): - wunderground.setup_platform(self.hass, VALID_CONFIG, - self.add_devices, None) + state = hass.states.get('sensor.pws_elevation') + assert state.state == '413' + assert state.name == 'Elevation' - @unittest.mock.patch('requests.get', - side_effect=mocked_requests_get_invalid) - def test_invalid_data(self, req_mock): - """Test the WUnderground invalid data.""" - wunderground.setup_platform(self.hass, VALID_CONFIG_PWS, - self.add_devices, None) - for device in self.DEVICES: - device.update() - self.assertEqual(STATE_UNKNOWN, device.state) + state = hass.states.get('sensor.pws_feelslike_c') + assert state.state == '40' + assert state.name == "Feels Like" + assert 'entity_picture' not in state.attributes + assert state.attributes['unit_of_measurement'] == TEMP_CELSIUS + + state = hass.states.get('sensor.pws_weather_1d_metric') + assert state.state == "Mostly Cloudy. Fog overnight." + assert state.name == 'Tuesday' + + state = hass.states.get('sensor.pws_precip_1d_in') + assert state.state == '0.03' + assert state.name == "Precipitation Intensity Today" + assert state.attributes['unit_of_measurement'] == LENGTH_INCHES + + +@asyncio.coroutine +def test_connect_failed(hass, aioclient_mock): + """Test the WUnderground connection error.""" + aioclient_mock.get(URL, exc=aiohttp.ClientError()) + with raises(PlatformNotReady): + yield from wunderground.async_setup_platform(hass, VALID_CONFIG, + lambda _: None) + + +@asyncio.coroutine +def test_invalid_data(hass, aioclient_mock): + """Test the WUnderground invalid data.""" + aioclient_mock.get(URL, text=load_fixture('wunderground-invalid.json')) + + yield from async_setup_component(hass, 'sensor', {'sensor': VALID_CONFIG}) + + for condition in VALID_CONFIG['monitored_conditions']: + state = hass.states.get('sensor.pws_' + condition) + assert state.state == STATE_UNKNOWN diff --git a/tests/components/switch/test_mqtt.py b/tests/components/switch/test_mqtt.py index 661f570e698..f79d0706321 100644 --- a/tests/components/switch/test_mqtt.py +++ b/tests/components/switch/test_mqtt.py @@ -70,16 +70,17 @@ class TestSwitchMQTT(unittest.TestCase): switch.turn_on(self.hass, 'switch.test') self.hass.block_till_done() - self.assertEqual(('command-topic', 'beer on', 2, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'command-topic', 'beer on', 2, False) + self.mock_publish.async_publish.reset_mock() state = self.hass.states.get('switch.test') self.assertEqual(STATE_ON, state.state) switch.turn_off(self.hass, 'switch.test') self.hass.block_till_done() - self.assertEqual(('command-topic', 'beer off', 2, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'command-topic', 'beer off', 2, False) state = self.hass.states.get('switch.test') self.assertEqual(STATE_OFF, state.state) diff --git a/tests/components/test_config_entry_example.py b/tests/components/test_config_entry_example.py new file mode 100644 index 00000000000..31084384c31 --- /dev/null +++ b/tests/components/test_config_entry_example.py @@ -0,0 +1,38 @@ +"""Test the config entry example component.""" +import asyncio + +from homeassistant import config_entries + + +@asyncio.coroutine +def test_flow_works(hass): + """Test that the config flow works.""" + result = yield from hass.config_entries.flow.async_init( + 'config_entry_example') + + assert result['type'] == config_entries.RESULT_TYPE_FORM + + result = yield from hass.config_entries.flow.async_configure( + result['flow_id'], { + 'object_id': 'bla' + }) + + assert result['type'] == config_entries.RESULT_TYPE_FORM + + result = yield from hass.config_entries.flow.async_configure( + result['flow_id'], { + 'name': 'Hello' + }) + + assert result['type'] == config_entries.RESULT_TYPE_CREATE_ENTRY + state = hass.states.get('config_entry_example.bla') + assert state is not None + assert state.name == 'Hello' + assert 'config_entry_example' in hass.config.components + assert len(hass.config_entries.async_entries()) == 1 + + # Test removing entry. + entry = hass.config_entries.async_entries()[0] + yield from hass.config_entries.async_remove(entry.entry_id) + state = hass.states.get('config_entry_example.bla') + assert state is None diff --git a/tests/components/test_conversation.py b/tests/components/test_conversation.py index fab1e24d8e7..8d629321853 100644 --- a/tests/components/test_conversation.py +++ b/tests/components/test_conversation.py @@ -6,6 +6,7 @@ import pytest from homeassistant.setup import async_setup_component from homeassistant.components import conversation +import homeassistant.components as component from homeassistant.helpers import intent from tests.common import async_mock_intent, async_mock_service @@ -16,6 +17,9 @@ def test_calling_intent(hass): """Test calling an intent from a conversation.""" intents = async_mock_intent(hass, 'OrderBeer') + result = yield from component.async_setup(hass, {}) + assert result + result = yield from async_setup_component(hass, 'conversation', { 'conversation': { 'intents': { @@ -145,6 +149,9 @@ def test_http_processing_intent(hass, test_client): @pytest.mark.parametrize('sentence', ('turn on kitchen', 'turn kitchen on')) def test_turn_on_intent(hass, sentence): """Test calling the turn on intent.""" + result = yield from component.async_setup(hass, {}) + assert result + result = yield from async_setup_component(hass, 'conversation', {}) assert result @@ -168,6 +175,9 @@ def test_turn_on_intent(hass, sentence): @pytest.mark.parametrize('sentence', ('turn off kitchen', 'turn kitchen off')) def test_turn_off_intent(hass, sentence): """Test calling the turn on intent.""" + result = yield from component.async_setup(hass, {}) + assert result + result = yield from async_setup_component(hass, 'conversation', {}) assert result @@ -187,9 +197,38 @@ def test_turn_off_intent(hass, sentence): assert call.data == {'entity_id': 'light.kitchen'} +@asyncio.coroutine +@pytest.mark.parametrize('sentence', ('toggle kitchen', 'kitchen toggle')) +def test_toggle_intent(hass, sentence): + """Test calling the turn on intent.""" + result = yield from component.async_setup(hass, {}) + assert result + + result = yield from async_setup_component(hass, 'conversation', {}) + assert result + + hass.states.async_set('light.kitchen', 'on') + calls = async_mock_service(hass, 'homeassistant', 'toggle') + + yield from hass.services.async_call( + 'conversation', 'process', { + conversation.ATTR_TEXT: sentence + }) + yield from hass.async_block_till_done() + + assert len(calls) == 1 + call = calls[0] + assert call.domain == 'homeassistant' + assert call.service == 'toggle' + assert call.data == {'entity_id': 'light.kitchen'} + + @asyncio.coroutine def test_http_api(hass, test_client): """Test the HTTP conversation API.""" + result = yield from component.async_setup(hass, {}) + assert result + result = yield from async_setup_component(hass, 'conversation', {}) assert result @@ -212,6 +251,9 @@ def test_http_api(hass, test_client): @asyncio.coroutine def test_http_api_wrong_data(hass, test_client): """Test the HTTP conversation API.""" + result = yield from component.async_setup(hass, {}) + assert result + result = yield from async_setup_component(hass, 'conversation', {}) assert result diff --git a/tests/components/test_history.py b/tests/components/test_history.py index 8484e2c536f..4a759e7e0ac 100644 --- a/tests/components/test_history.py +++ b/tests/components/test_history.py @@ -10,8 +10,7 @@ import homeassistant.util.dt as dt_util from homeassistant.components import history, recorder from tests.common import ( - init_recorder_component, mock_http_component, mock_state_change_event, - get_test_home_assistant) + init_recorder_component, mock_state_change_event, get_test_home_assistant) class TestComponentHistory(unittest.TestCase): @@ -38,7 +37,6 @@ class TestComponentHistory(unittest.TestCase): def test_setup(self): """Test setup method of history.""" - mock_http_component(self.hass) config = history.CONFIG_SCHEMA({ # ha.DOMAIN: {}, history.DOMAIN: { @@ -403,12 +401,12 @@ class TestComponentHistory(unittest.TestCase): filters = history.Filters() exclude = config[history.DOMAIN].get(history.CONF_EXCLUDE) if exclude: - filters.excluded_entities = exclude[history.CONF_ENTITIES] - filters.excluded_domains = exclude[history.CONF_DOMAINS] + filters.excluded_entities = exclude.get(history.CONF_ENTITIES, []) + filters.excluded_domains = exclude.get(history.CONF_DOMAINS, []) include = config[history.DOMAIN].get(history.CONF_INCLUDE) if include: - filters.included_entities = include[history.CONF_ENTITIES] - filters.included_domains = include[history.CONF_DOMAINS] + filters.included_entities = include.get(history.CONF_ENTITIES, []) + filters.included_domains = include.get(history.CONF_DOMAINS, []) hist = history.get_significant_states( self.hass, zero, four, filters=filters) diff --git a/tests/components/test_init.py b/tests/components/test_init.py index dde141b6495..fff3b74c831 100644 --- a/tests/components/test_init.py +++ b/tests/components/test_init.py @@ -11,6 +11,7 @@ from homeassistant import config from homeassistant.const import ( STATE_ON, STATE_OFF, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE) import homeassistant.components as comps +import homeassistant.helpers.intent as intent from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity from homeassistant.util.async import run_coroutine_threadsafe @@ -195,3 +196,96 @@ class TestComponentsCore(unittest.TestCase): self.hass.block_till_done() assert mock_check.called assert not mock_stop.called + + +@asyncio.coroutine +def test_turn_on_intent(hass): + """Test HassTurnOn intent.""" + result = yield from comps.async_setup(hass, {}) + assert result + + hass.states.async_set('light.test_light', 'off') + calls = async_mock_service(hass, 'light', SERVICE_TURN_ON) + + response = yield from intent.async_handle( + hass, 'test', 'HassTurnOn', {'name': {'value': 'test light'}} + ) + yield from hass.async_block_till_done() + + assert response.speech['plain']['speech'] == 'Turned on test light' + assert len(calls) == 1 + call = calls[0] + assert call.domain == 'light' + assert call.service == 'turn_on' + assert call.data == {'entity_id': ['light.test_light']} + + +@asyncio.coroutine +def test_turn_off_intent(hass): + """Test HassTurnOff intent.""" + result = yield from comps.async_setup(hass, {}) + assert result + + hass.states.async_set('light.test_light', 'on') + calls = async_mock_service(hass, 'light', SERVICE_TURN_OFF) + + response = yield from intent.async_handle( + hass, 'test', 'HassTurnOff', {'name': {'value': 'test light'}} + ) + yield from hass.async_block_till_done() + + assert response.speech['plain']['speech'] == 'Turned off test light' + assert len(calls) == 1 + call = calls[0] + assert call.domain == 'light' + assert call.service == 'turn_off' + assert call.data == {'entity_id': ['light.test_light']} + + +@asyncio.coroutine +def test_toggle_intent(hass): + """Test HassToggle intent.""" + result = yield from comps.async_setup(hass, {}) + assert result + + hass.states.async_set('light.test_light', 'off') + calls = async_mock_service(hass, 'light', SERVICE_TOGGLE) + + response = yield from intent.async_handle( + hass, 'test', 'HassToggle', {'name': {'value': 'test light'}} + ) + yield from hass.async_block_till_done() + + assert response.speech['plain']['speech'] == 'Toggled test light' + assert len(calls) == 1 + call = calls[0] + assert call.domain == 'light' + assert call.service == 'toggle' + assert call.data == {'entity_id': ['light.test_light']} + + +@asyncio.coroutine +def test_turn_on_multiple_intent(hass): + """Test HassTurnOn intent with multiple similar entities. + + This tests that matching finds the proper entity among similar names. + """ + result = yield from comps.async_setup(hass, {}) + assert result + + hass.states.async_set('light.test_light', 'off') + hass.states.async_set('light.test_lights_2', 'off') + hass.states.async_set('light.test_lighter', 'off') + calls = async_mock_service(hass, 'light', SERVICE_TURN_ON) + + response = yield from intent.async_handle( + hass, 'test', 'HassTurnOn', {'name': {'value': 'test lights'}} + ) + yield from hass.async_block_till_done() + + assert response.speech['plain']['speech'] == 'Turned on test lights' + assert len(calls) == 1 + call = calls[0] + assert call.domain == 'light' + assert call.service == 'turn_on' + assert call.data == {'entity_id': ['light.test_lights_2']} diff --git a/tests/components/test_logbook.py b/tests/components/test_logbook.py index 6a79994586c..bd10416c7a2 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -14,7 +14,7 @@ from homeassistant.components import logbook from homeassistant.setup import setup_component from tests.common import ( - mock_http_component, init_recorder_component, get_test_home_assistant) + init_recorder_component, get_test_home_assistant) _LOGGER = logging.getLogger(__name__) @@ -29,10 +29,7 @@ class TestComponentLogbook(unittest.TestCase): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() init_recorder_component(self.hass) # Force an in memory DB - mock_http_component(self.hass) - self.hass.config.components |= set(['frontend', 'recorder', 'api']) - assert setup_component(self.hass, logbook.DOMAIN, - self.EMPTY_CONFIG) + assert setup_component(self.hass, logbook.DOMAIN, self.EMPTY_CONFIG) self.hass.start() def tearDown(self): @@ -375,7 +372,8 @@ class TestComponentLogbook(unittest.TestCase): eventB = self.create_state_changed_event(pointA, entity_id2, 20, {'auto': True}) - entries = list(logbook.humanify((eventA, eventB))) + events = logbook._exclude_events((eventA, eventB), {}) + entries = list(logbook.humanify(events)) self.assertEqual(1, len(entries)) self.assert_entry(entries[0], pointA, 'bla', domain='switch', @@ -392,7 +390,8 @@ class TestComponentLogbook(unittest.TestCase): eventB = self.create_state_changed_event( pointA, entity_id2, 20, last_changed=pointA, last_updated=pointB) - entries = list(logbook.humanify((eventA, eventB))) + events = logbook._exclude_events((eventA, eventB), {}) + entries = list(logbook.humanify(events)) self.assertEqual(1, len(entries)) self.assert_entry(entries[0], pointA, 'bla', domain='switch', diff --git a/tests/components/test_mqtt_eventstream.py b/tests/components/test_mqtt_eventstream.py index 91175024ea6..f4fc3e89ee0 100644 --- a/tests/components/test_mqtt_eventstream.py +++ b/tests/components/test_mqtt_eventstream.py @@ -30,13 +30,16 @@ class TestMqttEventStream(object): """Stop everything that was started.""" self.hass.stop() - def add_eventstream(self, sub_topic=None, pub_topic=None): + def add_eventstream(self, sub_topic=None, pub_topic=None, + ignore_event=None): """Add a mqtt_eventstream component.""" config = {} if sub_topic: config['subscribe_topic'] = sub_topic if pub_topic: config['publish_topic'] = pub_topic + if ignore_event: + config['ignore_event'] = ignore_event return setup_component(self.hass, eventstream.DOMAIN, { eventstream.DOMAIN: config}) @@ -144,3 +147,57 @@ class TestMqttEventStream(object): self.hass.block_till_done() assert 1 == len(calls) + + @patch('homeassistant.components.mqtt.async_publish') + def test_ignored_event_doesnt_send_over_stream(self, mock_pub): + """"Test the ignoring of sending events if defined.""" + assert self.add_eventstream(pub_topic='bar', + ignore_event=['state_changed']) + self.hass.block_till_done() + + e_id = 'entity.test_id' + event = {} + event['event_type'] = EVENT_STATE_CHANGED + new_state = { + "state": "on", + "entity_id": e_id, + "attributes": {}, + } + event['event_data'] = {"new_state": new_state, "entity_id": e_id} + + # Reset the mock because it will have already gotten calls for the + # mqtt_eventstream 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() + + assert not mock_pub.called + + @patch('homeassistant.components.mqtt.async_publish') + def test_wrong_ignored_event_sends_over_stream(self, mock_pub): + """"Test the ignoring of sending events if defined.""" + assert self.add_eventstream(pub_topic='bar', + ignore_event=['statee_changed']) + self.hass.block_till_done() + + e_id = 'entity.test_id' + event = {} + event['event_type'] = EVENT_STATE_CHANGED + new_state = { + "state": "on", + "entity_id": e_id, + "attributes": {}, + } + event['event_data'] = {"new_state": new_state, "entity_id": e_id} + + # Reset the mock because it will have already gotten calls for the + # mqtt_eventstream 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() + + assert mock_pub.called diff --git a/tests/components/test_panel_iframe.py b/tests/components/test_panel_iframe.py index ef702b96f4b..91a07511787 100644 --- a/tests/components/test_panel_iframe.py +++ b/tests/components/test_panel_iframe.py @@ -55,6 +55,11 @@ class TestPanelIframe(unittest.TestCase): 'title': 'Api', 'url': '/api', }, + 'ftp': { + 'icon': 'mdi:weather', + 'title': 'FTP', + 'url': 'ftp://some/ftp', + }, }, }) @@ -86,3 +91,12 @@ class TestPanelIframe(unittest.TestCase): 'url': '/frontend_es5/panels/ha-panel-iframe-md5md5.html', 'url_path': 'api', } + + assert panels.get('ftp').to_response(self.hass, None) == { + 'component_name': 'iframe', + 'config': {'url': 'ftp://some/ftp'}, + 'icon': 'mdi:weather', + 'title': 'FTP', + 'url': '/frontend_es5/panels/ha-panel-iframe-md5md5.html', + 'url_path': 'ftp', + } diff --git a/tests/components/test_rflink.py b/tests/components/test_rflink.py index 9f6573920ca..ccb88018c66 100644 --- a/tests/components/test_rflink.py +++ b/tests/components/test_rflink.py @@ -8,12 +8,10 @@ from homeassistant.components.rflink import ( CONF_RECONNECT_INTERVAL, SERVICE_SEND_COMMAND) from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_STOP_COVER) -from tests.common import assert_setup_component @asyncio.coroutine -def mock_rflink(hass, config, domain, monkeypatch, failures=None, - platform_count=1): +def mock_rflink(hass, config, domain, monkeypatch, failures=None): """Create mock Rflink asyncio protocol, test component setup.""" transport, protocol = (Mock(), Mock()) @@ -47,9 +45,7 @@ def mock_rflink(hass, config, domain, monkeypatch, failures=None, 'rflink.protocol.create_rflink_connection', mock_create) - # verify instantiation of component with given config - with assert_setup_component(platform_count, domain): - yield from async_setup_component(hass, domain, config) + yield from async_setup_component(hass, domain, config) # hook into mock config for injecting events event_callback = mock_create.call_args_list[0][1]['event_callback'] @@ -164,7 +160,7 @@ def test_send_command(hass, monkeypatch): # setup mocking rflink module _, _, protocol, _ = yield from mock_rflink( - hass, config, domain, monkeypatch, platform_count=5) + hass, config, domain, monkeypatch) hass.async_add_job( hass.services.async_call(domain, SERVICE_SEND_COMMAND, @@ -188,7 +184,7 @@ def test_send_command_invalid_arguments(hass, monkeypatch): # setup mocking rflink module _, _, protocol, _ = yield from mock_rflink( - hass, config, domain, monkeypatch, platform_count=5) + hass, config, domain, monkeypatch) # one argument missing hass.async_add_job( diff --git a/tests/components/test_shopping_list.py b/tests/components/test_shopping_list.py index 2e1a03c37d0..4203f7587ae 100644 --- a/tests/components/test_shopping_list.py +++ b/tests/components/test_shopping_list.py @@ -150,7 +150,6 @@ def test_api_update_fails(hass, test_client): assert resp.status == 404 beer_id = hass.data['shopping_list'].items[0]['id'] - client = yield from test_client(hass.http.app) resp = yield from client.post( '/api/shopping_list/item/{}'.format(beer_id), json={ 'name': 123, diff --git a/tests/components/test_weblink.py b/tests/components/test_weblink.py index 249e81d37af..f35398e034c 100644 --- a/tests/components/test_weblink.py +++ b/tests/components/test_weblink.py @@ -91,6 +91,19 @@ class TestComponentWeblink(unittest.TestCase): } })) + def test_good_config_ftp_link(self): + """Test if new entity is created.""" + self.assertTrue(setup_component(self.hass, 'weblink', { + 'weblink': { + 'entities': [ + { + weblink.CONF_NAME: 'My FTP URL', + weblink.CONF_URL: 'ftp://somehost/' + }, + ], + } + })) + def test_entities_get_created(self): """Test if new entity is created.""" self.assertTrue(setup_component(self.hass, weblink.DOMAIN, { diff --git a/tests/components/test_websocket_api.py b/tests/components/test_websocket_api.py index 8b6c7494214..f85030a6892 100644 --- a/tests/components/test_websocket_api.py +++ b/tests/components/test_websocket_api.py @@ -8,8 +8,9 @@ import pytest from homeassistant.core import callback from homeassistant.components import websocket_api as wapi, frontend +from homeassistant.setup import async_setup_component -from tests.common import mock_http_component_app, mock_coro +from tests.common import mock_coro API_PASSWORD = 'test1234' @@ -17,10 +18,10 @@ API_PASSWORD = 'test1234' @pytest.fixture def websocket_client(loop, hass, test_client): """Websocket client fixture connected to websocket server.""" - websocket_app = mock_http_component_app(hass) - wapi.WebsocketAPIView().register(websocket_app.router) + assert loop.run_until_complete( + async_setup_component(hass, 'websocket_api')) - client = loop.run_until_complete(test_client(websocket_app)) + client = loop.run_until_complete(test_client(hass.http.app)) ws = loop.run_until_complete(client.ws_connect(wapi.URL)) auth_ok = loop.run_until_complete(ws.receive_json()) @@ -35,10 +36,14 @@ def websocket_client(loop, hass, test_client): @pytest.fixture def no_auth_websocket_client(hass, loop, test_client): """Websocket connection that requires authentication.""" - websocket_app = mock_http_component_app(hass, API_PASSWORD) - wapi.WebsocketAPIView().register(websocket_app.router) + assert loop.run_until_complete( + async_setup_component(hass, 'websocket_api', { + 'http': { + 'api_password': API_PASSWORD + } + })) - client = loop.run_until_complete(test_client(websocket_app)) + client = loop.run_until_complete(test_client(hass.http.app)) ws = loop.run_until_complete(client.ws_connect(wapi.URL)) auth_ok = loop.run_until_complete(ws.receive_json()) diff --git a/tests/components/vacuum/test_mqtt.py b/tests/components/vacuum/test_mqtt.py index 8c3b5fa4eeb..ba2288e3fc6 100644 --- a/tests/components/vacuum/test_mqtt.py +++ b/tests/components/vacuum/test_mqtt.py @@ -71,52 +71,56 @@ class TestVacuumMQTT(unittest.TestCase): vacuum.turn_on(self.hass, 'vacuum.mqtttest') self.hass.block_till_done() - self.assertEqual(('vacuum/command', 'turn_on', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'vacuum/command', 'turn_on', 0, False) + self.mock_publish.async_publish.reset_mock() vacuum.turn_off(self.hass, 'vacuum.mqtttest') self.hass.block_till_done() - self.assertEqual(('vacuum/command', 'turn_off', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'vacuum/command', 'turn_off', 0, False) + self.mock_publish.async_publish.reset_mock() vacuum.stop(self.hass, 'vacuum.mqtttest') self.hass.block_till_done() - self.assertEqual(('vacuum/command', 'stop', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'vacuum/command', 'stop', 0, False) + self.mock_publish.async_publish.reset_mock() vacuum.clean_spot(self.hass, 'vacuum.mqtttest') self.hass.block_till_done() - self.assertEqual(('vacuum/command', 'clean_spot', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'vacuum/command', 'clean_spot', 0, False) + self.mock_publish.async_publish.reset_mock() vacuum.locate(self.hass, 'vacuum.mqtttest') self.hass.block_till_done() - self.assertEqual(('vacuum/command', 'locate', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'vacuum/command', 'locate', 0, False) + self.mock_publish.async_publish.reset_mock() vacuum.start_pause(self.hass, 'vacuum.mqtttest') self.hass.block_till_done() - self.assertEqual(('vacuum/command', 'start_pause', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'vacuum/command', 'start_pause', 0, False) + self.mock_publish.async_publish.reset_mock() vacuum.return_to_base(self.hass, 'vacuum.mqtttest') self.hass.block_till_done() - self.assertEqual(('vacuum/command', 'return_to_base', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'vacuum/command', 'return_to_base', 0, False) + self.mock_publish.async_publish.reset_mock() vacuum.set_fan_speed(self.hass, 'high', 'vacuum.mqtttest') self.hass.block_till_done() - self.assertEqual( - ('vacuum/set_fan_speed', 'high', 0, False), - self.mock_publish.mock_calls[-2][1] - ) + self.mock_publish.async_publish.assert_called_once_with( + 'vacuum/set_fan_speed', 'high', 0, False) + self.mock_publish.async_publish.reset_mock() vacuum.send_command(self.hass, '44 FE 93', entity_id='vacuum.mqtttest') self.hass.block_till_done() - self.assertEqual( - ('vacuum/send_command', '44 FE 93', 0, False), - self.mock_publish.mock_calls[-2][1] - ) + self.mock_publish.async_publish.assert_called_once_with( + 'vacuum/send_command', '44 FE 93', 0, False) def test_status(self): """Test status updates from the vacuum.""" diff --git a/tests/conftest.py b/tests/conftest.py index f1947a61ad0..989785e72d5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,11 +8,11 @@ from unittest.mock import patch, MagicMock import pytest import requests_mock as _requests_mock -from homeassistant import util, setup +from homeassistant import util from homeassistant.util import location -from homeassistant.components import mqtt -from tests.common import async_test_home_assistant, mock_coro, INSTANCES +from tests.common import async_test_home_assistant, INSTANCES, \ + async_mock_mqtt_component from tests.test_util.aiohttp import mock_aiohttp_client from tests.mock.zwave import MockNetwork, MockOption @@ -85,17 +85,9 @@ def aioclient_mock(): @pytest.fixture def mqtt_mock(loop, hass): """Fixture to mock MQTT.""" - with patch('homeassistant.components.mqtt.MQTT') as mock_mqtt: - mock_mqtt().async_connect.return_value = mock_coro(True) - assert loop.run_until_complete(setup.async_setup_component( - hass, mqtt.DOMAIN, { - mqtt.DOMAIN: { - mqtt.CONF_BROKER: 'mock-broker', - } - })) - client = mock_mqtt() - client.reset_mock() - return client + client = loop.run_until_complete(async_mock_mqtt_component(hass)) + client.reset_mock() + return client @pytest.fixture diff --git a/tests/fixtures/pushbullet_devices.json b/tests/fixtures/pushbullet_devices.json index 04f336c8adf..576e748471a 100755 --- a/tests/fixtures/pushbullet_devices.json +++ b/tests/fixtures/pushbullet_devices.json @@ -1,43 +1,43 @@ -{ - "accounts": [], - "blocks": [], - "channels": [], - "chats": [], - "clients": [], - "contacts": [], - "devices": [{ - "active": true, - "iden": "identity1", - "created": 1.514520333770855e+09, - "modified": 1.5151951594363022e+09, - "type": "windows", - "kind": "windows", - "nickname": "DESKTOP", - "manufacturer": "Microsoft", - "model": "Windows 10 Home", - "app_version": 396, - "fingerprint": "{\"cpu\":\"AMD\",\"computer_name\":\"DESKTOP\"}", - "pushable": true, - "icon": "desktop", - "remote_files": "disabled" - }, { - "active": true, - "iden": "identity2", - "created": 1.5144974875448499e+09, - "modified": 1.514574792288634e+09, - "type": "ios", - "kind": "ios", - "nickname": "My iPhone", - "manufacturer": "Apple", - "model": "iPhone", - "app_version": 8646, - "push_token": "production:mytoken", - "pushable": true, - "icon": "phone" - }], - "grants": [], - "pushes": [], - "profiles": [], - "subscriptions": [], - "texts": [] -} +{ + "accounts": [], + "blocks": [], + "channels": [], + "chats": [], + "clients": [], + "contacts": [], + "devices": [{ + "active": true, + "iden": "identity1", + "created": 1.514520333770855e+09, + "modified": 1.5151951594363022e+09, + "type": "windows", + "kind": "windows", + "nickname": "DESKTOP", + "manufacturer": "Microsoft", + "model": "Windows 10 Home", + "app_version": 396, + "fingerprint": "{\"cpu\":\"AMD\",\"computer_name\":\"DESKTOP\"}", + "pushable": true, + "icon": "desktop", + "remote_files": "disabled" + }, { + "active": true, + "iden": "identity2", + "created": 1.5144974875448499e+09, + "modified": 1.514574792288634e+09, + "type": "ios", + "kind": "ios", + "nickname": "My iPhone", + "manufacturer": "Apple", + "model": "iPhone", + "app_version": 8646, + "push_token": "production:mytoken", + "pushable": true, + "icon": "phone" + }], + "grants": [], + "pushes": [], + "profiles": [], + "subscriptions": [], + "texts": [] +} diff --git a/tests/fixtures/wunderground-error.json b/tests/fixtures/wunderground-error.json new file mode 100644 index 00000000000..264ecbf8cd6 --- /dev/null +++ b/tests/fixtures/wunderground-error.json @@ -0,0 +1,11 @@ +{ + "response": { + "version": "0.1", + "termsofService": "http://www.wunderground.com/weather/api/d/terms.html", + "features": {}, + "error": { + "type": "keynotfound", + "description": "this key does not exist" + } + } +} diff --git a/tests/fixtures/wunderground-invalid.json b/tests/fixtures/wunderground-invalid.json new file mode 100644 index 00000000000..59661c6694d --- /dev/null +++ b/tests/fixtures/wunderground-invalid.json @@ -0,0 +1,18 @@ +{ + "response": { + "version": "0.1", + "termsofService": "http://www.wunderground.com/weather/api/d/terms.html", + "features": { + "conditions": 1, + "alerts": 1, + "forecast": 1 + } + }, + "current_observation": { + "image": { + "url": "http://icons.wxug.com/graphics/wu2/logo_130x80.png", + "title": "Weather Underground", + "link": "http://www.wunderground.com" + } + } +} diff --git a/tests/fixtures/wunderground-valid.json b/tests/fixtures/wunderground-valid.json new file mode 100644 index 00000000000..7ac1081cb4e --- /dev/null +++ b/tests/fixtures/wunderground-valid.json @@ -0,0 +1,90 @@ +{ + "response": { + "version": "0.1", + "termsofService": "http://www.wunderground.com/weather/api/d/terms.html", + "features": { + "conditions": 1, + "alerts": 1, + "forecast": 1 + } + }, + "current_observation": { + "image": { + "url": "http://icons.wxug.com/graphics/wu2/logo_130x80.png", + "title": "Weather Underground", + "link": "http://www.wunderground.com" + }, + "feelslike_c": "40", + "weather": "Clear", + "icon_url": "http://icons.wxug.com/i/c/k/clear.gif", + "display_location": { + "city": "Holly Springs", + "country": "US", + "full": "Holly Springs, NC" + }, + "observation_location": { + "elevation": "413 ft", + "full": "Twin Lake, Holly Springs, North Carolina" + } + }, + "alerts": [ + { + "type": "FLO", + "description": "Areal Flood Warning", + "date": "9:36 PM CDT on September 22, 2016", + "expires": "10:00 AM CDT on September 23, 2016", + "message": "This is a test alert message" + } + ], + "forecast": { + "txt_forecast": { + "date": "22:35 CEST", + "forecastday": [ + { + "period": 0, + "icon_url": "http://icons.wxug.com/i/c/k/clear.gif", + "title": "Tuesday", + "fcttext": "Mostly Cloudy. Fog overnight.", + "fcttext_metric": "Mostly Cloudy. Fog overnight.", + "pop": "0" + } + ] + }, + "simpleforecast": { + "forecastday": [ + { + "date": { + "pretty": "19:00 CEST 4. Duben 2017" + }, + "period": 1, + "high": { + "fahrenheit": "56", + "celsius": "13" + }, + "low": { + "fahrenheit": "43", + "celsius": "6" + }, + "conditions": "Mo\u017enost de\u0161t\u011b", + "icon_url": "http://icons.wxug.com/i/c/k/chancerain.gif", + "qpf_allday": { + "in": 0.03, + "mm": 1 + }, + "maxwind": { + "mph": 0, + "kph": 0, + "dir": "", + "degrees": 0 + }, + "avewind": { + "mph": 0, + "kph": 0, + "dir": "severn\u00ed", + "degrees": 0 + } + } + ] + } + } +} diff --git a/tests/fixtures/yahooweather.json b/tests/fixtures/yahooweather.json index f6ab2980618..7d8188764df 100644 --- a/tests/fixtures/yahooweather.json +++ b/tests/fixtures/yahooweather.json @@ -1,138 +1,138 @@ -{ - "query": { - "count": 1, - "created": "2017-11-17T13:40:47Z", - "lang": "en-US", - "results": { - "channel": { - "units": { - "distance": "km", - "pressure": "mb", - "speed": "km/h", - "temperature": "C" - }, - "title": "Yahoo! Weather - San Diego, CA, US", - "link": "http://us.rd.yahoo.com/dailynews/rss/weather/Country__Country/*https://weather.yahoo.com/country/state/city-23511632/", - "description": "Yahoo! Weather for San Diego, CA, US", - "language": "en-us", - "lastBuildDate": "Fri, 17 Nov 2017 05:40 AM PST", - "ttl": "60", - "location": { - "city": "San Diego", - "country": "United States", - "region": " CA" - }, - "wind": { - "chill": "56", - "direction": "0", - "speed": "6.34" - }, - "atmosphere": { - "humidity": "71", - "pressure": "33863.75", - "rising": "0", - "visibility": "22.91" - }, - "astronomy": { - "sunrise": "6:21 am", - "sunset": "4:47 pm" - }, - "image": { - "title": "Yahoo! Weather", - "width": "142", - "height": "18", - "link": "http://weather.yahoo.com", - "url": "http://l.yimg.com/a/i/brand/purplelogo//uh/us/news-wea.gif" - }, - "item": { - "title": "Conditions for San Diego, CA, US at 05:00 AM PST", - "lat": "32.878101", - "long": "-117.23497", - "link": "http://us.rd.yahoo.com/dailynews/rss/weather/Country__Country/*https://weather.yahoo.com/country/state/city-23511632/", - "pubDate": "Fri, 17 Nov 2017 05:00 AM PST", - "condition": { - "code": "26", - "date": "Fri, 17 Nov 2017 05:00 AM PST", - "temp": "18", - "text": "Cloudy" - }, - "forecast": [{ - "code": "28", - "date": "17 Nov 2017", - "day": "Fri", - "high": "23", - "low": "16", - "text": "Mostly Cloudy" - }, { - "code": "30", - "date": "18 Nov 2017", - "day": "Sat", - "high": "22", - "low": "13", - "text": "Partly Cloudy" - }, { - "code": "30", - "date": "19 Nov 2017", - "day": "Sun", - "high": "22", - "low": "12", - "text": "Partly Cloudy" - }, { - "code": "28", - "date": "20 Nov 2017", - "day": "Mon", - "high": "21", - "low": "11", - "text": "Mostly Cloudy" - }, { - "code": "28", - "date": "21 Nov 2017", - "day": "Tue", - "high": "24", - "low": "14", - "text": "Mostly Cloudy" - }, { - "code": "30", - "date": "22 Nov 2017", - "day": "Wed", - "high": "27", - "low": "15", - "text": "Partly Cloudy" - }, { - "code": "34", - "date": "23 Nov 2017", - "day": "Thu", - "high": "27", - "low": "15", - "text": "Mostly Sunny" - }, { - "code": "30", - "date": "24 Nov 2017", - "day": "Fri", - "high": "23", - "low": "16", - "text": "Partly Cloudy" - }, { - "code": "30", - "date": "25 Nov 2017", - "day": "Sat", - "high": "22", - "low": "15", - "text": "Partly Cloudy" - }, { - "code": "28", - "date": "26 Nov 2017", - "day": "Sun", - "high": "24", - "low": "13", - "text": "Mostly Cloudy" - }], - "description": "\n
\nCurrent Conditions:\n
Cloudy\n
\n
\nForecast:\n
Fri - Mostly Cloudy. High: 23Low: 16\n
Sat - Partly Cloudy. High: 22Low: 13\n
Sun - Partly Cloudy. High: 22Low: 12\n
Mon - Mostly Cloudy. High: 21Low: 11\n
Tue - Mostly Cloudy. High: 24Low: 14\n
\n
\nFull Forecast at Yahoo! Weather\n
\n
\n
\n]]>", - "guid": { - "isPermaLink": "false" - } - } - } - } - } -} +{ + "query": { + "count": 1, + "created": "2017-11-17T13:40:47Z", + "lang": "en-US", + "results": { + "channel": { + "units": { + "distance": "km", + "pressure": "mb", + "speed": "km/h", + "temperature": "C" + }, + "title": "Yahoo! Weather - San Diego, CA, US", + "link": "http://us.rd.yahoo.com/dailynews/rss/weather/Country__Country/*https://weather.yahoo.com/country/state/city-23511632/", + "description": "Yahoo! Weather for San Diego, CA, US", + "language": "en-us", + "lastBuildDate": "Fri, 17 Nov 2017 05:40 AM PST", + "ttl": "60", + "location": { + "city": "San Diego", + "country": "United States", + "region": " CA" + }, + "wind": { + "chill": "56", + "direction": "0", + "speed": "6.34" + }, + "atmosphere": { + "humidity": "71", + "pressure": "33863.75", + "rising": "0", + "visibility": "22.91" + }, + "astronomy": { + "sunrise": "6:21 am", + "sunset": "4:47 pm" + }, + "image": { + "title": "Yahoo! Weather", + "width": "142", + "height": "18", + "link": "http://weather.yahoo.com", + "url": "http://l.yimg.com/a/i/brand/purplelogo//uh/us/news-wea.gif" + }, + "item": { + "title": "Conditions for San Diego, CA, US at 05:00 AM PST", + "lat": "32.878101", + "long": "-117.23497", + "link": "http://us.rd.yahoo.com/dailynews/rss/weather/Country__Country/*https://weather.yahoo.com/country/state/city-23511632/", + "pubDate": "Fri, 17 Nov 2017 05:00 AM PST", + "condition": { + "code": "26", + "date": "Fri, 17 Nov 2017 05:00 AM PST", + "temp": "18", + "text": "Cloudy" + }, + "forecast": [{ + "code": "28", + "date": "17 Nov 2017", + "day": "Fri", + "high": "23", + "low": "16", + "text": "Mostly Cloudy" + }, { + "code": "30", + "date": "18 Nov 2017", + "day": "Sat", + "high": "22", + "low": "13", + "text": "Partly Cloudy" + }, { + "code": "30", + "date": "19 Nov 2017", + "day": "Sun", + "high": "22", + "low": "12", + "text": "Partly Cloudy" + }, { + "code": "28", + "date": "20 Nov 2017", + "day": "Mon", + "high": "21", + "low": "11", + "text": "Mostly Cloudy" + }, { + "code": "28", + "date": "21 Nov 2017", + "day": "Tue", + "high": "24", + "low": "14", + "text": "Mostly Cloudy" + }, { + "code": "30", + "date": "22 Nov 2017", + "day": "Wed", + "high": "27", + "low": "15", + "text": "Partly Cloudy" + }, { + "code": "34", + "date": "23 Nov 2017", + "day": "Thu", + "high": "27", + "low": "15", + "text": "Mostly Sunny" + }, { + "code": "30", + "date": "24 Nov 2017", + "day": "Fri", + "high": "23", + "low": "16", + "text": "Partly Cloudy" + }, { + "code": "30", + "date": "25 Nov 2017", + "day": "Sat", + "high": "22", + "low": "15", + "text": "Partly Cloudy" + }, { + "code": "28", + "date": "26 Nov 2017", + "day": "Sun", + "high": "24", + "low": "13", + "text": "Mostly Cloudy" + }], + "description": "\n
\nCurrent Conditions:\n
Cloudy\n
\n
\nForecast:\n
Fri - Mostly Cloudy. High: 23Low: 16\n
Sat - Partly Cloudy. High: 22Low: 13\n
Sun - Partly Cloudy. High: 22Low: 12\n
Mon - Mostly Cloudy. High: 21Low: 11\n
Tue - Mostly Cloudy. High: 24Low: 14\n
\n
\nFull Forecast at Yahoo! Weather\n
\n
\n
\n]]>", + "guid": { + "isPermaLink": "false" + } + } + } + } + } +} diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 2991e07a464..aa7b5170648 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -146,3 +146,21 @@ class TestConditionHelper: return_value=dt.now().replace(hour=21)): assert not condition.time(after=sixam, before=sixpm) assert condition.time(after=sixpm, before=sixam) + + def test_if_numeric_state_not_raise_on_unavailable(self): + """Test numeric_state doesn't raise on unavailable/unknown state.""" + test = condition.from_config({ + 'condition': 'numeric_state', + 'entity_id': 'sensor.temperature', + 'below': 42 + }) + + with patch('homeassistant.helpers.condition._LOGGER.warning') \ + as logwarn: + self.hass.states.set('sensor.temperature', 'unavailable') + assert not test(self.hass) + assert len(logwarn.mock_calls) == 0 + + self.hass.states.set('sensor.temperature', 'unknown') + assert not test(self.hass) + assert len(logwarn.mock_calls) == 0 diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 26262f50ac4..66f0597fc93 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -524,18 +524,14 @@ def test_enum(): def test_socket_timeout(): # pylint: disable=invalid-name """Test socket timeout validator.""" - TEST_CONF_TIMEOUT = 'timeout' # pylint: disable=invalid-name - - schema = vol.Schema( - {vol.Required(TEST_CONF_TIMEOUT, default=None): cv.socket_timeout}) + schema = vol.Schema(cv.socket_timeout) with pytest.raises(vol.Invalid): - schema({TEST_CONF_TIMEOUT: 0.0}) + schema(0.0) with pytest.raises(vol.Invalid): - schema({TEST_CONF_TIMEOUT: -1}) + schema(-1) - assert _GLOBAL_DEFAULT_TIMEOUT == schema({TEST_CONF_TIMEOUT: - None})[TEST_CONF_TIMEOUT] + assert _GLOBAL_DEFAULT_TIMEOUT == schema(None) - assert schema({TEST_CONF_TIMEOUT: 1})[TEST_CONF_TIMEOUT] == 1.0 + assert schema(1) == 1.0 diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index a54a6de511a..0681691ed67 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -19,16 +19,17 @@ from tests.common import ( _LOGGER = logging.getLogger(__name__) DOMAIN = "test_domain" +PLATFORM = 'test_platform' class MockEntityPlatform(entity_platform.EntityPlatform): """Mock class with some mock defaults.""" def __init__( - self, *, hass, + self, hass, logger=None, - domain='test', - platform_name='test_platform', + domain=DOMAIN, + platform_name=PLATFORM, scan_interval=timedelta(seconds=15), parallel_updates=0, entity_namespace=None, @@ -331,7 +332,7 @@ def test_parallel_updates_async_platform_with_constant(hass): @asyncio.coroutine def test_parallel_updates_sync_platform(hass): """Warn we log when platform setup takes a long time.""" - platform = MockPlatform() + platform = MockPlatform(setup_platform=lambda *args: None) loader.set_component('test_domain.platform', platform) @@ -486,7 +487,26 @@ def test_overriding_name_from_registry(hass): def test_registry_respect_entity_namespace(hass): """Test that the registry respects entity namespace.""" mock_registry(hass) - platform = MockEntityPlatform(hass=hass, entity_namespace='ns') + platform = MockEntityPlatform(hass, entity_namespace='ns') entity = MockEntity(unique_id='1234', name='Device Name') yield from platform.async_add_entities([entity]) - assert entity.entity_id == 'test.ns_device_name' + assert entity.entity_id == 'test_domain.ns_device_name' + + +@asyncio.coroutine +def test_registry_respect_entity_disabled(hass): + """Test that the registry respects entity disabled.""" + mock_registry(hass, { + 'test_domain.world': entity_registry.RegistryEntry( + entity_id='test_domain.world', + unique_id='1234', + # Using component.async_add_entities is equal to platform "domain" + platform='test_platform', + disabled_by=entity_registry.DISABLED_USER + ) + }) + platform = MockEntityPlatform(hass) + entity = MockEntity(unique_id='1234') + yield from platform.async_add_entities([entity]) + assert entity.entity_id is None + assert hass.states.async_entity_ids() == [] diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 7e1150638c1..cb8703d1fe6 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -148,6 +148,14 @@ test.named: test.no_name: platform: super_platform unique_id: without-name +test.disabled_user: + platform: super_platform + unique_id: disabled-user + disabled_by: user +test.disabled_hass: + platform: super_platform + unique_id: disabled-hass + disabled_by: hass """ registry = entity_registry.EntityRegistry(hass) @@ -162,3 +170,13 @@ test.no_name: 'test', 'super_platform', 'without-name') assert entry_with_name.name == 'registry override' assert entry_without_name.name is None + assert not entry_with_name.disabled + + entry_disabled_hass = registry.async_get_or_create( + 'test', 'super_platform', 'disabled-hass') + entry_disabled_user = registry.async_get_or_create( + 'test', 'super_platform', 'disabled-user') + assert entry_disabled_hass.disabled + assert entry_disabled_hass.disabled_by == entity_registry.DISABLED_HASS + assert entry_disabled_user.disabled + assert entry_disabled_user.disabled_by == entity_registry.DISABLED_USER diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 385b0a5df05..a8ae20ad69b 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -71,13 +71,14 @@ class TestScriptHelper(unittest.TestCase): script_obj = script.Script(self.hass, cv.SCRIPT_SCHEMA({ 'event': event, 'event_data_template': { - 'hello': """ - {% if is_world == 'yes' %} - world - {% else %} - not world - {% endif %} - """ + 'dict': { + 1: '{{ is_world }}', + 2: '{{ is_world }}{{ is_world }}', + 3: '{{ is_world }}{{ is_world }}{{ is_world }}', + }, + 'list': [ + '{{ is_world }}', '{{ is_world }}{{ is_world }}' + ] } })) @@ -86,7 +87,14 @@ class TestScriptHelper(unittest.TestCase): self.hass.block_till_done() assert len(calls) == 1 - assert calls[0].data.get('hello') == 'world' + assert calls[0].data == { + 'dict': { + 1: 'yes', + 2: 'yesyes', + 3: 'yesyesyes', + }, + 'list': ['yes', 'yesyes'] + } assert not script_obj.can_cancel def test_calling_service(self): diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index a454a5a64b4..728e683a43a 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -1,10 +1,10 @@ """Test check_config script.""" import asyncio import logging -import os import unittest import homeassistant.scripts.check_config as check_config +from homeassistant.loader import set_component from tests.common import patch_yaml_files, get_test_config_dir _LOGGER = logging.getLogger(__name__) @@ -36,14 +36,6 @@ def change_yaml_files(check_dict): check_dict['yaml_files'].append('...' + key[len(root):]) -def tearDownModule(self): # pylint: disable=invalid-name - """Clean files.""" - # .HA_VERSION created during bootstrap's config update - path = get_test_config_dir('.HA_VERSION') - if os.path.isfile(path): - os.remove(path) - - class TestCheckConfig(unittest.TestCase): """Tests for the homeassistant.scripts.check_config module.""" @@ -58,6 +50,9 @@ class TestCheckConfig(unittest.TestCase): # Py34: AssertionError asyncio.set_event_loop(asyncio.new_event_loop()) + # Will allow seeing full diff + self.maxDiff = None + # pylint: disable=no-self-use,invalid-name def test_config_platform_valid(self): """Test a valid platform setup.""" @@ -124,6 +119,9 @@ class TestCheckConfig(unittest.TestCase): def test_component_platform_not_found(self): """Test errors if component or platform not found.""" + # Make sure they don't exist + set_component('beer', None) + set_component('light.beer', None) files = { 'badcomponent.yaml': BASE_CONFIG + 'beer:', 'badplatform.yaml': BASE_CONFIG + 'light:\n platform: beer', @@ -162,7 +160,6 @@ class TestCheckConfig(unittest.TestCase): 'secrets.yaml': ('logger: debug\n' 'http_pw: abc123'), } - self.maxDiff = None with patch_yaml_files(files): config_path = get_test_config_dir('secret.yaml') @@ -182,8 +179,6 @@ class TestCheckConfig(unittest.TestCase): 'login_attempts_threshold': -1, 'server_host': '0.0.0.0', 'server_port': 8123, - 'ssl_certificate': None, - 'ssl_key': None, 'trusted_networks': [], 'use_x_forwarded_for': False}}, 'except': {}, @@ -212,3 +207,20 @@ class TestCheckConfig(unittest.TestCase): assert res['components'] == {} assert res['secret_cache'] == {} assert res['secrets'] == {} + + def test_bootstrap_error(self): \ + # pylint: disable=no-self-use,invalid-name + """Test a valid platform setup.""" + files = { + 'badbootstrap.yaml': BASE_CONFIG + 'automation: !include no.yaml', + } + with patch_yaml_files(files): + res = check_config.check(get_test_config_dir('badbootstrap.yaml')) + change_yaml_files(res) + + err = res['except'].pop(check_config.ERROR_STR) + assert len(err) == 1 + assert res['except'] == {} + assert res['components'] == {} + assert res['secret_cache'] == {} + assert res['secrets'] == {} diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py new file mode 100644 index 00000000000..3a1fe1d9d3e --- /dev/null +++ b/tests/test_config_entries.py @@ -0,0 +1,397 @@ +"""Test the config manager.""" +import asyncio +from unittest.mock import MagicMock, patch, mock_open + +import pytest +import voluptuous as vol + +from homeassistant import config_entries, loader +from homeassistant.setup import async_setup_component + +from tests.common import MockModule, mock_coro, MockConfigEntry + + +@pytest.fixture +def manager(hass): + """Fixture of a loaded config manager.""" + manager = config_entries.ConfigEntries(hass, {}) + manager._entries = [] + hass.config_entries = manager + return manager + + +@asyncio.coroutine +def test_call_setup_entry(hass): + """Test we call .setup_entry.""" + MockConfigEntry(domain='comp').add_to_hass(hass) + + mock_setup_entry = MagicMock(return_value=mock_coro(True)) + + loader.set_component( + 'comp', + MockModule('comp', async_setup_entry=mock_setup_entry)) + + result = yield from async_setup_component(hass, 'comp', {}) + assert result + assert len(mock_setup_entry.mock_calls) == 1 + + +@asyncio.coroutine +def test_remove_entry(manager): + """Test that we can remove an entry.""" + mock_unload_entry = MagicMock(return_value=mock_coro(True)) + + loader.set_component( + 'test', + MockModule('comp', async_unload_entry=mock_unload_entry)) + + MockConfigEntry(domain='test', entry_id='test1').add_to_manager(manager) + MockConfigEntry(domain='test', entry_id='test2').add_to_manager(manager) + MockConfigEntry(domain='test', entry_id='test3').add_to_manager(manager) + + assert [item.entry_id for item in manager.async_entries()] == \ + ['test1', 'test2', 'test3'] + + result = yield from manager.async_remove('test2') + + assert result == { + 'require_restart': False + } + assert [item.entry_id for item in manager.async_entries()] == \ + ['test1', 'test3'] + + assert len(mock_unload_entry.mock_calls) == 1 + + +@asyncio.coroutine +def test_remove_entry_raises(manager): + """Test if a component raises while removing entry.""" + @asyncio.coroutine + def mock_unload_entry(hass, entry): + """Mock unload entry function.""" + raise Exception("BROKEN") + + loader.set_component( + 'test', + MockModule('comp', async_unload_entry=mock_unload_entry)) + + MockConfigEntry(domain='test', entry_id='test1').add_to_manager(manager) + MockConfigEntry(domain='test', entry_id='test2').add_to_manager(manager) + MockConfigEntry(domain='test', entry_id='test3').add_to_manager(manager) + + assert [item.entry_id for item in manager.async_entries()] == \ + ['test1', 'test2', 'test3'] + + result = yield from manager.async_remove('test2') + + assert result == { + 'require_restart': True + } + assert [item.entry_id for item in manager.async_entries()] == \ + ['test1', 'test3'] + + +@asyncio.coroutine +def test_add_entry_calls_setup_entry(hass, manager): + """Test we call setup_config_entry.""" + mock_setup_entry = MagicMock(return_value=mock_coro(True)) + + loader.set_component( + 'comp', + MockModule('comp', async_setup_entry=mock_setup_entry)) + + class TestFlow(config_entries.ConfigFlowHandler): + + VERSION = 1 + + @asyncio.coroutine + def async_step_init(self, user_input=None): + return self.async_create_entry( + title='title', + data={ + 'token': 'supersecret' + }) + + with patch.dict(config_entries.HANDLERS, {'comp': TestFlow}): + yield from manager.flow.async_init('comp') + yield from hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 1 + p_hass, p_entry = mock_setup_entry.mock_calls[0][1] + + assert p_hass is hass + assert p_entry.data == { + 'token': 'supersecret' + } + + +@asyncio.coroutine +def test_entries_gets_entries(manager): + """Test entries are filtered by domain.""" + MockConfigEntry(domain='test').add_to_manager(manager) + entry1 = MockConfigEntry(domain='test2') + entry1.add_to_manager(manager) + entry2 = MockConfigEntry(domain='test2') + entry2.add_to_manager(manager) + + assert manager.async_entries('test2') == [entry1, entry2] + + +@asyncio.coroutine +def test_domains_gets_uniques(manager): + """Test we only return each domain once.""" + MockConfigEntry(domain='test').add_to_manager(manager) + MockConfigEntry(domain='test2').add_to_manager(manager) + MockConfigEntry(domain='test2').add_to_manager(manager) + MockConfigEntry(domain='test').add_to_manager(manager) + MockConfigEntry(domain='test3').add_to_manager(manager) + + assert manager.async_domains() == ['test', 'test2', 'test3'] + + +@asyncio.coroutine +def test_saving_and_loading(hass): + """Test that we're saving and loading correctly.""" + class TestFlow(config_entries.ConfigFlowHandler): + VERSION = 5 + + @asyncio.coroutine + def async_step_init(self, user_input=None): + return self.async_create_entry( + title='Test Title', + data={ + 'token': 'abcd' + } + ) + + with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): + yield from hass.config_entries.flow.async_init('test') + + class Test2Flow(config_entries.ConfigFlowHandler): + VERSION = 3 + + @asyncio.coroutine + def async_step_init(self, user_input=None): + return self.async_create_entry( + title='Test 2 Title', + data={ + 'username': 'bla' + } + ) + + json_path = 'homeassistant.util.json.open' + + with patch('homeassistant.config_entries.HANDLERS.get', + return_value=Test2Flow), \ + patch.object(config_entries, 'SAVE_DELAY', 0): + yield from hass.config_entries.flow.async_init('test') + + with patch(json_path, mock_open(), create=True) as mock_write: + # To trigger the call_later + yield from asyncio.sleep(0, loop=hass.loop) + # To execute the save + yield from hass.async_block_till_done() + + # Mock open calls are: open file, context enter, write, context leave + written = mock_write.mock_calls[2][1][0] + + # Now load written data in new config manager + manager = config_entries.ConfigEntries(hass, {}) + + with patch('os.path.isfile', return_value=True), \ + patch(json_path, mock_open(read_data=written), create=True): + yield from manager.async_load() + + # Ensure same order + for orig, loaded in zip(hass.config_entries.async_entries(), + manager.async_entries()): + assert orig.version == loaded.version + assert orig.domain == loaded.domain + assert orig.title == loaded.title + assert orig.data == loaded.data + assert orig.source == loaded.source + + +####################### +# FLOW MANAGER TESTS # +####################### + +@asyncio.coroutine +def test_configure_reuses_handler_instance(manager): + """Test that we reuse instances.""" + class TestFlow(config_entries.ConfigFlowHandler): + handle_count = 0 + + @asyncio.coroutine + def async_step_init(self, user_input=None): + self.handle_count += 1 + return self.async_show_form( + title=str(self.handle_count), + step_id='init') + + with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): + form = yield from manager.flow.async_init('test') + assert form['title'] == '1' + form = yield from manager.flow.async_configure(form['flow_id']) + assert form['title'] == '2' + assert len(manager.flow.async_progress()) == 1 + assert len(manager.async_entries()) == 0 + + +@asyncio.coroutine +def test_configure_two_steps(manager): + """Test that we reuse instances.""" + class TestFlow(config_entries.ConfigFlowHandler): + VERSION = 1 + + @asyncio.coroutine + def async_step_init(self, user_input=None): + if user_input is not None: + self.init_data = user_input + return self.async_step_second() + return self.async_show_form( + title='title', + step_id='init', + data_schema=vol.Schema([str]) + ) + + @asyncio.coroutine + def async_step_second(self, user_input=None): + if user_input is not None: + return self.async_create_entry( + title='Test Entry', + data=self.init_data + user_input + ) + return self.async_show_form( + title='title', + step_id='second', + data_schema=vol.Schema([str]) + ) + + with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): + form = yield from manager.flow.async_init('test') + + with pytest.raises(vol.Invalid): + form = yield from manager.flow.async_configure( + form['flow_id'], 'INCORRECT-DATA') + + form = yield from manager.flow.async_configure( + form['flow_id'], ['INIT-DATA']) + form = yield from manager.flow.async_configure( + form['flow_id'], ['SECOND-DATA']) + assert form['type'] == config_entries.RESULT_TYPE_CREATE_ENTRY + assert len(manager.flow.async_progress()) == 0 + assert len(manager.async_entries()) == 1 + entry = manager.async_entries()[0] + assert entry.domain == 'test' + assert entry.data == ['INIT-DATA', 'SECOND-DATA'] + + +@asyncio.coroutine +def test_show_form(manager): + """Test that abort removes the flow from progress.""" + schema = vol.Schema({ + vol.Required('username'): str, + vol.Required('password'): str + }) + + class TestFlow(config_entries.ConfigFlowHandler): + @asyncio.coroutine + def async_step_init(self, user_input=None): + return self.async_show_form( + title='Hello form', + step_id='init', + description='test-description', + data_schema=schema, + errors={ + 'username': 'Should be unique.' + } + ) + + with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): + form = yield from manager.flow.async_init('test') + assert form['type'] == 'form' + assert form['title'] == 'Hello form' + assert form['description'] == 'test-description' + assert form['data_schema'] is schema + assert form['errors'] == { + 'username': 'Should be unique.' + } + + +@asyncio.coroutine +def test_abort_removes_instance(manager): + """Test that abort removes the flow from progress.""" + class TestFlow(config_entries.ConfigFlowHandler): + is_new = True + + @asyncio.coroutine + def async_step_init(self, user_input=None): + old = self.is_new + self.is_new = False + return self.async_abort(reason=str(old)) + + with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): + form = yield from manager.flow.async_init('test') + assert form['reason'] == 'True' + assert len(manager.flow.async_progress()) == 0 + assert len(manager.async_entries()) == 0 + form = yield from manager.flow.async_init('test') + assert form['reason'] == 'True' + assert len(manager.flow.async_progress()) == 0 + assert len(manager.async_entries()) == 0 + + +@asyncio.coroutine +def test_create_saves_data(manager): + """Test creating a config entry.""" + class TestFlow(config_entries.ConfigFlowHandler): + VERSION = 5 + + @asyncio.coroutine + def async_step_init(self, user_input=None): + return self.async_create_entry( + title='Test Title', + data='Test Data' + ) + + with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): + yield from manager.flow.async_init('test') + assert len(manager.flow.async_progress()) == 0 + assert len(manager.async_entries()) == 1 + + entry = manager.async_entries()[0] + assert entry.version == 5 + assert entry.domain == 'test' + assert entry.title == 'Test Title' + assert entry.data == 'Test Data' + assert entry.source == config_entries.SOURCE_USER + + +@asyncio.coroutine +def test_discovery_init_flow(manager): + """Test a flow initialized by discovery.""" + class TestFlow(config_entries.ConfigFlowHandler): + VERSION = 5 + + @asyncio.coroutine + def async_step_discovery(self, info): + return self.async_create_entry(title=info['id'], data=info) + + data = { + 'id': 'hello', + 'token': 'secret' + } + + with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): + yield from manager.flow.async_init( + 'test', source=config_entries.SOURCE_DISCOVERY, data=data) + assert len(manager.flow.async_progress()) == 0 + assert len(manager.async_entries()) == 1 + + entry = manager.async_entries()[0] + assert entry.version == 5 + assert entry.domain == 'test' + assert entry.title == 'hello' + assert entry.data == data + assert entry.source == config_entries.SOURCE_DISCOVERY diff --git a/virtualization/vagrant/Vagrantfile b/virtualization/vagrant/Vagrantfile index 21d5bd04adc..e50c4e6de00 100644 --- a/virtualization/vagrant/Vagrantfile +++ b/virtualization/vagrant/Vagrantfile @@ -2,7 +2,7 @@ # vi: set ft=ruby : Vagrant.configure(2) do |config| - config.vm.box = "debian/contrib-jessie64" + config.vm.box = "debian/contrib-stretch64" config.vm.synced_folder "../../", "/home-assistant" config.vm.synced_folder "./config", "/root/.homeassistant" config.vm.network "forwarded_port", guest: 8123, host: 8123 diff --git a/virtualization/vagrant/home-assistant@.service b/virtualization/vagrant/home-assistant@.service index 8e520952db9..91b7307f30f 100644 --- a/virtualization/vagrant/home-assistant@.service +++ b/virtualization/vagrant/home-assistant@.service @@ -16,5 +16,8 @@ ExecStart=/usr/bin/hass --runner SendSIGKILL=no RestartForceExitStatus=100 +# on vagrant (vboxfs), disable sendfile https://www.virtualbox.org/ticket/9069 +Environment=AIOHTTP_NOSENDFILE=1 + [Install] WantedBy=multi-user.target diff --git a/virtualization/vagrant/provision.sh b/virtualization/vagrant/provision.sh index da5d48c6f18..d4ef4e0b446 100755 --- a/virtualization/vagrant/provision.sh +++ b/virtualization/vagrant/provision.sh @@ -105,7 +105,7 @@ main() { vagrant up --provision; exit ;; esac # ...otherwise we assume it's the Vagrant provisioner - if [ $(hostname) != "contrib-jessie" ]; then usage; exit; fi + if [ $(hostname) != "contrib-jessie" ] && [ $(hostname) != "contrib-stretch" ]; then usage; exit; fi if ! [ -f $SETUP_DONE ]; then setup; fi if [ -f $RESTART ]; then restart; fi if [ -f $RUN_TESTS ]; then run_tests; fi