diff --git a/.coveragerc b/.coveragerc index 993f8c9533b..7d482f4db3f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -39,7 +39,9 @@ omit = homeassistant/components/device_tracker/aruba.py homeassistant/components/device_tracker/asuswrt.py homeassistant/components/device_tracker/ddwrt.py + homeassistant/components/device_tracker/fritz.py homeassistant/components/device_tracker/geofancy.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 @@ -84,6 +86,7 @@ omit = 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/forecast.py homeassistant/components/sensor/glances.py @@ -97,6 +100,7 @@ omit = homeassistant/components/sensor/temper.py homeassistant/components/sensor/time_date.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 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/components/alexa.py b/homeassistant/components/alexa.py new file mode 100644 index 00000000000..c261cfd3f6a --- /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()} + 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 6a66a2a110e..18b743d06ca 100644 --- a/homeassistant/components/api.py +++ b/homeassistant/components/api.py @@ -125,22 +125,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) @@ -148,7 +149,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) @@ -162,7 +167,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): diff --git a/homeassistant/components/automation/numeric_state.py b/homeassistant/components/automation/numeric_state.py index 9f099100084..a4cbce8182a 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,18 +79,23 @@ 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: 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/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..fcb808894d5 --- /dev/null +++ b/homeassistant/components/device_tracker/icloud.py @@ -0,0 +1,85 @@ +""" +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 + + 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 + + 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 + ) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index fc955e30d44..37e9b39079e 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'] @@ -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/version.py b/homeassistant/components/frontend/version.py index 83383baf11d..c0bafc4f8f3 100644 --- a/homeassistant/components/frontend/version.py +++ b/homeassistant/components/frontend/version.py @@ -1,2 +1,2 @@ """ DO NOT MODIFY. Auto-generated by build_frontend script """ -VERSION = "aac488c33cd4291cd0924e60a55bd309" +VERSION = "0d8516cd9a13ee2ae3f27c702777e028" diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html index 907722b09ee..7d20e4bb667 100644 --- a/homeassistant/components/frontend/www_static/frontend.html +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -3775,6 +3775,7 @@ case"touchend":return this.addPointerListenerEnd(t,e,i,n);case"touchmove":return .column { max-width: 500px; + overflow-x: hidden; } .zone-card { @@ -3790,6 +3791,12 @@ case"touchend":return this.addPointerListenerEnd(t,e,i,n);case"touchmove":return .zone-card { margin-left: 0; } + } + + @media (max-width: 599px) { + .column { + max-width: 600px; + } } \ No newline at end of file + } \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/home-assistant-polymer b/homeassistant/components/frontend/www_static/home-assistant-polymer index e51b8add369..e19f3c5e34b 160000 --- a/homeassistant/components/frontend/www_static/home-assistant-polymer +++ b/homeassistant/components/frontend/www_static/home-assistant-polymer @@ -1 +1 @@ -Subproject commit e51b8add369f9e81d22b25b4be2400675361afdb +Subproject commit e19f3c5e34bc2f5e5bd2dcc1444bb569fb1c0c68 diff --git a/homeassistant/components/http.py b/homeassistant/components/http.py index 2b260b0e841..7a4e87de5a8 100644 --- a/homeassistant/components/http.py +++ b/homeassistant/components/http.py @@ -178,12 +178,8 @@ class RequestHandler(SimpleHTTPRequestHandler): """ Does some common checks and calls appropriate method. """ url = urlparse(self.path) - # Read query input - data = parse_qs(url.query) - - # parse_qs gives a list for each value, take the latest element - for key in data: - data[key] = data[key][-1] + # Read query input. parse_qs gives a list for each value, we want last + data = {key: data[-1] for key, data in parse_qs(url.query).items()} # Did we get post input ? content_length = int(self.headers.get(HTTP_HEADER_CONTENT_LENGTH, 0)) @@ -363,13 +359,13 @@ class RequestHandler(SimpleHTTPRequestHandler): def set_session_cookie_header(self): """ Add the header for the session cookie and return session id. """ if not self.authenticated: - return + return None session_id = self.get_cookie_session_id() if session_id is not None: self.server.sessions.extend_validation(session_id) - return + return session_id self.send_header( 'Set-Cookie', @@ -426,10 +422,10 @@ def session_valid_time(): class SessionStore(object): """ Responsible for storing and retrieving http sessions """ - def __init__(self, enabled=True): + def __init__(self): """ Set up the session store """ self._sessions = {} - self.lock = threading.RLock() + self._lock = threading.RLock() @util.Throttle(SESSION_CLEAR_INTERVAL) def _remove_expired(self): @@ -441,7 +437,7 @@ class SessionStore(object): def is_valid(self, key): """ Return True if a valid session is given. """ - with self.lock: + with self._lock: self._remove_expired() return (key in self._sessions and @@ -449,17 +445,19 @@ class SessionStore(object): def extend_validation(self, key): """ Extend a session validation time. """ - with self.lock: + with self._lock: + if key not in self._sessions: + return self._sessions[key] = session_valid_time() def destroy(self, key): """ Destroy a session by key. """ - with self.lock: + with self._lock: self._sessions.pop(key, None) def create(self): """ Creates a new session. """ - with self.lock: + with self._lock: session_id = util.get_random_string(20) while session_id in self._sessions: diff --git a/homeassistant/components/light/wink.py b/homeassistant/components/light/wink.py index 2a17c49089d..dde7c616722 100644 --- a/homeassistant/components/light/wink.py +++ b/homeassistant/components/light/wink.py @@ -42,7 +42,7 @@ class WinkLight(WinkToggleDevice): brightness = kwargs.get(ATTR_BRIGHTNESS) if brightness is not None: - self.wink.set_state(True, brightness / 255) + self.wink.set_state(True, brightness=brightness / 255) else: self.wink.set_state(True) diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index 2b343129861..5ad9607d9a3 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -15,7 +15,7 @@ from homeassistant.const import ( from homeassistant.components.media_player import ( MediaPlayerDevice, SUPPORT_PAUSE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_MUTE, - SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_YOUTUBE, + SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_YOUTUBE, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_NEXT_TRACK, MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO) @@ -24,7 +24,7 @@ CONF_IGNORE_CEC = 'ignore_cec' CAST_SPLASH = 'https://home-assistant.io/images/cast/splash.png' SUPPORT_CAST = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PREVIOUS_TRACK | \ - SUPPORT_NEXT_TRACK | SUPPORT_YOUTUBE + SUPPORT_NEXT_TRACK | SUPPORT_YOUTUBE | SUPPORT_PLAY_MEDIA KNOWN_HOSTS = [] # pylint: disable=invalid-name @@ -261,6 +261,10 @@ class CastDevice(MediaPlayerDevice): """ Seek the media to a specific location. """ self.cast.media_controller.seek(position) + def play_media(self, media_type, media_id): + """ Plays media from a URL """ + self.cast.media_controller.play_media(media_id, media_type) + def play_youtube(self, media_id): """ Plays a YouTube media. """ self.youtube.play_video(media_id) diff --git a/homeassistant/components/media_player/mpd.py b/homeassistant/components/media_player/mpd.py index 8134ce5e743..a61dac88150 100644 --- a/homeassistant/components/media_player/mpd.py +++ b/homeassistant/components/media_player/mpd.py @@ -21,14 +21,14 @@ from homeassistant.const import ( from homeassistant.components.media_player import ( MediaPlayerDevice, SUPPORT_PAUSE, SUPPORT_VOLUME_SET, SUPPORT_TURN_OFF, - SUPPORT_PREVIOUS_TRACK, SUPPORT_NEXT_TRACK, + SUPPORT_TURN_ON, SUPPORT_PREVIOUS_TRACK, SUPPORT_NEXT_TRACK, MEDIA_TYPE_MUSIC) _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['python-mpd2==0.5.4'] SUPPORT_MPD = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_TURN_OFF | \ - SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK + SUPPORT_TURN_ON | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK # pylint: disable=unused-argument @@ -141,7 +141,13 @@ class MpdDevice(MediaPlayerDevice): @property def media_title(self): """ Title of current playing media. """ - return self.currentsong['title'] + name = self.currentsong.get('name', None) + title = self.currentsong['title'] + + if name is None: + return title + else: + return '{}: {}'.format(name, title) @property def media_artist(self): @@ -163,9 +169,13 @@ class MpdDevice(MediaPlayerDevice): return SUPPORT_MPD def turn_off(self): - """ Service to exit the running MPD. """ + """ Service to send the MPD the command to stop playing. """ self.client.stop() + def turn_on(self): + """ Service to send the MPD the command to start playing. """ + self.client.play() + def set_volume_level(self, volume): """ Sets volume """ self.client.setvol(int(volume * 100)) diff --git a/homeassistant/components/sensor/arest.py b/homeassistant/components/sensor/arest.py index 3a0ce01719b..298c9b8cb79 100644 --- a/homeassistant/components/sensor/arest.py +++ b/homeassistant/components/sensor/arest.py @@ -11,9 +11,11 @@ import logging import requests -from homeassistant.const import DEVICE_DEFAULT_NAME +from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, \ + DEVICE_DEFAULT_NAME +from homeassistant.exceptions import TemplateError from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle +from homeassistant.util import template, Throttle _LOGGER = logging.getLogger(__name__) @@ -50,36 +52,50 @@ def setup_platform(hass, config, add_devices, discovery_info=None): arest = ArestData(resource) + def make_renderer(value_template): + """ Creates renderer based on variable_template value """ + if value_template is None: + return lambda value: value + + def _render(value): + try: + return template.render(hass, value_template, {'value': value}) + except TemplateError: + _LOGGER.exception('Error parsing value') + return value + + return _render + dev = [] if var_conf is not None: - for variable in config['monitored_variables']: + for variable in var_conf: if variable['name'] not in response['variables']: _LOGGER.error('Variable: "%s" does not exist', variable['name']) continue + renderer = make_renderer(variable.get(CONF_VALUE_TEMPLATE)) dev.append(ArestSensor(arest, resource, config.get('name', response['name']), variable['name'], variable=variable['name'], unit_of_measurement=variable.get( - 'unit_of_measurement'))) + ATTR_UNIT_OF_MEASUREMENT), + renderer=renderer)) if pins is not None: for pinnum, pin in pins.items(): + renderer = make_renderer(pin.get(CONF_VALUE_TEMPLATE)) dev.append(ArestSensor(ArestData(resource, pinnum), resource, config.get('name', response['name']), pin.get('name'), pin=pinnum, unit_of_measurement=pin.get( - 'unit_of_measurement'), - corr_factor=pin.get('correction_factor', - None), - decimal_places=pin.get('decimal_places', - None))) + ATTR_UNIT_OF_MEASUREMENT), + renderer=renderer)) add_devices(dev) @@ -89,8 +105,7 @@ class ArestSensor(Entity): """ Implements an aREST sensor for exposed variables. """ def __init__(self, arest, resource, location, name, variable=None, - pin=None, unit_of_measurement=None, corr_factor=None, - decimal_places=None): + pin=None, unit_of_measurement=None, renderer=None): self.arest = arest self._resource = resource self._name = '{} {}'.format(location.title(), name.title()) \ @@ -99,8 +114,7 @@ class ArestSensor(Entity): self._pin = pin self._state = 'n/a' self._unit_of_measurement = unit_of_measurement - self._corr_factor = corr_factor - self._decimal_places = decimal_places + self._renderer = renderer self.update() if self._pin is not None: @@ -126,17 +140,11 @@ class ArestSensor(Entity): if 'error' in values: return values['error'] - elif 'value' in values: - value = values['value'] - if self._corr_factor is not None: - value = float(value) * float(self._corr_factor) - if self._decimal_places is not None: - value = round(value, self._decimal_places) - if self._decimal_places == 0: - value = int(value) - return value - else: - return values.get(self._variable, 'n/a') + + value = self._renderer(values.get('value', + values.get(self._variable, + 'N/A'))) + return value def update(self): """ Gets the latest data from aREST API. """ diff --git a/homeassistant/components/sensor/command_sensor.py b/homeassistant/components/sensor/command_sensor.py index e60723f6bfa..95751e73645 100644 --- a/homeassistant/components/sensor/command_sensor.py +++ b/homeassistant/components/sensor/command_sensor.py @@ -10,8 +10,9 @@ import logging import subprocess from datetime import timedelta +from homeassistant.const import CONF_VALUE_TEMPLATE from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle +from homeassistant.util import template, Throttle _LOGGER = logging.getLogger(__name__) @@ -32,25 +33,24 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): data = CommandSensorData(config.get('command')) add_devices_callback([CommandSensor( + hass, data, config.get('name', DEFAULT_NAME), config.get('unit_of_measurement'), - config.get('correction_factor', None), - config.get('decimal_places', None) + config.get(CONF_VALUE_TEMPLATE) )]) # pylint: disable=too-many-arguments class CommandSensor(Entity): """ Represents a sensor that is returning a value of a shell commands. """ - def __init__(self, data, name, unit_of_measurement, corr_factor, - decimal_places): + def __init__(self, hass, data, name, unit_of_measurement, value_template): + self._hass = hass self.data = data self._name = name self._state = False self._unit_of_measurement = unit_of_measurement - self._corr_factor = corr_factor - self._decimal_places = decimal_places + self._value_template = value_template self.update() @property @@ -73,16 +73,10 @@ class CommandSensor(Entity): self.data.update() value = self.data.value - try: - if value is not None: - if self._corr_factor is not None: - value = float(value) * float(self._corr_factor) - if self._decimal_places is not None: - value = round(value, self._decimal_places) - if self._decimal_places == 0: - value = int(value) - self._state = value - except ValueError: + if self._value_template is not None: + self._state = template.render_with_possible_json_value( + self._hass, self._value_template, value, 'N/A') + else: self._state = value diff --git a/homeassistant/components/sensor/dweet.py b/homeassistant/components/sensor/dweet.py new file mode 100644 index 00000000000..8aa55c847cf --- /dev/null +++ b/homeassistant/components/sensor/dweet.py @@ -0,0 +1,118 @@ +""" +homeassistant.components.sensor.dweet +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Displays values from Dweet.io. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.dweet/ +""" +from datetime import timedelta +import logging +import json + +from homeassistant.util import Throttle +from homeassistant.util import template +from homeassistant.helpers.entity import Entity +from homeassistant.const import (STATE_UNKNOWN, CONF_VALUE_TEMPLATE) + +_LOGGER = logging.getLogger(__name__) +REQUIREMENTS = ['dweepy==0.2.0'] + +DEFAULT_NAME = 'Dweet.io Sensor' +CONF_DEVICE = 'device' + +# Return cached results if last scan was less then this time ago +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) + + +# pylint: disable=unused-variable, too-many-function-args +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Setup the Dweet sensor. """ + import dweepy + + device = config.get('device') + value_template = config.get(CONF_VALUE_TEMPLATE) + + if None in (device, value_template): + _LOGGER.error('Not all required config keys present: %s', + ', '.join(CONF_DEVICE, CONF_VALUE_TEMPLATE)) + return False + + try: + content = json.dumps(dweepy.get_latest_dweet_for(device)[0]['content']) + except dweepy.DweepyError: + _LOGGER.error("Device/thing '%s' could not be found", device) + return False + + if template.render_with_possible_json_value(hass, + value_template, + content) is '': + _LOGGER.error("'%s' was not found", value_template) + return False + + dweet = DweetData(device) + + add_devices([DweetSensor(hass, + dweet, + config.get('name', DEFAULT_NAME), + value_template, + config.get('unit_of_measurement'))]) + + +# pylint: disable=too-many-arguments +class DweetSensor(Entity): + """ Implements a Dweet sensor. """ + + def __init__(self, hass, dweet, name, value_template, unit_of_measurement): + self.hass = hass + self.dweet = dweet + self._name = name + self._value_template = value_template + self._state = STATE_UNKNOWN + self._unit_of_measurement = unit_of_measurement + self.update() + + @property + def name(self): + """ The name of the sensor. """ + return self._name + + @property + def unit_of_measurement(self): + """ Unit the value is expressed in. """ + return self._unit_of_measurement + + @property + def state(self): + """ Returns the state. """ + if self.dweet.data is None: + return STATE_UNKNOWN + else: + values = json.dumps(self.dweet.data[0]['content']) + value = template.render_with_possible_json_value( + self.hass, self._value_template, values) + return value + + def update(self): + """ Gets the latest data from REST API. """ + self.dweet.update() + + +# pylint: disable=too-few-public-methods +class DweetData(object): + """ Class for handling the data retrieval. """ + + def __init__(self, device): + self._device = device + self.data = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """ Gets the latest data from Dweet.io. """ + import dweepy + + try: + self.data = dweepy.get_latest_dweet_for(self._device) + except dweepy.DweepyError: + _LOGGER.error("Device '%s' could not be found", self._device) + self.data = None diff --git a/homeassistant/components/sensor/glances.py b/homeassistant/components/sensor/glances.py index 7938ae7e659..c2bd96c8eea 100644 --- a/homeassistant/components/sensor/glances.py +++ b/homeassistant/components/sensor/glances.py @@ -79,7 +79,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if resource not in SENSOR_TYPES: _LOGGER.error('Sensor type: "%s" does not exist', resource) else: - dev.append(GlancesSensor(rest, resource)) + dev.append(GlancesSensor(rest, config.get('name'), resource)) add_devices(dev) @@ -87,9 +87,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class GlancesSensor(Entity): """ Implements a Glances sensor. """ - def __init__(self, rest, sensor_type): + def __init__(self, rest, name, sensor_type): self.rest = rest - self._name = SENSOR_TYPES[sensor_type][0] + self._name = name self.type = sensor_type self._state = STATE_UNKNOWN self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] @@ -98,7 +98,10 @@ class GlancesSensor(Entity): @property def name(self): """ The name of the sensor. """ - return self._name + if self._name is None: + return SENSOR_TYPES[self.type][0] + else: + return '{} {}'.format(self._name, SENSOR_TYPES[self.type][0]) @property def unit_of_measurement(self): diff --git a/homeassistant/components/sensor/rest.py b/homeassistant/components/sensor/rest.py index f50113350da..4dcd036df5e 100644 --- a/homeassistant/components/sensor/rest.py +++ b/homeassistant/components/sensor/rest.py @@ -1,18 +1,17 @@ """ homeassistant.components.sensor.rest ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -The rest sensor will consume JSON responses sent by an exposed REST API. +The rest 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/sensor.rest/ """ from datetime import timedelta -from json import loads import logging - import requests -from homeassistant.util import Throttle +from homeassistant.const import CONF_VALUE_TEMPLATE +from homeassistant.util import template, Throttle from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) @@ -59,57 +58,31 @@ def setup_platform(hass, config, add_devices, discovery_info=None): 'Please check the URL in the configuration file.') return False - try: - data = loads(response.text) - except ValueError: - _LOGGER.error('No valid JSON in the response in: %s', data) - return False - - try: - RestSensor.extract_value(data, config.get('variable')) - except KeyError: - _LOGGER.error('Variable "%s" not found in response: "%s"', - config.get('variable'), data) - return False - if use_get: rest = RestDataGet(resource, verify_ssl) elif use_post: rest = RestDataPost(resource, payload, verify_ssl) - add_devices([RestSensor(rest, + add_devices([RestSensor(hass, + rest, config.get('name', DEFAULT_NAME), - config.get('variable'), config.get('unit_of_measurement'), - config.get('correction_factor', None), - config.get('decimal_places', None))]) + config.get(CONF_VALUE_TEMPLATE))]) # pylint: disable=too-many-arguments class RestSensor(Entity): """ Implements a REST sensor. """ - def __init__(self, rest, name, variable, unit_of_measurement, corr_factor, - decimal_places): + def __init__(self, hass, rest, name, unit_of_measurement, value_template): + self._hass = hass self.rest = rest self._name = name - self._variable = variable self._state = 'n/a' self._unit_of_measurement = unit_of_measurement - self._corr_factor = corr_factor - self._decimal_places = decimal_places + self._value_template = value_template self.update() - @classmethod - def extract_value(cls, data, variable): - """ Extracts the value using a key name or a path. """ - if isinstance(variable, list): - for variable_item in variable: - data = data[variable_item] - return data - else: - return data[variable] - @property def name(self): """ The name of the sensor. """ @@ -133,18 +106,10 @@ class RestSensor(Entity): if 'error' in value: self._state = value['error'] else: - try: - if value is not None: - value = RestSensor.extract_value(value, self._variable) - if self._corr_factor is not None: - value = float(value) * float(self._corr_factor) - if self._decimal_places is not None: - value = round(value, self._decimal_places) - if self._decimal_places == 0: - value = int(value) - self._state = value - except ValueError: - self._state = RestSensor.extract_value(value, self._variable) + if self._value_template is not None: + value = template.render_with_possible_json_value( + self._hass, self._value_template, value, 'N/A') + self._state = value # pylint: disable=too-few-public-methods @@ -164,7 +129,7 @@ class RestDataGet(object): verify=self._verify_ssl) if 'error' in self.data: del self.data['error'] - self.data = response.json() + self.data = response.text except requests.exceptions.ConnectionError: _LOGGER.error("No route to resource/endpoint.") self.data['error'] = 'N/A' @@ -188,7 +153,7 @@ class RestDataPost(object): timeout=10, verify=self._verify_ssl) if 'error' in self.data: del self.data['error'] - self.data = response.json() + self.data = response.text except requests.exceptions.ConnectionError: _LOGGER.error("No route to resource/endpoint.") self.data['error'] = 'N/A' diff --git a/homeassistant/components/sensor/twitch.py b/homeassistant/components/sensor/twitch.py new file mode 100644 index 00000000000..14a3190e07c --- /dev/null +++ b/homeassistant/components/sensor/twitch.py @@ -0,0 +1,82 @@ +""" +homeassistant.components.sensor.twitch +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Twitch stream status. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.twitch/ +""" + +from homeassistant.helpers.entity import Entity +from homeassistant.const import ATTR_ENTITY_PICTURE + +STATE_STREAMING = 'streaming' +STATE_OFFLINE = 'offline' +ATTR_GAME = 'game' +ATTR_TITLE = 'title' +ICON = 'mdi:twitch' + +REQUIREMENTS = ['python-twitch==1.2.0'] +DOMAIN = 'twitch' + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Sets up the Twitch platform. """ + add_devices( + [TwitchSensor(channel) for channel in config.get('channels', [])]) + + +class TwitchSensor(Entity): + """ Represents an Twitch channel. """ + + # pylint: disable=abstract-method + def __init__(self, channel): + self._channel = channel + self._state = STATE_OFFLINE + self._preview = None + self._game = None + self._title = None + self.update() + + @property + def should_poll(self): + """ Device should be polled. """ + return True + + @property + def name(self): + """ Returns the name of the device. """ + return self._channel + + @property + def state(self): + """ State of the sensor. """ + return self._state + + # pylint: disable=no-member + def update(self): + """ Update device state. """ + from twitch.api import v3 as twitch + stream = twitch.streams.by_channel(self._channel).get('stream') + if stream: + self._game = stream.get('channel').get('game') + self._title = stream.get('channel').get('status') + self._preview = stream.get('preview').get('small') + self._state = STATE_STREAMING + else: + self._state = STATE_OFFLINE + + @property + def state_attributes(self): + """ Returns the state attributes. """ + if self._state == STATE_STREAMING: + return { + ATTR_GAME: self._game, + ATTR_TITLE: self._title, + ATTR_ENTITY_PICTURE: self._preview + } + + @property + def icon(self): + return ICON diff --git a/homeassistant/components/switch/orvibo.py b/homeassistant/components/switch/orvibo.py index c636a7f3f55..a5c21342418 100644 --- a/homeassistant/components/switch/orvibo.py +++ b/homeassistant/components/switch/orvibo.py @@ -11,7 +11,7 @@ import logging from homeassistant.components.switch import SwitchDevice DEFAULT_NAME = "Orvibo S20 Switch" -REQUIREMENTS = ['orvibo==1.0.1'] +REQUIREMENTS = ['orvibo==1.1.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/thermostat/heatmiser.py b/homeassistant/components/thermostat/heatmiser.py index eaeef858172..a7ea7699558 100644 --- a/homeassistant/components/thermostat/heatmiser.py +++ b/homeassistant/components/thermostat/heatmiser.py @@ -10,7 +10,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/thermostat.heatmiser/ """ import logging -import heatmiserV3 from homeassistant.components.thermostat import ThermostatDevice from homeassistant.const import TEMP_CELCIUS @@ -26,6 +25,8 @@ _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices, discovery_info=None): """ Sets up the heatmiser thermostat. """ + from heatmiserV3 import heatmiser, connection + ipaddress = str(config[CONF_IPADDRESS]) port = str(config[CONF_PORT]) @@ -34,7 +35,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): CONF_IPADDRESS, CONF_PORT) return False - serport = heatmiserV3.connection.connection(ipaddress, port) + serport = connection.connection(ipaddress, port) serport.open() tstats = [] @@ -48,6 +49,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): for tstat in tstats: add_devices([ HeatmiserV3Thermostat( + heatmiser, tstat.get("id"), tstat.get("name"), serport) @@ -58,7 +60,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class HeatmiserV3Thermostat(ThermostatDevice): """ Represents a HeatmiserV3 thermostat. """ - def __init__(self, device, name, serport): + # pylint: disable=too-many-instance-attributes + def __init__(self, heatmiser, device, name, serport): + self.heatmiser = heatmiser self.device = device self.serport = serport self._current_temperature = None @@ -98,7 +102,7 @@ class HeatmiserV3Thermostat(ThermostatDevice): def set_temperature(self, temperature): """ Set new target temperature """ temperature = int(temperature) - heatmiserV3.heatmiser.hmSendAddress( + self.heatmiser.hmSendAddress( self._id, 18, temperature, @@ -107,7 +111,7 @@ class HeatmiserV3Thermostat(ThermostatDevice): self._target_temperature = int(temperature) def update(self): - self.dcb = heatmiserV3.heatmiser.hmReadAddress( + self.dcb = self.heatmiser.hmReadAddress( self._id, 'prt', self.serport) diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index bd32d356670..510bc9b4e54 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -14,3 +14,10 @@ class InvalidEntityFormatError(HomeAssistantError): class NoEntitySpecifiedError(HomeAssistantError): """ When no entity is specified. """ pass + + +class TemplateError(HomeAssistantError): + """ Error during template rendering. """ + def __init__(self, exception): + super().__init__('{}: {}'.format(exception.__class__.__name__, + exception)) diff --git a/homeassistant/util/template.py b/homeassistant/util/template.py index 107532db776..d0a07507bdf 100644 --- a/homeassistant/util/template.py +++ b/homeassistant/util/template.py @@ -6,10 +6,18 @@ Template utility methods for rendering strings with HA data. """ # pylint: disable=too-few-public-methods import json +import logging +import jinja2 from jinja2.sandbox import ImmutableSandboxedEnvironment +from homeassistant.const import STATE_UNKNOWN +from homeassistant.exceptions import TemplateError + +_LOGGER = logging.getLogger(__name__) +_SENTINEL = object() -def render_with_possible_json_value(hass, template, value): +def render_with_possible_json_value(hass, template, value, + error_value=_SENTINEL): """ Renders template with value exposed. If valid JSON will expose value_json too. """ variables = { @@ -20,7 +28,11 @@ def render_with_possible_json_value(hass, template, value): except ValueError: pass - return render(hass, template, variables) + try: + return render(hass, template, variables) + except TemplateError: + _LOGGER.exception('Error parsing value') + return value if error_value is _SENTINEL else error_value def render(hass, template, variables=None, **kwargs): @@ -28,9 +40,13 @@ def render(hass, template, variables=None, **kwargs): if variables is not None: kwargs.update(variables) - return ENV.from_string(template, { - 'states': AllStates(hass) - }).render(kwargs) + try: + return ENV.from_string(template, { + 'states': AllStates(hass), + 'is_state': hass.states.is_state + }).render(kwargs).strip() + except jinja2.TemplateError as err: + raise TemplateError(err) class AllStates(object): @@ -45,6 +61,10 @@ class AllStates(object): return iter(sorted(self._hass.states.all(), key=lambda state: state.entity_id)) + def __call__(self, entity_id): + state = self._hass.states.get(entity_id) + return STATE_UNKNOWN if state is None else state.state + class DomainStates(object): """ Class to expose a specific HA domain as attributes. """ @@ -66,8 +86,8 @@ class DomainStates(object): def forgiving_round(value, precision=0): """ Rounding method that accepts strings. """ try: - return int(float(value)) if precision == 0 else round(float(value), - precision) + value = round(float(value), precision) + return int(value) if precision == 0 else value except ValueError: # If value can't be converted to float return value @@ -81,6 +101,13 @@ def multiply(value, amount): # If value can't be converted to float return value -ENV = ImmutableSandboxedEnvironment() + +class TemplateEnvironment(ImmutableSandboxedEnvironment): + """ Home Assistant template environment. """ + + def is_safe_callable(self, obj): + return isinstance(obj, AllStates) or super().is_safe_callable(obj) + +ENV = TemplateEnvironment() ENV.filters['round'] = forgiving_round ENV.filters['multiply'] = multiply diff --git a/requirements.txt b/requirements.txt index 1bcfe79c2d0..14dfca13f23 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,4 @@ requests>=2,<3 pyyaml>=3.11,<4 pytz>=2015.4 pip>=7.0.0 -vincenty==0.1.3 +vincenty==0.1.3 \ No newline at end of file diff --git a/requirements_all.txt b/requirements_all.txt index a25ebb363d8..3776eaea0cf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -63,7 +63,11 @@ https://github.com/pavoni/home-assistant-vera-api/archive/efdba4e63d58a30bc9b36d # homeassistant.components.lock.wink # homeassistant.components.sensor.wink # homeassistant.components.switch.wink +<<<<<<< HEAD https://github.com/bradsk88/python-wink/archive/d3fcce7528bd031a2c05363a108628acc4eb03aa.zip#python-wink==0.3.1 +======= +https://github.com/bradsk88/python-wink/archive/91c8e9a5df24c8dd1a5267dc29a00a40c11d826a.zip#python-wink==0.3 +>>>>>>> 7fb5927ac80273d9b5e087defe72765f2ce3227a # homeassistant.components.media_player.cast pychromecast==0.6.12 diff --git a/script/bootstrap_frontend b/script/bootstrap_frontend index 6fc94f95725..5bc3baf38c9 100755 --- a/script/bootstrap_frontend +++ b/script/bootstrap_frontend @@ -1,5 +1,6 @@ echo "Bootstrapping frontend..." cd homeassistant/components/frontend/www_static/home-assistant-polymer npm install +bower install npm run setup_js_dev cd ../../../../.. diff --git a/setup.py b/setup.py index 2508b44e2fd..5bdc07700d9 100755 --- a/setup.py +++ b/setup.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python3 import os from setuptools import setup, find_packages from homeassistant.const import __version__ diff --git a/tests/components/automation/test_numeric_state.py b/tests/components/automation/test_numeric_state.py index 8280f396f93..49246e48117 100644 --- a/tests/components/automation/test_numeric_state.py +++ b/tests/components/automation/test_numeric_state.py @@ -295,7 +295,7 @@ class TestAutomationNumericState(unittest.TestCase): 'trigger': { 'platform': 'numeric_state', 'entity_id': 'test.entity', - 'attribute': 'test_attribute', + 'value_template': '{{ state.attributes.test_attribute }}', 'below': 10, }, 'action': { @@ -314,7 +314,7 @@ class TestAutomationNumericState(unittest.TestCase): 'trigger': { 'platform': 'numeric_state', 'entity_id': 'test.entity', - 'attribute': 'test_attribute', + 'value_template': '{{ state.attributes.test_attribute }}', 'below': 10, }, 'action': { @@ -333,7 +333,7 @@ class TestAutomationNumericState(unittest.TestCase): 'trigger': { 'platform': 'numeric_state', 'entity_id': 'test.entity', - 'attribute': 'test_attribute', + 'value_template': '{{ state.attributes.test_attribute }}', 'below': 10, }, 'action': { @@ -352,7 +352,7 @@ class TestAutomationNumericState(unittest.TestCase): 'trigger': { 'platform': 'numeric_state', 'entity_id': 'test.entity', - 'attribute': 'test_attribute', + 'value_template': '{{ state.attributes.test_attribute }}', 'below': 10, }, 'action': { @@ -371,7 +371,7 @@ class TestAutomationNumericState(unittest.TestCase): 'trigger': { 'platform': 'numeric_state', 'entity_id': 'test.entity', - 'attribute': 'test_attribute', + 'value_template': '{{ state.attributes.test_attribute }}', 'below': 10, }, 'action': { @@ -384,13 +384,51 @@ class TestAutomationNumericState(unittest.TestCase): self.hass.pool.block_till_done() self.assertEqual(1, len(self.calls)) + def test_template_list(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.entity', + 'value_template': '{{ state.attributes.test_attribute[2] }}', + 'below': 10, + }, + 'action': { + 'service': 'test.automation' + } + } + })) + # 3 is below 10 + self.hass.states.set('test.entity', 'entity', { 'test_attribute': [11, 15, 3] }) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_template_string(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.entity', + 'value_template': '{{ state.attributes.test_attribute | multiply(10) }}', + 'below': 10, + }, + 'action': { + 'service': 'test.automation' + } + } + })) + # 9 is below 10 + self.hass.states.set('test.entity', 'entity', { 'test_attribute': '0.9' }) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + def test_if_not_fires_on_attribute_change_with_attribute_not_below_multiple_attributes(self): self.assertTrue(automation.setup(self.hass, { automation.DOMAIN: { 'trigger': { 'platform': 'numeric_state', 'entity_id': 'test.entity', - 'attribute': 'test_attribute', + 'value_template': '{{ state.attributes.test_attribute }}', 'below': 10, }, 'action': { diff --git a/tests/components/test_alexa.py b/tests/components/test_alexa.py new file mode 100644 index 00000000000..75aec2b087c --- /dev/null +++ b/tests/components/test_alexa.py @@ -0,0 +1,225 @@ +""" +tests.test_component_alexa +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Tests Home Assistant Alexa component does what it should do. +""" +# pylint: disable=protected-access,too-many-public-methods +import unittest +import json +from unittest.mock import patch + +import requests + +from homeassistant import bootstrap, const +import homeassistant.core as ha +from homeassistant.components import alexa, http + +API_PASSWORD = "test1234" + +# Somehow the socket that holds the default port does not get released +# when we close down HA in a different test case. Until I have figured +# out what is going on, let's run this test on a different port. +SERVER_PORT = 8119 + +API_URL = "http://127.0.0.1:{}{}".format(SERVER_PORT, alexa.API_ENDPOINT) + +HA_HEADERS = {const.HTTP_HEADER_HA_AUTH: API_PASSWORD} + +hass = None + + +@patch('homeassistant.components.http.util.get_local_ip', + return_value='127.0.0.1') +def setUpModule(mock_get_local_ip): # pylint: disable=invalid-name + """ Initalizes a Home Assistant server. """ + global hass + + hass = ha.HomeAssistant() + + bootstrap.setup_component( + hass, http.DOMAIN, + {http.DOMAIN: {http.CONF_API_PASSWORD: API_PASSWORD, + http.CONF_SERVER_PORT: SERVER_PORT}}) + + bootstrap.setup_component(hass, alexa.DOMAIN, { + 'alexa': { + 'intents': { + 'WhereAreWeIntent': { + 'speech': { + 'type': 'plaintext', + 'text': + """ + {%- if is_state('device_tracker.paulus', 'home') and is_state('device_tracker.anne_therese', 'home') -%} + You are both home, you silly + {%- else -%} + Anne Therese is at {{ states("device_tracker.anne_therese") }} and Paulus is at {{ states("device_tracker.paulus") }} + {% endif %} + """, + } + }, + 'GetZodiacHoroscopeIntent': { + 'speech': { + 'type': 'plaintext', + 'text': 'You told us your sign is {{ ZodiacSign }}.' + } + } + } + } + }) + + hass.start() + + +def tearDownModule(): # pylint: disable=invalid-name + """ Stops the Home Assistant server. """ + hass.stop() + + +def _req(data={}): + return requests.post(API_URL, data=json.dumps(data), timeout=5, + headers=HA_HEADERS) + + +class TestAlexa(unittest.TestCase): + """ Test Alexa. """ + + def test_launch_request(self): + data = { + 'version': '1.0', + 'session': { + 'new': True, + 'sessionId': 'amzn1.echo-api.session.0000000-0000-0000-0000-00000000000', + 'application': { + 'applicationId': 'amzn1.echo-sdk-ams.app.000000-d0ed-0000-ad00-000000d00ebe' + }, + 'attributes': {}, + 'user': { + 'userId': 'amzn1.account.AM3B00000000000000000000000' + } + }, + 'request': { + 'type': 'LaunchRequest', + 'requestId': 'amzn1.echo-api.request.0000000-0000-0000-0000-00000000000', + 'timestamp': '2015-05-13T12:34:56Z' + } + } + req = _req(data) + self.assertEqual(200, req.status_code) + resp = req.json() + self.assertIn('outputSpeech', resp['response']) + + def test_intent_request_with_slots(self): + data = { + 'version': '1.0', + 'session': { + 'new': False, + 'sessionId': 'amzn1.echo-api.session.0000000-0000-0000-0000-00000000000', + 'application': { + 'applicationId': 'amzn1.echo-sdk-ams.app.000000-d0ed-0000-ad00-000000d00ebe' + }, + 'attributes': { + 'supportedHoroscopePeriods': { + 'daily': True, + 'weekly': False, + 'monthly': False + } + }, + 'user': { + 'userId': 'amzn1.account.AM3B00000000000000000000000' + } + }, + 'request': { + 'type': 'IntentRequest', + 'requestId': ' amzn1.echo-api.request.0000000-0000-0000-0000-00000000000', + 'timestamp': '2015-05-13T12:34:56Z', + 'intent': { + 'name': 'GetZodiacHoroscopeIntent', + 'slots': { + 'ZodiacSign': { + 'name': 'ZodiacSign', + 'value': 'virgo' + } + } + } + } + } + req = _req(data) + self.assertEqual(200, req.status_code) + text = req.json().get('response', {}).get('outputSpeech', {}).get('text') + self.assertEqual('You told us your sign is virgo.', text) + + def test_intent_request_without_slots(self): + data = { + 'version': '1.0', + 'session': { + 'new': False, + 'sessionId': 'amzn1.echo-api.session.0000000-0000-0000-0000-00000000000', + 'application': { + 'applicationId': 'amzn1.echo-sdk-ams.app.000000-d0ed-0000-ad00-000000d00ebe' + }, + 'attributes': { + 'supportedHoroscopePeriods': { + 'daily': True, + 'weekly': False, + 'monthly': False + } + }, + 'user': { + 'userId': 'amzn1.account.AM3B00000000000000000000000' + } + }, + 'request': { + 'type': 'IntentRequest', + 'requestId': ' amzn1.echo-api.request.0000000-0000-0000-0000-00000000000', + 'timestamp': '2015-05-13T12:34:56Z', + 'intent': { + 'name': 'WhereAreWeIntent', + } + } + } + req = _req(data) + self.assertEqual(200, req.status_code) + text = req.json().get('response', {}).get('outputSpeech', {}).get('text') + + self.assertEqual('Anne Therese is at unknown and Paulus is at unknown', text) + + hass.states.set('device_tracker.paulus', 'home') + hass.states.set('device_tracker.anne_therese', 'home') + + req = _req(data) + self.assertEqual(200, req.status_code) + text = req.json().get('response', {}).get('outputSpeech', {}).get('text') + self.assertEqual('You are both home, you silly', text) + + def test_session_ended_request(self): + data = { + 'version': '1.0', + 'session': { + 'new': False, + 'sessionId': 'amzn1.echo-api.session.0000000-0000-0000-0000-00000000000', + 'application': { + 'applicationId': 'amzn1.echo-sdk-ams.app.000000-d0ed-0000-ad00-000000d00ebe' + }, + 'attributes': { + 'supportedHoroscopePeriods': { + 'daily': True, + 'weekly': False, + 'monthly': False + } + }, + 'user': { + 'userId': 'amzn1.account.AM3B00000000000000000000000' + } + }, + 'request': { + 'type': 'SessionEndedRequest', + 'requestId': 'amzn1.echo-api.request.0000000-0000-0000-0000-00000000000', + 'timestamp': '2015-05-13T12:34:56Z', + 'reason': 'USER_INITIATED' + } + } + + req = _req(data) + self.assertEqual(200, req.status_code) + self.assertEqual('', req.text) diff --git a/tests/components/test_api.py b/tests/components/test_api.py index cf530c1f301..644982e18fc 100644 --- a/tests/components/test_api.py +++ b/tests/components/test_api.py @@ -5,10 +5,11 @@ tests.test_component_http Tests Home Assistant HTTP component does what it should do. """ # pylint: disable=protected-access,too-many-public-methods -import unittest +from contextlib import closing import json -from unittest.mock import patch import tempfile +import unittest +from unittest.mock import patch import requests @@ -415,3 +416,61 @@ class TestAPI(unittest.TestCase): }), headers=HA_HEADERS) self.assertEqual(200, req.status_code) + + def test_stream(self): + listen_count = self._listen_count() + with closing(requests.get(_url(const.URL_API_STREAM), + stream=True, headers=HA_HEADERS)) as req: + + data = self._stream_next_event(req) + self.assertEqual('ping', data) + + self.assertEqual(listen_count + 1, self._listen_count()) + + hass.bus.fire('test_event') + hass.pool.block_till_done() + + data = self._stream_next_event(req) + + self.assertEqual('test_event', data['event_type']) + + def test_stream_with_restricted(self): + listen_count = self._listen_count() + with closing(requests.get(_url(const.URL_API_STREAM), + data=json.dumps({ + 'restrict': 'test_event1,test_event3'}), + stream=True, headers=HA_HEADERS)) as req: + + data = self._stream_next_event(req) + self.assertEqual('ping', data) + + self.assertEqual(listen_count + 2, self._listen_count()) + + hass.bus.fire('test_event1') + hass.pool.block_till_done() + hass.bus.fire('test_event2') + hass.pool.block_till_done() + hass.bus.fire('test_event3') + hass.pool.block_till_done() + + data = self._stream_next_event(req) + self.assertEqual('test_event1', data['event_type']) + data = self._stream_next_event(req) + self.assertEqual('test_event3', data['event_type']) + + def _stream_next_event(self, stream): + data = b'' + last_new_line = False + for dat in stream.iter_content(1): + if dat == b'\n' and last_new_line: + break + data += dat + last_new_line = dat == b'\n' + + conv = data.decode('utf-8').strip()[6:] + + return conv if conv == 'ping' else json.loads(conv) + + def _listen_count(self): + """ Return number of event listeners. """ + return sum(hass.bus.listeners.values()) diff --git a/tests/util/test_template.py b/tests/util/test_template.py index 5c1dfff1f85..1ecd7d5b894 100644 --- a/tests/util/test_template.py +++ b/tests/util/test_template.py @@ -7,7 +7,7 @@ Tests Home Assistant util methods. # pylint: disable=too-many-public-methods import unittest import homeassistant.core as ha - +from homeassistant.exceptions import TemplateError from homeassistant.util import template @@ -57,10 +57,10 @@ class TestUtilTemplate(unittest.TestCase): '{{ states.sensor.temperature.state | round(1) }}')) def test_rounding_value2(self): - self.hass.states.set('sensor.temperature', 12.72) + self.hass.states.set('sensor.temperature', 12.78) self.assertEqual( - '127', + '128', template.render( self.hass, '{{ states.sensor.temperature.state | multiply(10) | round }}')) @@ -84,3 +84,44 @@ class TestUtilTemplate(unittest.TestCase): '', template.render_with_possible_json_value( self.hass, '{{ value_json }}', '{ I AM NOT JSON }')) + + def test_render_with_possible_json_value_with_template_error(self): + self.assertEqual( + 'hello', + template.render_with_possible_json_value( + self.hass, '{{ value_json', 'hello')) + + def test_render_with_possible_json_value_with_template_error_error_value(self): + self.assertEqual( + '-', + template.render_with_possible_json_value( + self.hass, '{{ value_json', 'hello', '-')) + + def test_raise_exception_on_error(self): + with self.assertRaises(TemplateError): + template.render(self.hass, '{{ invalid_syntax') + + def test_if_state_exists(self): + self.hass.states.set('test.object', 'available') + self.assertEqual( + 'exists', + template.render( + self.hass, + '{% if states.test.object %}exists{% else %}not exists{% endif %}')) + + def test_is_state(self): + self.hass.states.set('test.object', 'available') + self.assertEqual( + 'yes', + template.render( + self.hass, + '{% if is_state("test.object", "available") %}yes{% else %}no{% endif %}')) + + def test_states_function(self): + self.hass.states.set('test.object', 'available') + self.assertEqual( + 'available', + template.render(self.hass, '{{ states("test.object") }}')) + self.assertEqual( + 'unknown', + template.render(self.hass, '{{ states("test.object2") }}'))