diff --git a/.gitignore b/.gitignore index fc7e301a093..a82763e1b6d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ config/* !config/home-assistant.conf.default -homeassistant/components/http/www_static/polymer/bower_components/* +homeassistant/components/frontend/www_static/polymer/bower_components/* # There is not a better solution afaik.. !config/custom_components diff --git a/.gitmodules b/.gitmodules index b9cf022a8f4..6e49e76698a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -10,3 +10,6 @@ [submodule "homeassistant/external/noop"] path = homeassistant/external/noop url = https://github.com/balloob/noop.git +[submodule "homeassistant/components/frontend/www_static/polymer/home-assistant-js"] + path = homeassistant/components/frontend/www_static/polymer/home-assistant-js + url = https://github.com/balloob/home-assistant-js.git diff --git a/config/home-assistant.conf.example b/config/home-assistant.conf.example index 2ad1fcf8570..7501126af9f 100644 --- a/config/home-assistant.conf.example +++ b/config/home-assistant.conf.example @@ -101,4 +101,4 @@ time_minutes=0 time_seconds=0 execute_service=notify.notify -execute_service_data={"message":"It's 4, time for beer!"} +service_data={"message":"It's 4, time for beer!"} diff --git a/homeassistant/__init__.py b/homeassistant/__init__.py index 96d24cb62cf..2a72a8ce797 100644 --- a/homeassistant/__init__.py +++ b/homeassistant/__init__.py @@ -15,8 +15,6 @@ import re import datetime as dt import functools as ft -from requests.structures import CaseInsensitiveDict - from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, SERVICE_HOMEASSISTANT_STOP, EVENT_TIME_CHANGED, EVENT_STATE_CHANGED, @@ -314,6 +312,14 @@ class Event(object): self.data = data or {} self.origin = origin + def as_dict(self): + """ Returns a dict representation of this Event. """ + return { + 'event_type': self.event_type, + 'data': dict(self.data), + 'origin': str(self.origin) + } + def __repr__(self): # pylint: disable=maybe-no-member if self.data: @@ -355,7 +361,8 @@ class EventBus(object): event = Event(event_type, event_data, origin) - _LOGGER.info("Bus:Handling %s", event) + if event_type != EVENT_TIME_CHANGED: + _LOGGER.info("Bus:Handling %s", event) if not listeners: return @@ -438,7 +445,7 @@ class State(object): "Invalid entity id encountered: {}. " "Format should be .").format(entity_id)) - self.entity_id = entity_id + self.entity_id = entity_id.lower() self.state = state self.attributes = attributes or {} self.last_updated = dt.datetime.now() @@ -501,7 +508,7 @@ class StateMachine(object): """ Helper class that tracks the state of different entities. """ def __init__(self, bus): - self._states = CaseInsensitiveDict() + self._states = {} self._bus = bus self._lock = threading.Lock() @@ -511,7 +518,7 @@ class StateMachine(object): domain_filter = domain_filter.lower() return [state.entity_id for key, state - in self._states.lower_items() + in self._states.items() if util.split_entity_id(key)[0] == domain_filter] else: return list(self._states.keys()) @@ -522,7 +529,7 @@ class StateMachine(object): def get(self, entity_id): """ Returns the state of the specified entity. """ - state = self._states.get(entity_id) + state = self._states.get(entity_id.lower()) # Make a copy so people won't mutate the state return state.copy() if state else None @@ -539,6 +546,8 @@ class StateMachine(object): def is_state(self, entity_id, state): """ Returns True if entity exists and is specified state. """ + entity_id = entity_id.lower() + return (entity_id in self._states and self._states[entity_id].state == state) @@ -546,6 +555,8 @@ class StateMachine(object): """ Removes an entity from the state machine. Returns boolean to indicate if an entity was removed. """ + entity_id = entity_id.lower() + with self._lock: return self._states.pop(entity_id, None) is not None @@ -557,7 +568,7 @@ class StateMachine(object): If you just update the attributes and not the state, last changed will not be affected. """ - + entity_id = entity_id.lower() new_state = str(new_state) attributes = attributes or {} @@ -572,8 +583,8 @@ class StateMachine(object): if not (same_state and same_attr): last_changed = old_state.last_changed if same_state else None - state = self._states[entity_id] = \ - State(entity_id, new_state, attributes, last_changed) + state = State(entity_id, new_state, attributes, last_changed) + self._states[entity_id] = state event_data = {'entity_id': entity_id, 'new_state': state} @@ -603,7 +614,7 @@ class StateMachine(object): @ft.wraps(action) def state_listener(event): """ The listener that listens for specific state changes. """ - if event.data['entity_id'].lower() in entity_ids and \ + if event.data['entity_id'] in entity_ids and \ 'old_state' in event.data and \ _matcher(event.data['old_state'].state, from_state) and \ _matcher(event.data['new_state'].state, to_state): diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index 2b48882712c..cd4a98e5967 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -78,8 +78,9 @@ def ensure_config_path(config_dir): if not os.path.isfile(config_path): try: with open(config_path, 'w') as conf: - conf.write("[http]\n\n") + conf.write("[frontend]\n\n") conf.write("[discovery]\n\n") + conf.write("[recorder]\n\n") except IOError: print(('Fatal Error: No configuration file found and unable ' 'to write a default one to {}').format(config_path)) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 61f856b6e3e..6b35c268652 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -24,6 +24,12 @@ _LOGGER = logging.getLogger(__name__) def setup_component(hass, domain, config=None): """ Setup a component for Home Assistant. """ + # Check if already loaded + if domain in hass.components: + return + + _ensure_loader_prepared(hass) + if config is None: config = defaultdict(dict) @@ -63,7 +69,7 @@ def from_config_dict(config, hass=None): enable_logging(hass) - loader.prepare(hass) + _ensure_loader_prepared(hass) # Make a copy because we are mutating it. # Convert it to defaultdict so components can always have config dict @@ -140,3 +146,9 @@ def enable_logging(hass): else: _LOGGER.error( "Unable to setup error log %s (access denied)", err_log_path) + + +def _ensure_loader_prepared(hass): + """ Ensure Home Assistant loader is prepared. """ + if not loader.PREPARED: + loader.prepare(hass) diff --git a/homeassistant/components/api.py b/homeassistant/components/api.py new file mode 100644 index 00000000000..2bbb9516517 --- /dev/null +++ b/homeassistant/components/api.py @@ -0,0 +1,259 @@ +""" +homeassistant.components.api +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Provides a Rest API for Home Assistant. +""" +import re +import logging + +import homeassistant as ha +from homeassistant.helpers import TrackStates +import homeassistant.remote as rem +from homeassistant.const import ( + URL_API, URL_API_STATES, URL_API_EVENTS, URL_API_SERVICES, + URL_API_EVENT_FORWARD, URL_API_STATES_ENTITY, URL_API_COMPONENTS) + +HTTP_OK = 200 +HTTP_CREATED = 201 +HTTP_MOVED_PERMANENTLY = 301 +HTTP_BAD_REQUEST = 400 +HTTP_UNAUTHORIZED = 401 +HTTP_NOT_FOUND = 404 +HTTP_METHOD_NOT_ALLOWED = 405 +HTTP_UNPROCESSABLE_ENTITY = 422 + + +DOMAIN = 'api' +DEPENDENCIES = ['http'] + +_LOGGER = logging.getLogger(__name__) + + +def setup(hass, config): + """ Register the API with the HTTP interface. """ + + if 'http' not in hass.components: + _LOGGER.error('Dependency http is not loaded') + return False + + # /api - for validation purposes + hass.http.register_path('GET', URL_API, _handle_get_api) + + # /states + hass.http.register_path('GET', URL_API_STATES, _handle_get_api_states) + hass.http.register_path( + 'GET', re.compile(r'/api/states/(?P[a-zA-Z\._0-9]+)'), + _handle_get_api_states_entity) + hass.http.register_path( + 'POST', re.compile(r'/api/states/(?P[a-zA-Z\._0-9]+)'), + _handle_post_state_entity) + hass.http.register_path( + 'PUT', re.compile(r'/api/states/(?P[a-zA-Z\._0-9]+)'), + _handle_post_state_entity) + + # /events + hass.http.register_path('GET', URL_API_EVENTS, _handle_get_api_events) + hass.http.register_path( + 'POST', re.compile(r'/api/events/(?P[a-zA-Z\._0-9]+)'), + _handle_api_post_events_event) + + # /services + hass.http.register_path('GET', URL_API_SERVICES, _handle_get_api_services) + hass.http.register_path( + 'POST', + re.compile((r'/api/services/' + r'(?P[a-zA-Z\._0-9]+)/' + r'(?P[a-zA-Z\._0-9]+)')), + _handle_post_api_services_domain_service) + + # /event_forwarding + hass.http.register_path( + 'POST', URL_API_EVENT_FORWARD, _handle_post_api_event_forward) + hass.http.register_path( + 'DELETE', URL_API_EVENT_FORWARD, _handle_delete_api_event_forward) + + # /components + hass.http.register_path( + 'GET', URL_API_COMPONENTS, _handle_get_api_components) + + return True + + +def _handle_get_api(handler, path_match, data): + """ Renders the debug interface. """ + handler.write_json_message("API running.") + + +def _handle_get_api_states(handler, path_match, data): + """ Returns a dict containing all entity ids and their state. """ + handler.write_json(handler.server.hass.states.all()) + + +def _handle_get_api_states_entity(handler, path_match, data): + """ Returns the state of a specific entity. """ + entity_id = path_match.group('entity_id') + + state = handler.server.hass.states.get(entity_id) + + if state: + handler.write_json(state) + else: + handler.write_json_message("State does not exist.", HTTP_NOT_FOUND) + + +def _handle_post_state_entity(handler, path_match, data): + """ Handles updating the state of an entity. + + This handles the following paths: + /api/states/ + """ + entity_id = path_match.group('entity_id') + + try: + new_state = data['state'] + except KeyError: + handler.write_json_message("state not specified", HTTP_BAD_REQUEST) + return + + attributes = data['attributes'] if 'attributes' in data else None + + is_new_state = handler.server.hass.states.get(entity_id) is None + + # Write state + handler.server.hass.states.set(entity_id, new_state, attributes) + + state = handler.server.hass.states.get(entity_id) + + status_code = HTTP_CREATED if is_new_state else HTTP_OK + + handler.write_json( + state.as_dict(), + status_code=status_code, + location=URL_API_STATES_ENTITY.format(entity_id)) + + +def _handle_get_api_events(handler, path_match, data): + """ Handles getting overview of event listeners. """ + handler.write_json([{"event": key, "listener_count": value} + for key, value + in handler.server.hass.bus.listeners.items()]) + + +def _handle_api_post_events_event(handler, path_match, event_data): + """ Handles firing of an event. + + This handles the following paths: + /api/events/ + + Events from /api are threated as remote events. + """ + event_type = path_match.group('event_type') + + if event_data is not None and not isinstance(event_data, dict): + handler.write_json_message( + "event_data should be an object", HTTP_UNPROCESSABLE_ENTITY) + + event_origin = ha.EventOrigin.remote + + # Special case handling for event STATE_CHANGED + # We will try to convert state dicts back to State objects + if event_type == ha.EVENT_STATE_CHANGED and event_data: + for key in ('old_state', 'new_state'): + state = ha.State.from_dict(event_data.get(key)) + + if state: + event_data[key] = state + + handler.server.hass.bus.fire(event_type, event_data, event_origin) + + handler.write_json_message("Event {} fired.".format(event_type)) + + +def _handle_get_api_services(handler, path_match, data): + """ Handles getting overview of services. """ + handler.write_json( + [{"domain": key, "services": value} + for key, value + in handler.server.hass.services.services.items()]) + + +# pylint: disable=invalid-name +def _handle_post_api_services_domain_service(handler, path_match, data): + """ Handles calling a service. + + This handles the following paths: + /api/services// + """ + domain = path_match.group('domain') + service = path_match.group('service') + + with TrackStates(handler.server.hass) as changed_states: + handler.server.hass.services.call(domain, service, data, True) + + handler.write_json(changed_states) + + +# pylint: disable=invalid-name +def _handle_post_api_event_forward(handler, path_match, data): + """ Handles adding an event forwarding target. """ + + try: + host = data['host'] + api_password = data['api_password'] + except KeyError: + handler.write_json_message( + "No host or api_password received.", HTTP_BAD_REQUEST) + return + + try: + port = int(data['port']) if 'port' in data else None + except ValueError: + handler.write_json_message( + "Invalid value received for port", HTTP_UNPROCESSABLE_ENTITY) + return + + api = rem.API(host, api_password, port) + + if not api.validate_api(): + handler.write_json_message( + "Unable to validate API", HTTP_UNPROCESSABLE_ENTITY) + return + + if handler.server.event_forwarder is None: + handler.server.event_forwarder = \ + rem.EventForwarder(handler.server.hass) + + handler.server.event_forwarder.connect(api) + + handler.write_json_message("Event forwarding setup.") + + +def _handle_delete_api_event_forward(handler, path_match, data): + """ Handles deleting an event forwarding target. """ + + try: + host = data['host'] + except KeyError: + handler.write_json_message("No host received.", HTTP_BAD_REQUEST) + return + + try: + port = int(data['port']) if 'port' in data else None + except ValueError: + handler.write_json_message( + "Invalid value received for port", HTTP_UNPROCESSABLE_ENTITY) + return + + if handler.server.event_forwarder is not None: + api = rem.API(host, None, port) + + handler.server.event_forwarder.disconnect(api) + + handler.write_json_message("Event forwarding cancelled.") + + +def _handle_get_api_components(handler, path_match, data): + """ Returns all the loaded components. """ + + handler.write_json(handler.server.hass.components) diff --git a/homeassistant/components/demo.py b/homeassistant/components/demo.py index 0dcc1a41bf7..d5846f450e2 100644 --- a/homeassistant/components/demo.py +++ b/homeassistant/components/demo.py @@ -123,7 +123,7 @@ def setup(hass, config): {'entity_id': switches[1:]})) # Setup room groups - group.setup_group(hass, 'living_room', lights[0:3] + switches[0:1]) + group.setup_group(hass, 'living room', lights[0:3] + switches[0:1]) group.setup_group(hass, 'bedroom', [lights[3]] + switches[1:]) # Setup process diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py new file mode 100644 index 00000000000..b43faf76952 --- /dev/null +++ b/homeassistant/components/frontend/__init__.py @@ -0,0 +1,102 @@ +""" +homeassistant.components.frontend +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Provides a frontend for Home Assistant. +""" +import re +import os +import logging + +from . import version +import homeassistant.util as util + +DOMAIN = 'frontend' +DEPENDENCIES = ['api'] + +HTTP_OK = 200 +HTTP_CREATED = 201 +HTTP_MOVED_PERMANENTLY = 301 +HTTP_BAD_REQUEST = 400 +HTTP_UNAUTHORIZED = 401 +HTTP_NOT_FOUND = 404 +HTTP_METHOD_NOT_ALLOWED = 405 +HTTP_UNPROCESSABLE_ENTITY = 422 + + +URL_ROOT = "/" + +_LOGGER = logging.getLogger(__name__) + + +def setup(hass, config): + """ Setup serving the frontend. """ + if 'http' not in hass.components: + _LOGGER.error('Dependency http is not loaded') + return False + + hass.http.register_path('GET', URL_ROOT, _handle_get_root, False) + + # Static files + hass.http.register_path( + 'GET', re.compile(r'/static/(?P[a-zA-Z\._\-0-9/]+)'), + _handle_get_static, False) + hass.http.register_path( + 'HEAD', re.compile(r'/static/(?P[a-zA-Z\._\-0-9/]+)'), + _handle_get_static, False) + + return True + + +def _handle_get_root(handler, path_match, data): + """ Renders the debug interface. """ + + def write(txt): + """ Helper to write text to the output. """ + handler.wfile.write((txt + "\n").encode("UTF-8")) + + handler.send_response(HTTP_OK) + handler.send_header('Content-type', 'text/html; charset=utf-8') + handler.end_headers() + + if handler.server.development: + app_url = "polymer/home-assistant.html" + else: + app_url = "frontend-{}.html".format(version.VERSION) + + # auto login if no password was set, else check api_password param + auth = (handler.server.api_password if handler.server.no_password_set + else data.get('api_password', '')) + + write(("" + "" + "Home Assistant" + "" + "" + "" + "" + "" + "" + "" + "

Initializing Home Assistant

" + "" + "" + "" + "").format(app_url, auth)) + + +def _handle_get_static(handler, path_match, data): + """ Returns a static file for the frontend. """ + req_file = util.sanitize_path(path_match.group('file')) + + # Strip md5 hash out of frontend filename + if re.match(r'^frontend-[A-Za-z0-9]{32}\.html$', req_file): + req_file = "frontend.html" + + path = os.path.join(os.path.dirname(__file__), 'www_static', req_file) + + handler.write_file(path) diff --git a/homeassistant/components/http/frontend.py b/homeassistant/components/frontend/version.py similarity index 58% rename from homeassistant/components/http/frontend.py rename to homeassistant/components/frontend/version.py index cd61782ef8a..37ae80e9bc9 100644 --- a/homeassistant/components/http/frontend.py +++ b/homeassistant/components/frontend/version.py @@ -1,2 +1,2 @@ """ DO NOT MODIFY. Auto-generated by build_frontend script """ -VERSION = "43699d5ec727d3444985a1028d21e0d9" +VERSION = "db6b9c263c4be99af5b25b8c1cb20e57" diff --git a/homeassistant/components/http/www_static/favicon-192x192.png b/homeassistant/components/frontend/www_static/favicon-192x192.png similarity index 100% rename from homeassistant/components/http/www_static/favicon-192x192.png rename to homeassistant/components/frontend/www_static/favicon-192x192.png diff --git a/homeassistant/components/http/www_static/favicon.ico b/homeassistant/components/frontend/www_static/favicon.ico similarity index 100% rename from homeassistant/components/http/www_static/favicon.ico rename to homeassistant/components/frontend/www_static/favicon.ico diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html new file mode 100644 index 00000000000..7d090f8d650 --- /dev/null +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -0,0 +1,210 @@ + + + + + + + + + + diff --git a/homeassistant/components/http/www_static/images/config_philips_hue.jpg b/homeassistant/components/frontend/www_static/images/config_philips_hue.jpg similarity index 100% rename from homeassistant/components/http/www_static/images/config_philips_hue.jpg rename to homeassistant/components/frontend/www_static/images/config_philips_hue.jpg diff --git a/homeassistant/components/frontend/www_static/polymer/bower.json b/homeassistant/components/frontend/www_static/polymer/bower.json new file mode 100644 index 00000000000..27e1f50d022 --- /dev/null +++ b/homeassistant/components/frontend/www_static/polymer/bower.json @@ -0,0 +1,42 @@ +{ + "name": "Home Assistant", + "version": "0.1.0", + "authors": [ + "Paulus Schoutsen " + ], + "main": "splash-login.html", + "license": "MIT", + "private": true, + "ignore": [ + "bower_components" + ], + "dependencies": { + "webcomponentsjs": "Polymer/webcomponentsjs#~0.5.4", + "font-roboto": "Polymer/font-roboto#~0.5.4", + "core-header-panel": "polymer/core-header-panel#~0.5.4", + "core-toolbar": "polymer/core-toolbar#~0.5.4", + "core-tooltip": "Polymer/core-tooltip#~0.5.4", + "core-menu": "polymer/core-menu#~0.5.4", + "core-item": "Polymer/core-item#~0.5.4", + "core-input": "Polymer/core-input#~0.5.4", + "core-icons": "polymer/core-icons#~0.5.4", + "core-image": "polymer/core-image#~0.5.4", + "core-style": "polymer/core-style#~0.5.4", + "paper-toast": "Polymer/paper-toast#~0.5.4", + "paper-dialog": "Polymer/paper-dialog#~0.5.4", + "paper-spinner": "Polymer/paper-spinner#~0.5.4", + "paper-button": "Polymer/paper-button#~0.5.4", + "paper-input": "Polymer/paper-input#~0.5.4", + "paper-toggle-button": "polymer/paper-toggle-button#~0.5.4", + "paper-icon-button": "polymer/paper-icon-button#~0.5.4", + "paper-menu-button": "polymer/paper-menu-button#~0.5.4", + "paper-dropdown": "polymer/paper-dropdown#~0.5.4", + "paper-item": "polymer/paper-item#~0.5.4", + "paper-slider": "polymer/paper-slider#~0.5.4", + "color-picker-element": "~0.0.2", + "google-apis": "GoogleWebComponents/google-apis#~0.4.2", + "core-drawer-panel": "polymer/core-drawer-panel#~0.5.4", + "core-scroll-header-panel": "polymer/core-scroll-header-panel#~0.5.4", + "moment": "~2.9.0" + } +} diff --git a/homeassistant/components/http/www_static/polymer/cards/state-card-configurator.html b/homeassistant/components/frontend/www_static/polymer/cards/state-card-configurator.html similarity index 90% rename from homeassistant/components/http/www_static/polymer/cards/state-card-configurator.html rename to homeassistant/components/frontend/www_static/polymer/cards/state-card-configurator.html index 19d0f6340c1..78b808dfb09 100644 --- a/homeassistant/components/http/www_static/polymer/cards/state-card-configurator.html +++ b/homeassistant/components/frontend/www_static/polymer/cards/state-card-configurator.html @@ -1,9 +1,8 @@ - - + + + diff --git a/homeassistant/components/http/www_static/polymer/cards/state-card-display.html b/homeassistant/components/frontend/www_static/polymer/cards/state-card-display.html similarity index 87% rename from homeassistant/components/http/www_static/polymer/cards/state-card-display.html rename to homeassistant/components/frontend/www_static/polymer/cards/state-card-display.html index 96241b0516b..cbafabac21a 100755 --- a/homeassistant/components/http/www_static/polymer/cards/state-card-display.html +++ b/homeassistant/components/frontend/www_static/polymer/cards/state-card-display.html @@ -1,4 +1,3 @@ - diff --git a/homeassistant/components/http/www_static/polymer/cards/state-card-thermostat.html b/homeassistant/components/frontend/www_static/polymer/cards/state-card-thermostat.html similarity index 93% rename from homeassistant/components/http/www_static/polymer/cards/state-card-thermostat.html rename to homeassistant/components/frontend/www_static/polymer/cards/state-card-thermostat.html index 7642e1216e5..63c6708ce67 100644 --- a/homeassistant/components/http/www_static/polymer/cards/state-card-thermostat.html +++ b/homeassistant/components/frontend/www_static/polymer/cards/state-card-thermostat.html @@ -1,4 +1,3 @@ - diff --git a/homeassistant/components/http/www_static/polymer/cards/state-card-toggle.html b/homeassistant/components/frontend/www_static/polymer/cards/state-card-toggle.html similarity index 69% rename from homeassistant/components/http/www_static/polymer/cards/state-card-toggle.html rename to homeassistant/components/frontend/www_static/polymer/cards/state-card-toggle.html index 78d44c3de7d..08e22a0ec81 100755 --- a/homeassistant/components/http/www_static/polymer/cards/state-card-toggle.html +++ b/homeassistant/components/frontend/www_static/polymer/cards/state-card-toggle.html @@ -1,4 +1,3 @@ - @@ -7,20 +6,17 @@