diff --git a/.coveragerc b/.coveragerc index bd23599aa0d..bf0db4bd986 100644 --- a/.coveragerc +++ b/.coveragerc @@ -36,32 +36,34 @@ omit = homeassistant/components/*/mysensors.py homeassistant/components/binary_sensor/arest.py + homeassistant/components/binary_sensor/rest.py homeassistant/components/browser.py homeassistant/components/camera/* homeassistant/components/device_tracker/actiontec.py homeassistant/components/device_tracker/aruba.py homeassistant/components/device_tracker/asuswrt.py homeassistant/components/device_tracker/ddwrt.py - homeassistant/components/device_tracker/geofancy.py + homeassistant/components/device_tracker/fritz.py + homeassistant/components/device_tracker/locative.py + homeassistant/components/device_tracker/icloud.py homeassistant/components/device_tracker/luci.py - homeassistant/components/device_tracker/ubus.py homeassistant/components/device_tracker/netgear.py homeassistant/components/device_tracker/nmap_tracker.py homeassistant/components/device_tracker/owntracks.py + homeassistant/components/device_tracker/snmp.py homeassistant/components/device_tracker/thomson.py homeassistant/components/device_tracker/tomato.py homeassistant/components/device_tracker/tplink.py - homeassistant/components/device_tracker/snmp.py + homeassistant/components/device_tracker/ubus.py homeassistant/components/discovery.py homeassistant/components/downloader.py homeassistant/components/ifttt.py homeassistant/components/influxdb.py homeassistant/components/keyboard.py - homeassistant/components/light/hue.py - homeassistant/components/light/mqtt.py - homeassistant/components/light/limitlessled.py homeassistant/components/light/blinksticklight.py + homeassistant/components/light/hue.py homeassistant/components/light/hyperion.py + homeassistant/components/light/limitlessled.py homeassistant/components/media_player/cast.py homeassistant/components/media_player/denon.py homeassistant/components/media_player/firetv.py @@ -69,9 +71,8 @@ omit = homeassistant/components/media_player/kodi.py homeassistant/components/media_player/mpd.py homeassistant/components/media_player/plex.py - homeassistant/components/media_player/squeezebox.py homeassistant/components/media_player/sonos.py - homeassistant/components/notify/file.py + homeassistant/components/media_player/squeezebox.py homeassistant/components/notify/instapush.py homeassistant/components/notify/nma.py homeassistant/components/notify/pushbullet.py @@ -84,10 +85,11 @@ omit = homeassistant/components/notify/xmpp.py homeassistant/components/sensor/arest.py homeassistant/components/sensor/bitcoin.py - homeassistant/components/sensor/command_sensor.py homeassistant/components/sensor/cpuspeed.py homeassistant/components/sensor/dht.py + homeassistant/components/sensor/dweet.py homeassistant/components/sensor/efergy.py + homeassistant/components/sensor/eliqonline.py homeassistant/components/sensor/forecast.py homeassistant/components/sensor/glances.py homeassistant/components/sensor/openweathermap.py @@ -98,10 +100,11 @@ omit = homeassistant/components/sensor/systemmonitor.py homeassistant/components/sensor/temper.py homeassistant/components/sensor/time_date.py + homeassistant/components/sensor/torque.py homeassistant/components/sensor/transmission.py + homeassistant/components/sensor/twitch.py homeassistant/components/sensor/worldclock.py homeassistant/components/switch/arest.py - homeassistant/components/switch/command_switch.py homeassistant/components/switch/edimax.py homeassistant/components/switch/hikvisioncam.py homeassistant/components/switch/mystrom.py @@ -110,6 +113,7 @@ omit = homeassistant/components/switch/rpi_gpio.py homeassistant/components/switch/transmission.py homeassistant/components/switch/wemo.py + homeassistant/components/thermostat/heatmiser.py homeassistant/components/thermostat/homematic.py homeassistant/components/thermostat/honeywell.py homeassistant/components/thermostat/nest.py diff --git a/.travis.yml b/.travis.yml index f12d318b5d4..a75cf6685d3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,11 +2,16 @@ sudo: false language: python cache: directories: - - $HOME/virtualenv/python$TRAVIS_PYTHON_VERSION/ + - $HOME/.cache/pip + # - "$HOME/virtualenv/python$TRAVIS_PYTHON_VERSION" python: - - 3.4.2 - - 3.5.0 + - 3.4 + - 3.5 install: + # Validate requirements_all.txt on Python 3.5 + - if [[ $TRAVIS_PYTHON_VERSION == '3.5' ]]; then python3 setup.py develop; script/gen_requirements_all.py validate; fi - script/bootstrap_server script: - script/cibuild +matrix: + fast_finish: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 03e8e88a35f..1606149a1c7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,8 +17,7 @@ For help on building your component, please see the [developer documentation](ht After you finish adding support for your device: - - Add a link to the website of your device/service/component in the "examples" listing of the `README.md` file. - - Add any new dependencies to `requirements_all.txt` if needed. There is no ordering right now, so just add it to the end of the file. + - Add any new dependencies to `requirements_all.txt` if needed. Use `script/gen_requirements_all.py`. - Update the `.coveragerc` file to exclude your platform if there are no tests available. - Provide some documentation for [home-assistant.io](https://home-assistant.io/). It's OK to just add a docstring with configuration details (sample entry for `configuration.yaml` file and alike) to the file header as a start. Visit the [website documentation](https://home-assistant.io/developers/website/) for further information on contributing to [home-assistant.io](https://github.com/balloob/home-assistant.io). - Make sure all your code passes ``pylint`` and ``flake8`` (PEP8 and some more) validation. To check your repository, run `./script/lint`. diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 41377aadebf..b704fc082ac 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -275,7 +275,7 @@ def enable_logging(hass, verbose=False, daemon=False, log_rotate_days=None): datefmt='%y-%m-%d %H:%M:%S')) logger = logging.getLogger('') logger.addHandler(err_handler) - logger.setLevel(logging.INFO) # this sets the minimum log level + logger.setLevel(logging.INFO) else: _LOGGER.error( diff --git a/homeassistant/components/alexa.py b/homeassistant/components/alexa.py new file mode 100644 index 00000000000..0b06f3c9a79 --- /dev/null +++ b/homeassistant/components/alexa.py @@ -0,0 +1,186 @@ +""" +components.alexa +~~~~~~~~~~~~~~~~ +Component to offer a service end point for an Alexa skill. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/alexa/ +""" +import enum +import logging + +from homeassistant.const import HTTP_OK, HTTP_UNPROCESSABLE_ENTITY +from homeassistant.util import template + +DOMAIN = 'alexa' +DEPENDENCIES = ['http'] + +_LOGGER = logging.getLogger(__name__) +_CONFIG = {} + +API_ENDPOINT = '/api/alexa' + +CONF_INTENTS = 'intents' +CONF_CARD = 'card' +CONF_SPEECH = 'speech' + + +def setup(hass, config): + """ Activate Alexa component. """ + _CONFIG.update(config[DOMAIN].get(CONF_INTENTS, {})) + + hass.http.register_path('POST', API_ENDPOINT, _handle_alexa, True) + + return True + + +def _handle_alexa(handler, path_match, data): + """ Handle Alexa. """ + _LOGGER.debug('Received Alexa request: %s', data) + + req = data.get('request') + + if req is None: + _LOGGER.error('Received invalid data from Alexa: %s', data) + handler.write_json_message( + "Invalid value received for port", HTTP_UNPROCESSABLE_ENTITY) + return + + req_type = req['type'] + + if req_type == 'SessionEndedRequest': + handler.send_response(HTTP_OK) + handler.end_headers() + return + + intent = req.get('intent') + response = AlexaResponse(handler.server.hass, intent) + + if req_type == 'LaunchRequest': + response.add_speech( + SpeechType.plaintext, + "Hello, and welcome to the future. How may I help?") + handler.write_json(response.as_dict()) + return + + if req_type != 'IntentRequest': + _LOGGER.warning('Received unsupported request: %s', req_type) + return + + intent_name = intent['name'] + config = _CONFIG.get(intent_name) + + if config is None: + _LOGGER.warning('Received unknown intent %s', intent_name) + response.add_speech( + SpeechType.plaintext, + "This intent is not yet configured within Home Assistant.") + handler.write_json(response.as_dict()) + return + + speech = config.get(CONF_SPEECH) + card = config.get(CONF_CARD) + + # pylint: disable=unsubscriptable-object + if speech is not None: + response.add_speech(SpeechType[speech['type']], speech['text']) + + if card is not None: + response.add_card(CardType[card['type']], card['title'], + card['content']) + + handler.write_json(response.as_dict()) + + +class SpeechType(enum.Enum): + """ Alexa speech types. """ + plaintext = "PlainText" + ssml = "SSML" + + +class CardType(enum.Enum): + """ Alexa card types. """ + simple = "Simple" + link_account = "LinkAccount" + + +class AlexaResponse(object): + """ Helps generating the response for Alexa. """ + + def __init__(self, hass, intent=None): + self.hass = hass + self.speech = None + self.card = None + self.reprompt = None + self.session_attributes = {} + self.should_end_session = True + if intent is not None and 'slots' in intent: + self.variables = {key: value['value'] for key, value + in intent['slots'].items() if 'value' in value} + else: + self.variables = {} + + def add_card(self, card_type, title, content): + """ Add a card to the response. """ + assert self.card is None + + card = { + "type": card_type.value + } + + if card_type == CardType.link_account: + self.card = card + return + + card["title"] = self._render(title), + card["content"] = self._render(content) + self.card = card + + def add_speech(self, speech_type, text): + """ Add speech to the response. """ + assert self.speech is None + + key = 'ssml' if speech_type == SpeechType.ssml else 'text' + + self.speech = { + 'type': speech_type.value, + key: self._render(text) + } + + def add_reprompt(self, speech_type, text): + """ Add repromopt if user does not answer. """ + assert self.reprompt is None + + key = 'ssml' if speech_type == SpeechType.ssml else 'text' + + self.reprompt = { + 'type': speech_type.value, + key: self._render(text) + } + + def as_dict(self): + """ Returns response in an Alexa valid dict. """ + response = { + 'shouldEndSession': self.should_end_session + } + + if self.card is not None: + response['card'] = self.card + + if self.speech is not None: + response['outputSpeech'] = self.speech + + if self.reprompt is not None: + response['reprompt'] = { + 'outputSpeech': self.reprompt + } + + return { + 'version': '1.0', + 'sessionAttributes': self.session_attributes, + 'response': response, + } + + def _render(self, template_string): + """ Render a response, adding data from intent if available. """ + return template.render(self.hass, template_string, self.variables) diff --git a/homeassistant/components/api.py b/homeassistant/components/api.py index 1e6e66baee0..11f1826549e 100644 --- a/homeassistant/components/api.py +++ b/homeassistant/components/api.py @@ -12,16 +12,19 @@ import threading import json import homeassistant.core as ha +from homeassistant.exceptions import TemplateError from homeassistant.helpers.state import TrackStates import homeassistant.remote as rem +from homeassistant.util import template from homeassistant.bootstrap import ERROR_LOG_FILENAME from homeassistant.const import ( URL_API, URL_API_STATES, URL_API_EVENTS, URL_API_SERVICES, URL_API_STREAM, URL_API_EVENT_FORWARD, URL_API_STATES_ENTITY, URL_API_COMPONENTS, URL_API_CONFIG, URL_API_BOOTSTRAP, URL_API_ERROR_LOG, URL_API_LOG_OUT, - EVENT_TIME_CHANGED, EVENT_HOMEASSISTANT_STOP, MATCH_ALL, + URL_API_TEMPLATE, EVENT_TIME_CHANGED, EVENT_HOMEASSISTANT_STOP, MATCH_ALL, HTTP_OK, HTTP_CREATED, HTTP_BAD_REQUEST, HTTP_NOT_FOUND, - HTTP_UNPROCESSABLE_ENTITY) + HTTP_UNPROCESSABLE_ENTITY, HTTP_HEADER_CONTENT_TYPE, + CONTENT_TYPE_TEXT_PLAIN) DOMAIN = 'api' @@ -91,6 +94,9 @@ def setup(hass, config): hass.http.register_path('POST', URL_API_LOG_OUT, _handle_post_api_log_out) + hass.http.register_path('POST', URL_API_TEMPLATE, + _handle_post_api_template) + return True @@ -120,22 +126,23 @@ def _handle_get_api_stream(handler, path_match, data): try: wfile.write(msg.encode("UTF-8")) wfile.flush() - handler.server.sessions.extend_validation(session_id) - except IOError: + except (IOError, ValueError): + # IOError: socket errors + # ValueError: raised when 'I/O operation on closed file' block.set() def forward_events(event): """ Forwards events to the open request. """ nonlocal gracefully_closed - if block.is_set() or event.event_type == EVENT_TIME_CHANGED or \ - restrict and event.event_type not in restrict: + if block.is_set() or event.event_type == EVENT_TIME_CHANGED: return elif event.event_type == EVENT_HOMEASSISTANT_STOP: gracefully_closed = True block.set() return + handler.server.sessions.extend_validation(session_id) write_message(json.dumps(event, cls=rem.JSONEncoder)) handler.send_response(HTTP_OK) @@ -143,7 +150,11 @@ def _handle_get_api_stream(handler, path_match, data): session_id = handler.set_session_cookie_header() handler.end_headers() - hass.bus.listen(MATCH_ALL, forward_events) + if restrict: + for event in restrict: + hass.bus.listen(event, forward_events) + else: + hass.bus.listen(MATCH_ALL, forward_events) while True: write_message(STREAM_PING_PAYLOAD) @@ -157,7 +168,11 @@ def _handle_get_api_stream(handler, path_match, data): _LOGGER.info("Found broken event stream to %s, cleaning up", handler.client_address[0]) - hass.bus.remove_listener(MATCH_ALL, forward_events) + if restrict: + for event in restrict: + hass.bus.remove_listener(event, forward_events) + else: + hass.bus.remove_listener(MATCH_ALL, forward_events) def _handle_get_api_config(handler, path_match, data): @@ -359,6 +374,22 @@ def _handle_post_api_log_out(handler, path_match, data): handler.end_headers() +def _handle_post_api_template(handler, path_match, data): + """ Log user out. """ + template_string = data.get('template', '') + + try: + rendered = template.render(handler.server.hass, template_string) + + handler.send_response(HTTP_OK) + handler.send_header(HTTP_HEADER_CONTENT_TYPE, CONTENT_TYPE_TEXT_PLAIN) + handler.end_headers() + handler.wfile.write(rendered.encode('utf-8')) + except TemplateError as e: + handler.write_json_message(str(e), HTTP_UNPROCESSABLE_ENTITY) + return + + def _services_json(hass): """ Generate services data to JSONify. """ return [{"domain": key, "services": value} diff --git a/homeassistant/components/automation/numeric_state.py b/homeassistant/components/automation/numeric_state.py index 9f099100084..f2baf760748 100644 --- a/homeassistant/components/automation/numeric_state.py +++ b/homeassistant/components/automation/numeric_state.py @@ -8,13 +8,14 @@ at https://home-assistant.io/components/automation/#numeric-state-trigger """ import logging +from homeassistant.const import CONF_VALUE_TEMPLATE from homeassistant.helpers.event import track_state_change +from homeassistant.util import template CONF_ENTITY_ID = "entity_id" CONF_BELOW = "below" CONF_ABOVE = "above" -CONF_ATTRIBUTE = "attribute" _LOGGER = logging.getLogger(__name__) @@ -29,7 +30,7 @@ def trigger(hass, config, action): below = config.get(CONF_BELOW) above = config.get(CONF_ABOVE) - attribute = config.get(CONF_ATTRIBUTE) + value_template = config.get(CONF_VALUE_TEMPLATE) if below is None and above is None: _LOGGER.error("Missing configuration key." @@ -37,13 +38,20 @@ def trigger(hass, config, action): CONF_BELOW, CONF_ABOVE) return False + if value_template is not None: + renderer = lambda value: template.render(hass, + value_template, + {'state': value}) + else: + renderer = lambda value: value.state + # pylint: disable=unused-argument def state_automation_listener(entity, from_s, to_s): """ Listens for state changes and calls action. """ # Fire action if we go from outside range into range - if _in_range(to_s, above, below, attribute) and \ - (from_s is None or not _in_range(from_s, above, below, attribute)): + if _in_range(above, below, renderer(to_s)) and \ + (from_s is None or not _in_range(above, below, renderer(from_s))): action() track_state_change( @@ -63,7 +71,7 @@ def if_action(hass, config): below = config.get(CONF_BELOW) above = config.get(CONF_ABOVE) - attribute = config.get(CONF_ATTRIBUTE) + value_template = config.get(CONF_VALUE_TEMPLATE) if below is None and above is None: _LOGGER.error("Missing configuration key." @@ -71,22 +79,28 @@ def if_action(hass, config): CONF_BELOW, CONF_ABOVE) return None + if value_template is not None: + renderer = lambda value: template.render(hass, + value_template, + {'state': value}) + else: + renderer = lambda value: value.state + def if_numeric_state(): """ Test numeric state condition. """ state = hass.states.get(entity_id) - return state is not None and _in_range(state, above, below, attribute) + return state is not None and _in_range(above, below, renderer(state)) return if_numeric_state -def _in_range(state, range_start, range_end, attribute): +def _in_range(range_start, range_end, value): """ Checks if value is inside the range """ - value = (state.state if attribute is None - else state.attributes.get(attribute)) try: value = float(value) except ValueError: - _LOGGER.warning("Missing value in numeric check") + _LOGGER.warning("Value returned from template is not a number: %s", + value) return False if range_start is not None and range_end is not None: diff --git a/homeassistant/components/automation/template.py b/homeassistant/components/automation/template.py new file mode 100644 index 00000000000..8615538c42a --- /dev/null +++ b/homeassistant/components/automation/template.py @@ -0,0 +1,65 @@ +""" +homeassistant.components.automation.template +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Offers template automation rules. + +For more details about this automation rule, please refer to the documentation +at https://home-assistant.io/components/automation/#template-trigger +""" +import logging + +from homeassistant.const import CONF_VALUE_TEMPLATE, EVENT_STATE_CHANGED +from homeassistant.exceptions import TemplateError +from homeassistant.util import template + +_LOGGER = logging.getLogger(__name__) + + +def trigger(hass, config, action): + """ Listen for state changes based on `config`. """ + value_template = config.get(CONF_VALUE_TEMPLATE) + + if value_template is None: + _LOGGER.error("Missing configuration key %s", CONF_VALUE_TEMPLATE) + return False + + # Local variable to keep track of if the action has already been triggered + already_triggered = False + + def event_listener(event): + """ Listens for state changes and calls action. """ + nonlocal already_triggered + template_result = _check_template(hass, value_template) + + # Check to see if template returns true + if template_result and not already_triggered: + already_triggered = True + action() + elif not template_result: + already_triggered = False + + hass.bus.listen(EVENT_STATE_CHANGED, event_listener) + return True + + +def if_action(hass, config): + """ Wraps action method with state based condition. """ + + value_template = config.get(CONF_VALUE_TEMPLATE) + + if value_template is None: + _LOGGER.error("Missing configuration key %s", CONF_VALUE_TEMPLATE) + return False + + return lambda: _check_template(hass, value_template) + + +def _check_template(hass, value_template): + """ Checks if result of template is true """ + try: + value = template.render(hass, value_template, {}) + except TemplateError: + _LOGGER.exception('Error parsing template') + return False + + return value.lower() == 'true' diff --git a/homeassistant/components/binary_sensor/mqtt.py b/homeassistant/components/binary_sensor/mqtt.py index cac991d4eb2..916f1226c82 100644 --- a/homeassistant/components/binary_sensor/mqtt.py +++ b/homeassistant/components/binary_sensor/mqtt.py @@ -7,7 +7,10 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.mqtt/ """ import logging + +from homeassistant.const import CONF_VALUE_TEMPLATE from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.util import template import homeassistant.components.mqtt as mqtt _LOGGER = logging.getLogger(__name__) @@ -34,13 +37,15 @@ def setup_platform(hass, config, add_devices, discovery_info=None): config.get('state_topic', None), config.get('qos', DEFAULT_QOS), config.get('payload_on', DEFAULT_PAYLOAD_ON), - config.get('payload_off', DEFAULT_PAYLOAD_OFF))]) + config.get('payload_off', DEFAULT_PAYLOAD_OFF), + config.get(CONF_VALUE_TEMPLATE))]) # pylint: disable=too-many-arguments, too-many-instance-attributes class MqttBinarySensor(BinarySensorDevice): """ Represents a binary sensor that is updated by MQTT. """ - def __init__(self, hass, name, state_topic, qos, payload_on, payload_off): + def __init__(self, hass, name, state_topic, qos, payload_on, payload_off, + value_template): self._hass = hass self._name = name self._state = False @@ -51,6 +56,9 @@ class MqttBinarySensor(BinarySensorDevice): def message_received(topic, payload, qos): """ A new MQTT message has been received. """ + if value_template is not None: + payload = template.render_with_possible_json_value( + hass, value_template, payload) if payload == self._payload_on: self._state = True self.update_ha_state() diff --git a/homeassistant/components/binary_sensor/rest.py b/homeassistant/components/binary_sensor/rest.py new file mode 100644 index 00000000000..6cb6ede5e50 --- /dev/null +++ b/homeassistant/components/binary_sensor/rest.py @@ -0,0 +1,144 @@ +""" +homeassistant.components.binary_sensor.rest +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The rest binary sensor will consume responses sent by an exposed REST API. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.rest/ +""" +from datetime import timedelta +import logging +import requests + +from homeassistant.const import CONF_VALUE_TEMPLATE +from homeassistant.util import template, Throttle +from homeassistant.components.binary_sensor import BinarySensorDevice + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'REST Binary Sensor' +DEFAULT_METHOD = 'GET' + +# Return cached results if last scan was less then this time ago +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) + + +# pylint: disable=unused-variable +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Get the REST binary sensor. """ + + use_get = False + use_post = False + + resource = config.get('resource', None) + method = config.get('method', DEFAULT_METHOD) + payload = config.get('payload', None) + verify_ssl = config.get('verify_ssl', True) + + if method == 'GET': + use_get = True + elif method == 'POST': + use_post = True + + try: + if use_get: + response = requests.get(resource, timeout=10, verify=verify_ssl) + elif use_post: + response = requests.post(resource, data=payload, timeout=10, + verify=verify_ssl) + if not response.ok: + _LOGGER.error("Response status is '%s'", response.status_code) + return False + except requests.exceptions.MissingSchema: + _LOGGER.error("Missing resource or schema in configuration. " + "Add http:// or https:// to your URL") + return False + except requests.exceptions.ConnectionError: + _LOGGER.error('No route to resource/endpoint: %s', resource) + return False + + if use_get: + rest = RestDataGet(resource, verify_ssl) + elif use_post: + rest = RestDataPost(resource, payload, verify_ssl) + + add_devices([RestBinarySensor(hass, + rest, + config.get('name', DEFAULT_NAME), + config.get(CONF_VALUE_TEMPLATE))]) + + +# pylint: disable=too-many-arguments +class RestBinarySensor(BinarySensorDevice): + """ Implements a REST binary sensor. """ + + def __init__(self, hass, rest, name, value_template): + self._hass = hass + self.rest = rest + self._name = name + self._state = False + self._value_template = value_template + self.update() + + @property + def name(self): + """ The name of the binary sensor. """ + return self._name + + @property + def is_on(self): + """ True if the binary sensor is on. """ + if self.rest.data is False: + return False + else: + if self._value_template is not None: + self.rest.data = template.render_with_possible_json_value( + self._hass, self._value_template, self.rest.data, False) + return bool(int(self.rest.data)) + + def update(self): + """ Gets the latest data from REST API and updates the state. """ + self.rest.update() + + +# pylint: disable=too-few-public-methods +class RestDataGet(object): + """ Class for handling the data retrieval with GET method. """ + + def __init__(self, resource, verify_ssl): + self._resource = resource + self._verify_ssl = verify_ssl + self.data = False + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """ Gets the latest data from REST service with GET method. """ + try: + response = requests.get(self._resource, timeout=10, + verify=self._verify_ssl) + self.data = response.text + except requests.exceptions.ConnectionError: + _LOGGER.error("No route to resource/endpoint: %s", self._resource) + self.data = False + + +# pylint: disable=too-few-public-methods +class RestDataPost(object): + """ Class for handling the data retrieval with POST method. """ + + def __init__(self, resource, payload, verify_ssl): + self._resource = resource + self._payload = payload + self._verify_ssl = verify_ssl + self.data = False + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """ Gets the latest data from REST service with POST method. """ + try: + response = requests.post(self._resource, data=self._payload, + timeout=10, verify=self._verify_ssl) + self.data = response.text + except requests.exceptions.ConnectionError: + _LOGGER.error("No route to resource/endpoint: %s", self._resource) + self.data = False diff --git a/homeassistant/components/conversation.py b/homeassistant/components/conversation.py index 7cd1193448c..18ddf8fcc8d 100644 --- a/homeassistant/components/conversation.py +++ b/homeassistant/components/conversation.py @@ -9,6 +9,7 @@ https://home-assistant.io/components/conversation/ import logging import re + from homeassistant import core from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF) @@ -21,9 +22,13 @@ ATTR_TEXT = "text" REGEX_TURN_COMMAND = re.compile(r'turn (?P(?: |\w)+) (?P\w+)') +REQUIREMENTS = ['fuzzywuzzy==0.8.0'] + def setup(hass, config): """ Registers the process service. """ + from fuzzywuzzy import process as fuzzyExtract + logger = logging.getLogger(__name__) def process(service): @@ -42,9 +47,11 @@ def setup(hass, config): name, command = match.groups() - entity_ids = [ - state.entity_id for state in hass.states.all() - if state.name.lower() == name] + entities = {state.entity_id: state.name for state in hass.states.all()} + + entity_ids = fuzzyExtract.extractOne(name, + entities, + score_cutoff=65)[2] if not entity_ids: logger.error( diff --git a/homeassistant/components/device_tracker/fritz.py b/homeassistant/components/device_tracker/fritz.py new file mode 100644 index 00000000000..73c2e98792f --- /dev/null +++ b/homeassistant/components/device_tracker/fritz.py @@ -0,0 +1,122 @@ +""" +homeassistant.components.device_tracker.fritz +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Device tracker platform that supports scanning a FRITZ!Box router for device +presence. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.fritz/ +""" +import logging +from datetime import timedelta + +from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD +from homeassistant.helpers import validate_config +from homeassistant.util import Throttle +from homeassistant.components.device_tracker import DOMAIN + +# Return cached results if last scan was less then this time ago +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) + +_LOGGER = logging.getLogger(__name__) + + +# noinspection PyUnusedLocal +def get_scanner(hass, config): + """ Validates config and returns FritzBoxScanner. """ + if not validate_config(config, + {DOMAIN: []}, + _LOGGER): + return None + + scanner = FritzBoxScanner(config[DOMAIN]) + return scanner if scanner.success_init else None + + +# pylint: disable=too-many-instance-attributes +class FritzBoxScanner(object): + """ + This class queries a FRITZ!Box router. It is using the + fritzconnection library for communication with the router. + + The API description can be found under: + https://pypi.python.org/pypi/fritzconnection/0.4.6 + + This scanner retrieves the list of known hosts and checks their + corresponding states (on, or off). + + Due to a bug of the fritzbox api (router side) it is not possible + to track more than 16 hosts. + """ + def __init__(self, config): + self.last_results = [] + self.host = '169.254.1.1' # This IP is valid for all fritzboxes + self.username = 'admin' + self.password = '' + self.success_init = True + + # Try to import the fritzconnection library + try: + # noinspection PyPackageRequirements,PyUnresolvedReferences + import fritzconnection as fc + except ImportError: + _LOGGER.exception("""Failed to import Python library + fritzconnection. Please run + /setup to install it.""") + self.success_init = False + return + + # Check for user specific configuration + if CONF_HOST in config.keys(): + self.host = config[CONF_HOST] + if CONF_USERNAME in config.keys(): + self.username = config[CONF_USERNAME] + if CONF_PASSWORD in config.keys(): + self.password = config[CONF_PASSWORD] + + # Establish a connection to the FRITZ!Box + try: + self.fritz_box = fc.FritzHosts(address=self.host, + user=self.username, + password=self.password) + except (ValueError, TypeError): + self.fritz_box = None + + # At this point it is difficult to tell if a connection is established. + # So just check for null objects ... + if self.fritz_box is None or not self.fritz_box.modelname: + self.success_init = False + + if self.success_init: + _LOGGER.info("Successfully connected to %s", + self.fritz_box.modelname) + self._update_info() + else: + _LOGGER.error("Failed to establish connection to FRITZ!Box " + "with IP: %s", self.host) + + def scan_devices(self): + """ Scan for new devices and return a list of found device ids. """ + self._update_info() + active_hosts = [] + for known_host in self.last_results: + if known_host["status"] == "1": + active_hosts.append(known_host["mac"]) + return active_hosts + + def get_device_name(self, mac): + """ Returns the name of the given device or None if is not known. """ + ret = self.fritz_box.get_specific_host_entry(mac)["NewHostName"] + if ret == {}: + return None + return ret + + @Throttle(MIN_TIME_BETWEEN_SCANS) + def _update_info(self): + """ Retrieves latest information from the FRITZ!Box. """ + if not self.success_init: + return False + + _LOGGER.info("Scanning") + self.last_results = self.fritz_box.get_hosts_info() + return True diff --git a/homeassistant/components/device_tracker/icloud.py b/homeassistant/components/device_tracker/icloud.py new file mode 100644 index 00000000000..76046552551 --- /dev/null +++ b/homeassistant/components/device_tracker/icloud.py @@ -0,0 +1,87 @@ +""" +homeassistant.components.device_tracker.icloud +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Device tracker platform that supports scanning iCloud devices. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.icloud/ +""" +import logging + +import re +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD +from homeassistant.helpers.event import track_utc_time_change + +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ['pyicloud==0.7.2'] + +CONF_INTERVAL = 'interval' +DEFAULT_INTERVAL = 8 + + +def setup_scanner(hass, config, see): + """ Set up the iCloud Scanner. """ + from pyicloud import PyiCloudService + from pyicloud.exceptions import PyiCloudFailedLoginException + from pyicloud.exceptions import PyiCloudNoDevicesException + + # Get the username and password from the configuration + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + + if username is None or password is None: + _LOGGER.error('Must specify a username and password') + return False + + try: + _LOGGER.info('Logging into iCloud Account') + # Attempt the login to iCloud + api = PyiCloudService(username, + password, + verify=True) + except PyiCloudFailedLoginException as error: + _LOGGER.exception('Error logging into iCloud Service: %s', error) + return False + + def keep_alive(now): + """ Keeps authenticating iCloud connection. """ + api.authenticate() + _LOGGER.info("Authenticate against iCloud") + + track_utc_time_change(hass, keep_alive, second=0) + + def update_icloud(now): + """ Authenticate against iCloud and scan for devices. """ + try: + # The session timeouts if we are not using it so we + # have to re-authenticate. This will send an email. + api.authenticate() + # Loop through every device registered with the iCloud account + for device in api.devices: + status = device.status() + location = device.location() + # If the device has a location add it. If not do nothing + if location: + see( + dev_id=re.sub(r"(\s|\W|')", + '', + status['name']), + host_name=status['name'], + gps=(location['latitude'], location['longitude']), + battery=status['batteryLevel']*100, + gps_accuracy=location['horizontalAccuracy'] + ) + else: + # No location found for the device so continue + continue + except PyiCloudNoDevicesException: + _LOGGER.info('No iCloud Devices found!') + + track_utc_time_change( + hass, update_icloud, + minute=range(0, 60, config.get(CONF_INTERVAL, DEFAULT_INTERVAL)), + second=0 + ) + + return True diff --git a/homeassistant/components/device_tracker/geofancy.py b/homeassistant/components/device_tracker/locative.py similarity index 73% rename from homeassistant/components/device_tracker/geofancy.py rename to homeassistant/components/device_tracker/locative.py index a5e6edee71a..2d238992cc7 100644 --- a/homeassistant/components/device_tracker/geofancy.py +++ b/homeassistant/components/device_tracker/locative.py @@ -1,10 +1,10 @@ """ -homeassistant.components.device_tracker.geofancy +homeassistant.components.device_tracker.locative ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Geofancy platform for the device tracker. +Locative platform for the device tracker. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.geofancy/ +https://home-assistant.io/components/device_tracker.locative/ """ from homeassistant.const import ( HTTP_UNPROCESSABLE_ENTITY, HTTP_INTERNAL_SERVER_ERROR) @@ -13,32 +13,32 @@ DEPENDENCIES = ['http'] _SEE = 0 -URL_API_GEOFANCY_ENDPOINT = "/api/geofancy" +URL_API_LOCATIVE_ENDPOINT = "/api/locative" def setup_scanner(hass, config, see): - """ Set up an endpoint for the Geofancy app. """ + """ Set up an endpoint for the Locative app. """ # Use a global variable to keep setup_scanner compact when using a callback global _SEE _SEE = see # POST would be semantically better, but that currently does not work - # since Geofancy sends the data as key1=value1&key2=value2 + # since Locative sends the data as key1=value1&key2=value2 # in the request body, while Home Assistant expects json there. hass.http.register_path( - 'GET', URL_API_GEOFANCY_ENDPOINT, _handle_get_api_geofancy) + 'GET', URL_API_LOCATIVE_ENDPOINT, _handle_get_api_locative) return True -def _handle_get_api_geofancy(handler, path_match, data): - """ Geofancy message received. """ +def _handle_get_api_locative(handler, path_match, data): + """ Locative message received. """ if not isinstance(data, dict): handler.write_json_message( - "Error while parsing Geofancy message.", + "Error while parsing Locative message.", HTTP_INTERNAL_SERVER_ERROR) return if 'latitude' not in data or 'longitude' not in data: @@ -67,4 +67,4 @@ def _handle_get_api_geofancy(handler, path_match, data): _SEE(dev_id=device_entity_id, gps=gps_coords, location_name=data['id']) - handler.write_json_message("Geofancy message processed") + handler.write_json_message("Locative message processed") diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index fc955e30d44..98f0435e9f3 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -11,7 +11,6 @@ import logging from . import version, mdi_version import homeassistant.util as util from homeassistant.const import URL_ROOT, HTTP_OK -from homeassistant.config import get_default_config_dir DOMAIN = 'frontend' DEPENDENCIES = ['api'] @@ -22,7 +21,7 @@ _LOGGER = logging.getLogger(__name__) FRONTEND_URLS = [ URL_ROOT, '/logbook', '/history', '/map', '/devService', '/devState', - '/devEvent', '/devInfo', '/states'] + '/devEvent', '/devInfo', '/devTemplate', '/states'] _FINGERPRINT = re.compile(r'^(\w+)-[a-z0-9]{32}\.(\w+)$', re.IGNORECASE) @@ -109,8 +108,6 @@ def _handle_get_local(handler, path_match, data): """ req_file = util.sanitize_path(path_match.group('file')) - path = os.path.join(get_default_config_dir(), 'www', req_file) - if not os.path.isfile(path): - return False + path = handler.server.hass.config.path('www', req_file) handler.write_file(path) diff --git a/homeassistant/components/frontend/index.html.template b/homeassistant/components/frontend/index.html.template index c0631c9d9db..e21d00e86bc 100644 --- a/homeassistant/components/frontend/index.html.template +++ b/homeassistant/components/frontend/index.html.template @@ -13,7 +13,7 @@