diff --git a/README.md b/README.md index 5ae5de1cf84..f52bc381f22 100644 --- a/README.md +++ b/README.md @@ -40,138 +40,11 @@ Installation instructions Done. Start it now by running `python start.py` -Web interface and API ---------------------- -Home Assistent runs a webserver accessible on port 8123. - - * At http://localhost:8123/ it will provide a debug interface showing the current state of the system and an overview of registered services. - * At http://localhost:8123/api/ it provides a password protected API. - -A screenshot of the debug interface: -![screenshot-debug-interface](https://raw.github.com/balloob/home-assistant/master/docs/screenshot-debug-interface.png) - -All API calls have to be accompanied by an 'api_password' parameter (as specified in `home-assistant.conf`) and will -return JSON encoded objects. If successful calls will return status code 200 or 201. - -Other status codes that can occur are: - - 400 (Bad Request) - - 401 (Unauthorized) - - 404 (Not Found) - - 405 (Method not allowed) - -The api supports the following actions: - -**/api/states - GET**
-Returns a list of entity ids for which a state is available - -```json -{ - "entity_ids": [ - "Paulus_Nexus_4", - "weather.sun", - "all_devices" - ] -} -``` - -**/api/events - GET**
-Returns a dict with as keys the events and as value the number of listeners. - -```json -{ - "event_listeners": { - "state_changed": 5, - "time_changed": 2 - } -} -``` - -**/api/services - GET**
-Returns a dict with as keys the domain and as value a list of published services. - -```json -{ - "services": { - "browser": [ - "browse_url" - ], - "keyboard": [ - "volume_up", - "volume_down" - ] - } -} -``` - -**/api/states/<entity_id>** - GET
-Returns the current state from an entity - -```json -{ - "attributes": { - "next_rising": "07:04:15 29-10-2013", - "next_setting": "18:00:31 29-10-2013" - }, - "entity_id": "weather.sun", - "last_changed": "23:24:33 28-10-2013", - "state": "below_horizon" -} -``` - -**/api/states/<entity_id>** - POST
-Updates the current state of an entity. Returns status code 201 if successful with location header of updated resource and the new state in the body.
-parameter: new_state - string
-optional parameter: attributes - JSON encoded object - -```json -{ - "attributes": { - "next_rising": "07:04:15 29-10-2013", - "next_setting": "18:00:31 29-10-2013" - }, - "entity_id": "weather.sun", - "last_changed": "23:24:33 28-10-2013", - "state": "below_horizon" -} -``` - -**/api/events/<event_type>** - POST
-Fires an event with event_type
-optional parameter: event_data - JSON encoded object - -```json -{ - "message": "Event download_file fired." -} -``` - -**/api/services/<domain>/<service>** - POST
-Calls a service within a specific domain.
-optional parameter: service_data - JSON encoded object - -```json -{ - "message": "Service keyboard/volume_up called." -} -``` - -Android remote control ----------------------- - -An app has been built using [Tasker for Android](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm) that: - - * Provides buttons to control the lights and the chromecast - * Reports the charging state and battery level of the phone - -The [APK](https://raw.github.com/balloob/home-assistant/master/android-tasker/Home_Assistant.apk) and [Tasker project XML](https://raw.github.com/balloob/home-assistant/master/android-tasker/Home_Assistant.prj.xml) can be found in [/android-tasker/](https://github.com/balloob/home-assistant/tree/master/android-tasker) - -![screenshot-android-tasker.jpg](https://raw.github.com/balloob/home-assistant/master/docs/screenshot-android-tasker.png) - Architecture ------------ -The core of Home Assistant exists of two parts; a Bus for calling services and firing events and a State Machine that keeps track of the state of things. +The core of Home Assistant exists of three parts; an EventBus for firing events, a StateMachine that keeps track of the state of things and a ServiceRegistry to manage services. -![screenshot-android-tasker.jpg](https://raw.github.com/balloob/home-assistant/master/docs/architecture.png) +![home assistant architecture](https://raw.github.com/balloob/home-assistant/master/docs/architecture.png) For example to control the lights there are two components. One is the device_tracker that polls the wireless router for connected devices and updates the state of the tracked devices in the State Machine to be either 'Home' or 'Not Home'. @@ -238,3 +111,175 @@ Registers service `downloader/download_file` that will download files. File to d **browser** Registers service `browser/browse_url` that opens `url` as specified in event_data in the system default browser. + +### Multiple connected instances + +Home Assistant supports running multiple synchronzied instances using a master-slave model. Slaves forward all local events fired and states set to the master instance which will then replicate it to each slave. + +Because each slave maintains it's own ServiceRegistry it is possible to have multiple slaves respond to one service call. + +![home assistant master-slave architecture](https://raw.github.com/balloob/home-assistant/master/docs/architecture-remote.png) + +Web interface and API +--------------------- +Home Assistent runs a webserver accessible on port 8123. + + * At http://localhost:8123/ it will provide a debug interface showing the current state of the system and an overview of registered services. + * At http://localhost:8123/api/ it provides a password protected API. + +A screenshot of the debug interface: +![screenshot-debug-interface](https://raw.github.com/balloob/home-assistant/master/docs/screenshot-debug-interface.png) + +In the package `homeassistant.remote` a Python API on top of the HTTP API can be found. + +All API calls have to be accompanied by an 'api_password' parameter (as specified in `home-assistant.conf`) and will +return JSON encoded objects. If successful calls will return status code 200 or 201. + +Other status codes that can occur are: + - 400 (Bad Request) + - 401 (Unauthorized) + - 404 (Not Found) + - 405 (Method not allowed) + +The api supports the following actions: + +**/api/events - GET**
+Returns a dict with as keys the events and as value the number of listeners. + +```json +{ + "event_listeners": { + "state_changed": 5, + "time_changed": 2 + } +} +``` + +**/api/services - GET**
+Returns a dict with as keys the domain and as value a list of published services. + +```json +{ + "services": { + "browser": [ + "browse_url" + ], + "keyboard": [ + "volume_up", + "volume_down" + ] + } +} +``` + +**/api/states - GET**
+Returns a dict with as keys the entity_ids and as value the state. + +```json +{ + "sun.sun": { + "attributes": { + "next_rising": "07:04:15 29-10-2013", + "next_setting": "18:00:31 29-10-2013" + }, + "entity_id": "sun.sun", + "last_changed": "23:24:33 28-10-2013", + "state": "below_horizon" + }, + "process.Dropbox": { + "attributes": {}, + "entity_id": "process.Dropbox", + "last_changed": "23:24:33 28-10-2013", + "state": "on" + } +} +``` + +**/api/states/<entity_id>** - GET
+Returns the current state from an entity + +```json +{ + "attributes": { + "next_rising": "07:04:15 29-10-2013", + "next_setting": "18:00:31 29-10-2013" + }, + "entity_id": "sun.sun", + "last_changed": "23:24:33 28-10-2013", + "state": "below_horizon" +} +``` + +**/api/states/<entity_id>** - POST
+Updates the current state of an entity. Returns status code 201 if successful with location header of updated resource and the new state in the body.
+parameter: new_state - string
+optional parameter: attributes - JSON encoded object + +```json +{ + "attributes": { + "next_rising": "07:04:15 29-10-2013", + "next_setting": "18:00:31 29-10-2013" + }, + "entity_id": "weather.sun", + "last_changed": "23:24:33 28-10-2013", + "state": "below_horizon" +} +``` + +**/api/events/<event_type>** - POST
+Fires an event with event_type
+optional parameter: event_data - JSON encoded object + +```json +{ + "message": "Event download_file fired." +} +``` + +**/api/services/<domain>/<service>** - POST
+Calls a service within a specific domain.
+optional parameter: service_data - JSON encoded object + +```json +{ + "message": "Service keyboard/volume_up called." +} +``` + +**/api/event_forwarding** - POST
+Setup event forwarding to another Home Assistant instance.
+parameter: host - string
+parameter: api_password - string
+optional parameter: port - int
+ +```json +{ + "message": "Event forwarding setup." +} +``` + +**/api/event_forwarding** - DELETE
+Cancel event forwarding to another Home Assistant instance.
+parameter: host - string
+optional parameter: port - int
+ +If your client does not support DELETE HTTP requests you can add an optional attribute _METHOD and set its value to DELETE. + +```json +{ + "message": "Event forwarding cancelled." +} +``` + +Android remote control +---------------------- + +An app has been built using [Tasker for Android](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm) that: + + * Provides buttons to control the lights and the chromecast + * Reports the charging state and battery level of the phone + +The [APK](https://raw.github.com/balloob/home-assistant/master/android-tasker/Home_Assistant.apk) and [Tasker project XML](https://raw.github.com/balloob/home-assistant/master/android-tasker/Home_Assistant.prj.xml) can be found in [/android-tasker/](https://github.com/balloob/home-assistant/tree/master/android-tasker) + +![screenshot-android-tasker.jpg](https://raw.github.com/balloob/home-assistant/master/docs/screenshot-android-tasker.png) diff --git a/docs/architecture-remote.png b/docs/architecture-remote.png new file mode 100644 index 00000000000..3109c921846 Binary files /dev/null and b/docs/architecture-remote.png differ diff --git a/docs/architecture.png b/docs/architecture.png index 927410ee875..8a53f65c7ca 100644 Binary files a/docs/architecture.png and b/docs/architecture.png differ diff --git a/home-assistant.conf.default b/home-assistant.conf.default index d0ae2c4738c..5227d34ef99 100644 --- a/home-assistant.conf.default +++ b/home-assistant.conf.default @@ -2,7 +2,7 @@ latitude=32.87336 longitude=-117.22743 -[httpinterface] +[http] api_password=mypass [light.hue] diff --git a/homeassistant/__init__.py b/homeassistant/__init__.py index 75204036e17..b7962442501 100644 --- a/homeassistant/__init__.py +++ b/homeassistant/__init__.py @@ -9,6 +9,7 @@ of entities and react to changes. import time import logging import threading +import enum import datetime as dt import functools as ft @@ -40,24 +41,21 @@ class HomeAssistant(object): """ Core class to route all communication to right components. """ def __init__(self): - self._pool = pool = _create_worker_pool() + self._pool = pool = create_worker_pool() self.bus = EventBus(pool) - self.states = StateMachine(self.bus) self.services = ServiceRegistry(self.bus, pool) + self.states = StateMachine(self.bus) - def start(self, non_blocking=False): - """ Start home assistant. - Set non_blocking to True if you don't want this method to block - as long as Home Assistant is running. """ - + def start(self): + """ Start home assistant. """ Timer(self) self.bus.fire(EVENT_HOMEASSISTANT_START) - if non_blocking: - return - + def block_till_stopped(self): + """ Will register service homeassistant/stop and + will block until called. """ request_shutdown = threading.Event() self.services.register(DOMAIN, SERVICE_HOMEASSISTANT_STOP, @@ -96,6 +94,7 @@ class HomeAssistant(object): def state_listener(event): """ The listener that listens for specific state changes. """ if entity_id == event.data['entity_id'] and \ + 'old_state' in event.data and \ _matcher(event.data['old_state'].state, from_state) and \ _matcher(event.data['new_state'].state, to_state): @@ -235,7 +234,7 @@ class JobPriority(util.OrderedEnum): return JobPriority.EVENT_DEFAULT -def _create_worker_pool(thread_count=POOL_NUM_THREAD): +def create_worker_pool(thread_count=POOL_NUM_THREAD): """ Creates a worker pool to be used. """ logger = logging.getLogger(__name__) @@ -264,22 +263,37 @@ def _create_worker_pool(thread_count=POOL_NUM_THREAD): return util.ThreadPool(thread_count, job_handler, busy_callback) +class EventOrigin(enum.Enum): + """ Distinguish between origin of event. """ + # pylint: disable=no-init + + local = "LOCAL" + remote = "REMOTE" + + def __str__(self): + return self.value + + # pylint: disable=too-few-public-methods class Event(object): """ Represents an event within the Bus. """ - __slots__ = ['event_type', 'data'] + __slots__ = ['event_type', 'data', 'origin'] - def __init__(self, event_type, data=None): + def __init__(self, event_type, data=None, origin=EventOrigin.local): self.event_type = event_type self.data = data or {} + self.origin = origin def __repr__(self): + # pylint: disable=maybe-no-member if self.data: - return "".format( - self.event_type, util.repr_helper(self.data)) + return "".format( + self.event_type, self.origin.value[0], + util.repr_helper(self.data)) else: - return "".format(self.event_type) + return "".format(self.event_type, + self.origin.value[0]) class EventBus(object): @@ -291,7 +305,7 @@ class EventBus(object): self._listeners = {} self._logger = logging.getLogger(__name__) self._lock = threading.Lock() - self._pool = pool or _create_worker_pool() + self._pool = pool or create_worker_pool() @property def listeners(self): @@ -302,7 +316,7 @@ class EventBus(object): return {key: len(self._listeners[key]) for key in self._listeners} - def fire(self, event_type, event_data=None): + def fire(self, event_type, event_data=None, origin=EventOrigin.local): """ Fire an event. """ with self._lock: # Copy the list of the current listeners because some listeners @@ -311,7 +325,7 @@ class EventBus(object): get = self._listeners.get listeners = get(MATCH_ALL, []) + get(event_type, []) - event = Event(event_type, event_data) + event = Event(event_type, event_data, origin) self._logger.info("Bus:Handling {}".format(event)) @@ -390,7 +404,9 @@ class State(object): """ Static method to create a state from a dict. Ensures: state == State.from_json_dict(state.to_json_dict()) """ - if 'entity_id' not in json_dict and 'state' not in json_dict: + if not (json_dict and + 'entity_id' in json_dict and + 'state' in json_dict): return None last_changed = json_dict.get('last_changed') @@ -429,6 +445,11 @@ class StateMachine(object): """ List of entity ids that are being tracked. """ return list(self._states.keys()) + def all(self): + """ Returns a dict mapping all entity_ids to their state. """ + return {entity_id: state.copy() for entity_id, state + in self._states.items()} + def get(self, entity_id): """ Returns the state of the specified entity. """ state = self._states.get(entity_id) @@ -456,24 +477,22 @@ class StateMachine(object): attributes = attributes or {} with self._lock: - if entity_id in self._states: - old_state = self._states[entity_id] + old_state = self._states.get(entity_id) - if old_state.state != new_state or \ - old_state.attributes != attributes: + # If state did not exist or is different, set it + if not old_state or \ + old_state.state != new_state or \ + old_state.attributes != attributes: - state = self._states[entity_id] = \ - State(entity_id, new_state, attributes) + state = self._states[entity_id] = \ + State(entity_id, new_state, attributes) - self._bus.fire(EVENT_STATE_CHANGED, - {'entity_id': entity_id, - 'old_state': old_state, - 'new_state': state}) + event_data = {'entity_id': entity_id, 'new_state': state} - else: - # If state did not exist yet - self._states[entity_id] = State(entity_id, new_state, - attributes) + if old_state: + event_data['old_state'] = old_state + + self._bus.fire(EVENT_STATE_CHANGED, event_data) # pylint: disable=too-few-public-methods @@ -501,7 +520,7 @@ class ServiceRegistry(object): def __init__(self, bus, pool=None): self._services = {} self._lock = threading.Lock() - self._pool = pool or _create_worker_pool() + self._pool = pool or create_worker_pool() bus.listen(EVENT_CALL_SERVICE, self._event_to_service_call) @property diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 7f63d725964..5d089aa31c6 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -194,13 +194,12 @@ def from_config_file(config_path, enable_logging=True): add_status("Keyboard", load_module('keyboard').setup(hass)) # Init HTTP interface - if has_opt("httpinterface", "api_password"): - httpinterface = load_module('httpinterface') + if has_opt("http", "api_password"): + http = load_module('http') - httpinterface.HTTPInterface( - hass, get_opt("httpinterface", "api_password")) + http.setup(hass, get_opt("http", "api_password")) - add_status("HTTPInterface", True) + add_status("HTTP", True) # Init groups if has_section("group"): diff --git a/homeassistant/components/httpinterface/__init__.py b/homeassistant/components/http/__init__.py similarity index 80% rename from homeassistant/components/httpinterface/__init__.py rename to homeassistant/components/http/__init__.py index be20e5be228..2cd5a67f3f4 100644 --- a/homeassistant/components/httpinterface/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -73,13 +73,13 @@ import logging import re import os from http.server import BaseHTTPRequestHandler, HTTPServer +from socketserver import ThreadingMixIn from urllib.parse import urlparse, parse_qs import homeassistant as ha +import homeassistant.remote as rem import homeassistant.util as util -SERVER_PORT = 8123 - HTTP_OK = 200 HTTP_CREATED = 201 HTTP_MOVED_PERMANENTLY = 301 @@ -92,46 +92,49 @@ HTTP_UNPROCESSABLE_ENTITY = 422 URL_ROOT = "/" URL_CHANGE_STATE = "/change_state" URL_FIRE_EVENT = "/fire_event" - -URL_API_STATES = "/api/states" -URL_API_STATES_ENTITY = "/api/states/{}" -URL_API_EVENTS = "/api/events" -URL_API_EVENTS_EVENT = "/api/events/{}" -URL_API_SERVICES = "/api/services" -URL_API_SERVICES_SERVICE = "/api/services/{}/{}" +URL_CALL_SERVICE = "/call_service" URL_STATIC = "/static/{}" -class HTTPInterface(threading.Thread): - """ Provides an HTTP interface for Home Assistant. """ +def setup(hass, api_password, server_port=None, server_host=None): + """ Sets up the HTTP API and debug interface. """ + server_port = server_port or rem.SERVER_PORT - # pylint: disable=too-many-arguments - def __init__(self, hass, api_password, server_port=None, server_host=None): - threading.Thread.__init__(self) + # If no server host is given, accept all incoming requests + server_host = server_host or '0.0.0.0' - self.daemon = True + server = HomeAssistantHTTPServer((server_host, server_port), + RequestHandler, hass, api_password) - server_port = server_port or SERVER_PORT + hass.listen_once_event( + ha.EVENT_HOMEASSISTANT_START, + lambda event: + threading.Thread(target=server.start, daemon=True).start()) - # If no server host is given, accept all incoming requests - server_host = server_host or '0.0.0.0' - self.server = HTTPServer((server_host, server_port), RequestHandler) +class HomeAssistantHTTPServer(ThreadingMixIn, HTTPServer): + """ Handle HTTP requests in a threaded fashion. """ - self.server.flash_message = None - self.server.logger = logging.getLogger(__name__) - self.server.hass = hass - self.server.api_password = api_password + def __init__(self, server_address, RequestHandlerClass, + hass, api_password): + super().__init__(server_address, RequestHandlerClass) - hass.listen_once_event(ha.EVENT_HOMEASSISTANT_START, - lambda event: self.start()) + self.hass = hass + self.api_password = api_password + self.logger = logging.getLogger(__name__) - def run(self): - """ Start the HTTP interface. """ - self.server.logger.info("Starting") + # To store flash messages between sessions + self.flash_message = None - self.server.serve_forever() + # We will lazy init this one if needed + self.event_forwarder = None + + def start(self): + """ Starts the server. """ + self.logger.info("Starting") + + self.serve_forever() # pylint: disable=too-many-public-methods @@ -139,13 +142,15 @@ class RequestHandler(BaseHTTPRequestHandler): """ Handles incoming HTTP requests """ PATHS = [ # debug interface - ('GET', '/', '_handle_get_root'), - ('POST', re.compile(r'/change_state'), '_handle_change_state'), - ('POST', re.compile(r'/fire_event'), '_handle_fire_event'), - ('POST', re.compile(r'/call_service'), '_handle_call_service'), + ('GET', URL_ROOT, '_handle_get_root'), + # These get compiled as RE because these methods are reused + # by other urls that use url parameters + ('POST', re.compile(URL_CHANGE_STATE), '_handle_change_state'), + ('POST', re.compile(URL_FIRE_EVENT), '_handle_fire_event'), + ('POST', re.compile(URL_CALL_SERVICE), '_handle_call_service'), # /states - ('GET', '/api/states', '_handle_get_api_states'), + ('GET', rem.URL_API_STATES, '_handle_get_api_states'), ('GET', re.compile(r'/api/states/(?P[a-zA-Z\._0-9]+)'), '_handle_get_api_states_entity'), @@ -154,19 +159,24 @@ class RequestHandler(BaseHTTPRequestHandler): '_handle_change_state'), # /events - ('GET', '/api/events', '_handle_get_api_events'), + ('GET', rem.URL_API_EVENTS, '_handle_get_api_events'), ('POST', re.compile(r'/api/events/(?P[a-zA-Z\._0-9]+)'), '_handle_fire_event'), # /services - ('GET', '/api/services', '_handle_get_api_services'), + ('GET', rem.URL_API_SERVICES, '_handle_get_api_services'), ('POST', re.compile((r'/api/services/' r'(?P[a-zA-Z\._0-9]+)/' r'(?P[a-zA-Z\._0-9]+)')), '_handle_call_service'), + # /event_forwarding + ('POST', rem.URL_API_EVENT_FORWARD, '_handle_post_api_event_forward'), + ('DELETE', rem.URL_API_EVENT_FORWARD, + '_handle_delete_api_event_forward'), + # Statis files ('GET', re.compile(r'/static/(?P[a-zA-Z\._\-0-9/]+)'), '_handle_get_static') @@ -193,6 +203,9 @@ class RequestHandler(BaseHTTPRequestHandler): except KeyError: api_password = '' + if '_METHOD' in data: + method = data['_METHOD'][0] + if url.path.startswith('/api/'): self.use_json = True @@ -327,11 +340,9 @@ class RequestHandler(BaseHTTPRequestHandler): "AttributesLast Changed" "").format(self.server.api_password)) - for entity_id in \ - sorted(self.server.hass.states.entity_ids, - key=lambda key: key.lower()): - - state = self.server.hass.states.get(entity_id) + for entity_id, state in \ + sorted(self.server.hass.states.all().items(), + key=lambda item: item[0].lower()): attributes = "
".join( ["{}: {}".format(attr, state.attributes[attr]) @@ -512,7 +523,7 @@ class RequestHandler(BaseHTTPRequestHandler): self._write_json(state.as_dict(), status_code=HTTP_CREATED, location= - URL_API_STATES_ENTITY.format(entity_id)) + rem.URL_API_STATES_ENTITY.format(entity_id)) else: self._message( "State of {} changed to {}".format(entity_id, new_state)) @@ -534,21 +545,33 @@ class RequestHandler(BaseHTTPRequestHandler): This handles the following paths: /fire_event /api/events/ + + Events from /api are threated as remote events. """ try: try: event_type = path_match.group('event_type') + event_origin = ha.EventOrigin.remote except IndexError: # If group event_type does not exist in path_match event_type = data['event_type'][0] + event_origin = ha.EventOrigin.local - try: + if 'event_data' in data: event_data = json.loads(data['event_data'][0]) - except KeyError: - # Happens if key 'event_data' does not exist + else: event_data = None - self.server.hass.bus.fire(event_type, event_data) + # 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 + + self.server.hass.bus.fire(event_type, event_data, event_origin) self._message("Event {} fired.".format(event_type)) @@ -598,9 +621,8 @@ class RequestHandler(BaseHTTPRequestHandler): # pylint: disable=unused-argument def _handle_get_api_states(self, path_match, data): - """ Returns the entitie ids which state are being tracked. """ - self._write_json( - {'entity_ids': list(self.server.hass.states.entity_ids)}) + """ Returns a dict containing all entity ids and their state. """ + self._write_json(self.server.hass.states.all()) # pylint: disable=unused-argument def _handle_get_api_states_entity(self, path_match, data): @@ -609,10 +631,9 @@ class RequestHandler(BaseHTTPRequestHandler): state = self.server.hass.states.get(entity_id) - try: - self._write_json(state.as_dict()) - except AttributeError: - # If state for entity_id does not exist + if state: + self._write_json(state) + else: self._message("State does not exist.", HTTP_UNPROCESSABLE_ENTITY) def _handle_get_api_events(self, path_match, data): @@ -623,6 +644,60 @@ class RequestHandler(BaseHTTPRequestHandler): """ Handles getting overview of services. """ self._write_json({'services': self.server.hass.services.services}) + def _handle_post_api_event_forward(self, path_match, data): + """ Handles adding an event forwarding target. """ + + try: + host = data['host'][0] + api_password = data['api_password'][0] + + port = int(data['port'][0]) if 'port' in data else None + + if self.server.event_forwarder is None: + self.server.event_forwarder = \ + rem.EventForwarder(self.server.hass) + + api = rem.API(host, api_password, port) + + self.server.event_forwarder.connect(api) + + self._message("Event forwarding setup.") + + except KeyError: + # Occurs if domain or service does not exist in data + self._message("No host or api_password received.", + HTTP_BAD_REQUEST) + + except ValueError: + # Occurs during error parsing port + self._message( + "Invalid value received for port", HTTP_UNPROCESSABLE_ENTITY) + + def _handle_delete_api_event_forward(self, path_match, data): + """ Handles deleting an event forwarding target. """ + + try: + host = data['host'][0] + + port = int(data['port'][0]) if 'port' in data else None + + if self.server.event_forwarder is not None: + api = rem.API(host, None, port) + + self.server.event_forwarder.disconnect(api) + + self._message("Event forwarding cancelled.") + + except KeyError: + # Occurs if domain or service does not exist in data + self._message("No host or api_password received.", + HTTP_BAD_REQUEST) + + except ValueError: + # Occurs during error parsing port + self._message( + "Invalid value received for port", HTTP_UNPROCESSABLE_ENTITY) + def _handle_get_static(self, path_match, data): """ Returns a static file. """ req_file = util.sanitize_filename(path_match.group('file')) @@ -680,4 +755,5 @@ class RequestHandler(BaseHTTPRequestHandler): if data: self.wfile.write( - json.dumps(data, indent=4, sort_keys=True).encode("UTF-8")) + json.dumps(data, indent=4, sort_keys=True, + cls=rem.JSONEncoder).encode("UTF-8")) diff --git a/homeassistant/components/httpinterface/www_static/favicon.ico b/homeassistant/components/http/www_static/favicon.ico similarity index 100% rename from homeassistant/components/httpinterface/www_static/favicon.ico rename to homeassistant/components/http/www_static/favicon.ico diff --git a/homeassistant/components/httpinterface/www_static/style.css b/homeassistant/components/http/www_static/style.css similarity index 100% rename from homeassistant/components/httpinterface/www_static/style.css rename to homeassistant/components/http/www_static/style.css diff --git a/homeassistant/remote.py b/homeassistant/remote.py index 26641b113c9..b5f24620508 100644 --- a/homeassistant/remote.py +++ b/homeassistant/remote.py @@ -17,24 +17,37 @@ import urllib.parse import requests import homeassistant as ha -import homeassistant.components.httpinterface as hah + +SERVER_PORT = 8123 + +URL_API_STATES = "/api/states" +URL_API_STATES_ENTITY = "/api/states/{}" +URL_API_EVENTS = "/api/events" +URL_API_EVENTS_EVENT = "/api/events/{}" +URL_API_SERVICES = "/api/services" +URL_API_SERVICES_SERVICE = "/api/services/{}/{}" +URL_API_EVENT_FORWARD = "/api/event_forwarding" METHOD_GET = "get" METHOD_POST = "post" -def _setup_call_api(host, port, api_password): - """ Helper method to setup a call api method. """ - port = port or hah.SERVER_PORT +class API(object): + """ Object to pass around Home Assistant API location and credentials. """ + # pylint: disable=too-few-public-methods - base_url = "http://{}:{}".format(host, port) + def __init__(self, host, api_password, port=None): + self.host = host + self.port = port or SERVER_PORT + self.api_password = api_password + self.base_url = "http://{}:{}".format(host, self.port) - def _call_api(method, path, data=None): + def __call__(self, method, path, data=None): """ Makes a call to the Home Assistant api. """ data = data or {} - data['api_password'] = api_password + data['api_password'] = self.api_password - url = urllib.parse.urljoin(base_url, path) + url = urllib.parse.urljoin(self.base_url, path) try: if method == METHOD_GET: @@ -46,7 +59,134 @@ def _setup_call_api(host, port, api_password): logging.getLogger(__name__).exception("Error connecting to server") raise ha.HomeAssistantError("Error connecting to server") - return _call_api + +class HomeAssistant(ha.HomeAssistant): + """ Home Assistant that forwards work. """ + # pylint: disable=super-init-not-called + + def __init__(self, local_api, remote_api): + self.local_api = local_api + self.remote_api = remote_api + + self._pool = pool = ha.create_worker_pool() + + self.bus = EventBus(remote_api, pool) + self.services = ha.ServiceRegistry(self.bus, pool) + self.states = StateMachine(self.bus, self.remote_api) + + def start(self): + ha.Timer(self) + + # Setup that events from remote_api get forwarded to local_api + connect_remote_events(self.remote_api, self.local_api) + + self.bus.fire(ha.EVENT_HOMEASSISTANT_START, + origin=ha.EventOrigin.remote) + + +class EventBus(ha.EventBus): + """ EventBus implementation that forwards fire_event to remote API. """ + + def __init__(self, api, pool=None): + super().__init__(pool) + self._api = api + + def fire(self, event_type, event_data=None, origin=ha.EventOrigin.local): + """ Forward local events to remote target, + handles remote event as usual. """ + # All local events that are not TIME_CHANGED are forwarded to API + if origin == ha.EventOrigin.local and \ + event_type != ha.EVENT_TIME_CHANGED: + + fire_event(self._api, event_type, event_data) + + else: + super().fire(event_type, event_data, origin) + + +class EventForwarder(object): + """ Listens for events and forwards to specified APIs. """ + + def __init__(self, hass, restrict_origin=None): + self.hass = hass + self.restrict_origin = restrict_origin + self.logger = logging.getLogger(__name__) + + # We use a tuple (host, port) as key to ensure + # that we do not forward to the same host twice + self._targets = {} + + self._lock = threading.Lock() + + def connect(self, api): + """ + Attach to a HA instance and forward events. + + Will overwrite old target if one exists with same host/port. + """ + with self._lock: + if len(self._targets) == 0: + # First target we get, setup listener for events + self.hass.bus.listen(ha.MATCH_ALL, self._event_listener) + + key = (api.host, api.port) + + self._targets[key] = api + + def disconnect(self, api): + """ Removes target from being forwarded to. """ + with self._lock: + key = (api.host, api.port) + + did_remove = self._targets.pop(key, None) is None + + if len(self._targets) == 0: + # Remove event listener if no forwarding targets present + self.hass.bus.remove_listener(ha.MATCH_ALL, + self._event_listener) + + return did_remove + + def _event_listener(self, event): + """ Listen and forwards all events. """ + with self._lock: + # We don't forward time events or, if enabled, non-local events + if event.event_type == ha.EVENT_TIME_CHANGED or \ + (self.restrict_origin and event.origin != self.restrict_origin): + return + + for api in self._targets.values(): + fire_event(api, event.event_type, event.data, self.logger) + + +class StateMachine(ha.StateMachine): + """ + Fires set events to an API. + Uses state_change events to track states. + """ + + def __init__(self, bus, api): + super().__init__(None) + + self.logger = logging.getLogger(__name__) + + self._api = api + + self.mirror() + + bus.listen(ha.EVENT_STATE_CHANGED, self._state_changed_listener) + + def set(self, entity_id, new_state, attributes=None): + """ Calls set_state on remote API . """ + set_state(self._api, entity_id, new_state, attributes) + + def mirror(self): + """ Discards current data and mirrors the remote state machine. """ + self._states = get_states(self._api, self.logger) + + def _state_changed_listener(self, event): + """ Listens for state changed events and applies them. """ + self._states[event.data['entity_id']] = event.data['new_state'] class JSONEncoder(json.JSONEncoder): @@ -61,212 +201,168 @@ class JSONEncoder(json.JSONEncoder): return json.JSONEncoder.default(self, obj) -class EventBus(object): - """ Allows to interface with a Home Assistant EventBus via the API. """ +def connect_remote_events(from_api, to_api): + """ Sets up from_api to forward all events to to_api. """ - def __init__(self, host, api_password, port=None): - self.logger = logging.getLogger(__name__) + data = {'host': to_api.host, 'api_password': to_api.api_password} - self._call_api = _setup_call_api(host, port, api_password) + if to_api.port is not None: + data['port'] = to_api.port - @property - def listeners(self): - """ List of events that is being listened for. """ - try: - req = self._call_api(METHOD_GET, hah.URL_API_EVENTS) + try: + from_api(METHOD_POST, URL_API_EVENT_FORWARD, data) - if req.status_code == 200: - data = req.json() - - return data['event_listeners'] - - else: - raise ha.HomeAssistantError( - "Got unexpected result (3): {}.".format(req.text)) - - except ValueError: # If req.json() can't parse the json - self.logger.exception("Bus:Got unexpected result") - raise ha.HomeAssistantError( - "Got unexpected result: {}".format(req.text)) - - except KeyError: # If not all expected keys are in the returned JSON - self.logger.exception("Bus:Got unexpected result (2)") - raise ha.HomeAssistantError( - "Got unexpected result (2): {}".format(req.text)) - - def fire(self, event_type, event_data=None): - """ Fire an event. """ - - if event_data: - data = {'event_data': json.dumps(event_data, cls=JSONEncoder)} - else: - data = None - - req = self._call_api(METHOD_POST, - hah.URL_API_EVENTS_EVENT.format(event_type), - data) - - if req.status_code != 200: - error = "Error firing event: {} - {}".format( - req.status_code, req.text) - - self.logger.error("Bus:{}".format(error)) - raise ha.HomeAssistantError(error) + except ha.HomeAssistantError: + pass -class StateMachine(object): - """ Allows to interface with a Home Assistant StateMachine via the API. """ +def disconnect_remote_events(from_api, to_api): + """ Disconnects forwarding events from from_api to to_api. """ + data = {'host': to_api.host, '_METHOD': 'DELETE'} - def __init__(self, host, api_password, port=None): - self._call_api = _setup_call_api(host, port, api_password) + if to_api.port is not None: + data['port'] = to_api.port - self.lock = threading.Lock() - self.logger = logging.getLogger(__name__) + try: + from_api(METHOD_POST, URL_API_EVENT_FORWARD, data) - @property - def entity_ids(self): - """ List of entity ids which states are being tracked. """ - - try: - req = self._call_api(METHOD_GET, hah.URL_API_STATES) - - return req.json()['entity_ids'] - - except requests.exceptions.ConnectionError: - self.logger.exception("StateMachine:Error connecting to server") - return [] - - except ValueError: # If req.json() can't parse the json - self.logger.exception("StateMachine:Got unexpected result") - return [] - - except KeyError: # If 'entity_ids' key not in parsed json - self.logger.exception("StateMachine:Got unexpected result (2)") - return [] - - def set(self, entity_id, new_state, attributes=None): - """ Set the state of a entity, add entity if it does not exist. - - Attributes is an optional dict to specify attributes of this state. """ - - attributes = attributes or {} - - self.lock.acquire() - - data = {'new_state': new_state, - 'attributes': json.dumps(attributes)} - - try: - req = self._call_api(METHOD_POST, - hah.URL_API_STATES_ENTITY.format(entity_id), - data) - - if req.status_code != 201: - error = "Error changing state: {} - {}".format( - req.status_code, req.text) - - self.logger.error("StateMachine:{}".format(error)) - raise ha.HomeAssistantError(error) - - except requests.exceptions.ConnectionError: - self.logger.exception("StateMachine:Error connecting to server") - raise ha.HomeAssistantError("Error connecting to server") - - finally: - self.lock.release() - - def get(self, entity_id): - """ Returns the state of the specified entity. """ - - try: - req = self._call_api(METHOD_GET, - hah.URL_API_STATES_ENTITY.format(entity_id)) - - if req.status_code == 200: - data = req.json() - - return ha.State.from_dict(data) - - elif req.status_code == 422: - # Entity does not exist - return None - - else: - raise ha.HomeAssistantError( - "Got unexpected result (3): {}.".format(req.text)) - - except requests.exceptions.ConnectionError: - self.logger.exception("StateMachine:Error connecting to server") - raise ha.HomeAssistantError("Error connecting to server") - - except ValueError: # If req.json() can't parse the json - self.logger.exception("StateMachine:Got unexpected result") - raise ha.HomeAssistantError( - "Got unexpected result: {}".format(req.text)) - - except KeyError: # If not all expected keys are in the returned JSON - self.logger.exception("StateMachine:Got unexpected result (2)") - raise ha.HomeAssistantError( - "Got unexpected result (2): {}".format(req.text)) - - def is_state(self, entity_id, state): - """ Returns True if entity exists and is specified state. """ - try: - return self.get(entity_id).state == state - except AttributeError: - # get returned None - return False + except ha.HomeAssistantError: + pass -class ServiceRegistry(object): - """ Allows to interface with a Home Assistant ServiceRegistry - via the API. """ +def get_event_listeners(api, logger=None): + """ List of events that is being listened for. """ + try: + req = api(METHOD_GET, URL_API_EVENTS) - def __init__(self, host, api_password, port=None): - self.logger = logging.getLogger(__name__) + return req.json()['event_listeners'] if req.status_code == 200 else {} - self._call_api = _setup_call_api(host, port, api_password) + except (ha.HomeAssistantError, ValueError, KeyError): + # ValueError if req.json() can't parse the json + # KeyError if 'event_listeners' not found in parsed json + if logger: + logger.exception("Bus:Got unexpected result") - @property - def services(self): - """ List the available services. """ - try: - req = self._call_api(METHOD_GET, hah.URL_API_SERVICES) + return {} - if req.status_code == 200: - data = req.json() - return data['services'] +def fire_event(api, event_type, event_data=None, logger=None): + """ Fire an event at remote API. """ - else: - raise ha.HomeAssistantError( - "Got unexpected result (3): {}.".format(req.text)) + if event_data: + data = {'event_data': json.dumps(event_data, cls=JSONEncoder)} + else: + data = None - except ValueError: # If req.json() can't parse the json - self.logger.exception("ServiceRegistry:Got unexpected result") - raise ha.HomeAssistantError( - "Got unexpected result: {}".format(req.text)) + try: + req = api(METHOD_POST, URL_API_EVENTS_EVENT.format(event_type), data) - except KeyError: # If not all expected keys are in the returned JSON - self.logger.exception("ServiceRegistry:Got unexpected result (2)") - raise ha.HomeAssistantError( - "Got unexpected result (2): {}".format(req.text)) + if req.status_code != 200 and logger: + logger.error( + "Error firing event: {} - {}".format( + req.status_code, req.text)) - def call_service(self, domain, service, service_data=None): - """ Calls a service. """ + except ha.HomeAssistantError: + pass - if service_data: - data = {'service_data': json.dumps(service_data)} - else: - data = None - req = self._call_api(METHOD_POST, - hah.URL_API_SERVICES_SERVICE.format( - domain, service), - data) +def get_state(api, entity_id, logger=None): + """ Queries given API for state of entity_id. """ - if req.status_code != 200: - error = "Error calling service: {} - {}".format( - req.status_code, req.text) + try: + req = api(METHOD_GET, + URL_API_STATES_ENTITY.format(entity_id)) - self.logger.error("ServiceRegistry:{}".format(error)) + # req.status_code == 422 if entity does not exist + + return ha.State.from_dict(req.json()) \ + if req.status_code == 200 else None + + except (ha.HomeAssistantError, ValueError): + # ValueError if req.json() can't parse the json + if logger: + logger.exception("Error getting state") + + return None + + +def get_states(api, logger=None): + """ Queries given API for all states. """ + + try: + req = api(METHOD_GET, + URL_API_STATES) + + json_result = req.json() + states = {} + + for entity_id, state_dict in json_result.items(): + state = ha.State.from_dict(state_dict) + + if state: + states[entity_id] = state + + return states + + except (ha.HomeAssistantError, ValueError, AttributeError): + # ValueError if req.json() can't parse the json + # AttributeError if parsed JSON was not a dict + if logger: + logger.exception("Error getting state") + + return {} + + +def set_state(api, entity_id, new_state, attributes=None, logger=None): + """ Tells API to update state for entity_id. """ + + attributes = attributes or {} + + data = {'new_state': new_state, + 'attributes': json.dumps(attributes)} + + try: + req = api(METHOD_POST, + URL_API_STATES_ENTITY.format(entity_id), + data) + + if req.status_code != 201 and logger: + logger.error( + "Error changing state: {} - {}".format( + req.status_code, req.text)) + + except ha.HomeAssistantError: + if logger: + logger.exception("Error setting state to server") + + +def is_state(api, entity_id, state, logger=None): + """ Queries API to see if entity_id is specified state. """ + cur_state = get_state(api, entity_id, logger) + + return cur_state and cur_state.state == state + + +def get_services(api, logger=None): + """ Returns a dict with per domain the available services at API. """ + try: + req = api(METHOD_GET, URL_API_SERVICES) + + return req.json()['services'] if req.status_code == 200 else {} + + except (ha.HomeAssistantError, ValueError, KeyError): + # ValueError if req.json() can't parse the json + # KeyError if not all expected keys are in the returned JSON + if logger: + logger.exception("ServiceRegistry:Got unexpected result") + + return {} + + +def call_service(api, domain, service, service_data=None, logger=None): + """ Calls a service at the remote API. """ + event_data = service_data or {} + event_data[ha.ATTR_DOMAIN] = domain + event_data[ha.ATTR_SERVICE] = service + + fire_event(api, ha.EVENT_CALL_SERVICE, event_data, logger) diff --git a/homeassistant/test.py b/homeassistant/test.py index c193e2df7b2..b61b17f32d2 100644 --- a/homeassistant/test.py +++ b/homeassistant/test.py @@ -13,11 +13,11 @@ import requests import homeassistant as ha import homeassistant.remote as remote -import homeassistant.components.httpinterface as hah +import homeassistant.components.http as http API_PASSWORD = "test1234" -HTTP_BASE_URL = "http://127.0.0.1:{}".format(hah.SERVER_PORT) +HTTP_BASE_URL = "http://127.0.0.1:{}".format(remote.SERVER_PORT) def _url(path=""): @@ -28,6 +28,7 @@ def _url(path=""): class HAHelper(object): # pylint: disable=too-few-public-methods """ Helper class to keep track of current running HA instance. """ hass = None + slave = None def ensure_homeassistant_started(): @@ -39,9 +40,9 @@ def ensure_homeassistant_started(): hass.bus.listen('test_event', len) hass.states.set('test', 'a_state') - hah.HTTPInterface(hass, API_PASSWORD) + http.setup(hass, API_PASSWORD) - hass.bus.fire(ha.EVENT_HOMEASSISTANT_START) + hass.start() # Give objects time to startup time.sleep(1) @@ -51,6 +52,26 @@ def ensure_homeassistant_started(): return HAHelper.hass +def ensure_slave_started(): + """ Ensure a home assistant slave is started. """ + + if not HAHelper.slave: + local_api = remote.API("127.0.0.1", API_PASSWORD, 8124) + remote_api = remote.API("127.0.0.1", API_PASSWORD) + slave = remote.HomeAssistant(local_api, remote_api) + + http.setup(slave, API_PASSWORD, 8124) + + slave.start() + + # Give objects time to startup + time.sleep(1) + + HAHelper.slave = slave + + return HAHelper.slave + + # pylint: disable=too-many-public-methods class TestHTTPInterface(unittest.TestCase): """ Test the HTTP debug interface and API. """ @@ -75,12 +96,12 @@ class TestHTTPInterface(unittest.TestCase): """ Test if we get access denied if we omit or provide a wrong api password. """ req = requests.get( - _url(hah.URL_API_STATES_ENTITY.format("test"))) + _url(remote.URL_API_STATES_ENTITY.format("test"))) self.assertEqual(req.status_code, 401) req = requests.get( - _url(hah.URL_API_STATES_ENTITY.format("test")), + _url(remote.URL_API_STATES_ENTITY.format("test")), params={"api_password": "not the password"}) self.assertEqual(req.status_code, 401) @@ -89,7 +110,7 @@ class TestHTTPInterface(unittest.TestCase): """ Test if we can change a state from the debug interface. """ self.hass.states.set("test.test", "not_to_be_set") - requests.post(_url(hah.URL_CHANGE_STATE), + requests.post(_url(http.URL_CHANGE_STATE), data={"entity_id": "test.test", "new_state": "debug_state_change2", "api_password": API_PASSWORD}) @@ -110,7 +131,7 @@ class TestHTTPInterface(unittest.TestCase): self.hass.listen_once_event("test_event_with_data", listener) requests.post( - _url(hah.URL_FIRE_EVENT), + _url(http.URL_FIRE_EVENT), data={"event_type": "test_event_with_data", "event_data": '{"test": 1}', "api_password": API_PASSWORD}) @@ -122,18 +143,20 @@ class TestHTTPInterface(unittest.TestCase): def test_api_list_state_entities(self): """ Test if the debug interface allows us to list state entities. """ - req = requests.get(_url(hah.URL_API_STATES), + req = requests.get(_url(remote.URL_API_STATES), data={"api_password": API_PASSWORD}) - data = req.json() + remote_data = req.json() - self.assertEqual(list(self.hass.states.entity_ids), - data['entity_ids']) + local_data = {entity_id: state.as_dict() for entity_id, state + in self.hass.states.all().items()} + + self.assertEqual(local_data, remote_data) def test_api_get(self): """ Test if the debug interface allows us to get a state. """ req = requests.get( - _url(hah.URL_API_STATES_ENTITY.format("test")), + _url(remote.URL_API_STATES_ENTITY.format("test")), data={"api_password": API_PASSWORD}) data = ha.State.from_dict(req.json()) @@ -147,7 +170,7 @@ class TestHTTPInterface(unittest.TestCase): def test_api_get_non_existing_state(self): """ Test if the debug interface allows us to get a state. """ req = requests.get( - _url(hah.URL_API_STATES_ENTITY.format("does_not_exist")), + _url(remote.URL_API_STATES_ENTITY.format("does_not_exist")), params={"api_password": API_PASSWORD}) self.assertEqual(req.status_code, 422) @@ -157,7 +180,7 @@ class TestHTTPInterface(unittest.TestCase): self.hass.states.set("test.test", "not_to_be_set") - requests.post(_url(hah.URL_API_STATES_ENTITY.format("test.test")), + requests.post(_url(remote.URL_API_STATES_ENTITY.format("test.test")), data={"new_state": "debug_state_change2", "api_password": API_PASSWORD}) @@ -172,7 +195,7 @@ class TestHTTPInterface(unittest.TestCase): new_state = "debug_state_change" req = requests.post( - _url(hah.URL_API_STATES_ENTITY.format( + _url(remote.URL_API_STATES_ENTITY.format( "test_entity_that_does_not_exist")), data={"new_state": new_state, "api_password": API_PASSWORD}) @@ -195,7 +218,7 @@ class TestHTTPInterface(unittest.TestCase): self.hass.listen_once_event("test.event_no_data", listener) requests.post( - _url(hah.URL_API_EVENTS_EVENT.format("test.event_no_data")), + _url(remote.URL_API_EVENTS_EVENT.format("test.event_no_data")), data={"api_password": API_PASSWORD}) # Allow the event to take place @@ -217,7 +240,7 @@ class TestHTTPInterface(unittest.TestCase): self.hass.listen_once_event("test_event_with_data", listener) requests.post( - _url(hah.URL_API_EVENTS_EVENT.format("test_event_with_data")), + _url(remote.URL_API_EVENTS_EVENT.format("test_event_with_data")), data={"event_data": '{"test": 1}', "api_password": API_PASSWORD}) @@ -238,7 +261,7 @@ class TestHTTPInterface(unittest.TestCase): self.hass.listen_once_event("test_event_with_bad_data", listener) req = requests.post( - _url(hah.URL_API_EVENTS_EVENT.format("test_event")), + _url(remote.URL_API_EVENTS_EVENT.format("test_event")), data={"event_data": 'not json', "api_password": API_PASSWORD}) @@ -250,7 +273,7 @@ class TestHTTPInterface(unittest.TestCase): def test_api_get_event_listeners(self): """ Test if we can get the list of events being listened for. """ - req = requests.get(_url(hah.URL_API_EVENTS), + req = requests.get(_url(remote.URL_API_EVENTS), params={"api_password": API_PASSWORD}) data = req.json() @@ -259,7 +282,7 @@ class TestHTTPInterface(unittest.TestCase): def test_api_get_services(self): """ Test if we can get a dict describing current services. """ - req = requests.get(_url(hah.URL_API_SERVICES), + req = requests.get(_url(remote.URL_API_SERVICES), params={"api_password": API_PASSWORD}) data = req.json() @@ -277,7 +300,7 @@ class TestHTTPInterface(unittest.TestCase): self.hass.services.register("test_domain", "test_service", listener) requests.post( - _url(hah.URL_API_SERVICES_SERVICE.format( + _url(remote.URL_API_SERVICES_SERVICE.format( "test_domain", "test_service")), data={"api_password": API_PASSWORD}) @@ -299,7 +322,7 @@ class TestHTTPInterface(unittest.TestCase): self.hass.services.register("test_domain", "test_service", listener) requests.post( - _url(hah.URL_API_SERVICES_SERVICE.format( + _url(remote.URL_API_SERVICES_SERVICE.format( "test_domain", "test_service")), data={"service_data": '{"test": 1}', "api_password": API_PASSWORD}) @@ -310,7 +333,7 @@ class TestHTTPInterface(unittest.TestCase): self.assertEqual(len(test_value), 1) -class TestRemote(unittest.TestCase): +class TestRemoteMethods(unittest.TestCase): """ Test the homeassistant.remote module. """ @classmethod @@ -318,134 +341,115 @@ class TestRemote(unittest.TestCase): """ things to be run when tests are started. """ cls.hass = ensure_homeassistant_started() - cls.remote_sm = remote.StateMachine("127.0.0.1", API_PASSWORD) - cls.remote_eb = remote.EventBus("127.0.0.1", API_PASSWORD) - cls.remote_sr = remote.ServiceRegistry("127.0.0.1", API_PASSWORD) - cls.sm_with_remote_eb = ha.StateMachine(cls.remote_eb) - cls.sm_with_remote_eb.set("test", "a_state") + cls.api = remote.API("127.0.0.1", API_PASSWORD) - # pylint: disable=invalid-name - def test_remote_sm_list_state_entities(self): - """ Test if the debug interface allows us to list state entity ids. """ + def test_get_event_listeners(self): + """ Test Python API get_event_listeners. """ - self.assertEqual(list(self.hass.states.entity_ids), - self.remote_sm.entity_ids) + self.assertEqual( + remote.get_event_listeners(self.api), self.hass.bus.listeners) - def test_remote_sm_get(self): - """ Test if debug interface allows us to get state of an entity. """ - remote_state = self.remote_sm.get("test") - - state = self.hass.states.get("test") - - self.assertEqual(remote_state.state, state.state) - self.assertEqual(remote_state.last_changed, state.last_changed) - self.assertEqual(remote_state.attributes, state.attributes) - - def test_remote_sm_get_non_existing_state(self): - """ Test remote state machine to get state of non existing entity. """ - self.assertEqual(self.remote_sm.get("test_does_not_exist"), None) - - def test_remote_sm_state_change(self): - """ Test if we can change the state of an existing entity. """ - - self.remote_sm.set("test", "set_remotely", {"test": 1}) - - state = self.hass.states.get("test") - - self.assertEqual(state.state, "set_remotely") - self.assertEqual(state.attributes['test'], 1) - - def test_remote_eb_listening_for_same(self): - """ Test if remote EB correctly reports listener overview. """ - self.assertEqual(self.hass.bus.listeners, - self.remote_eb.listeners) - - # pylint: disable=invalid-name - def test_remote_eb_fire_event_with_no_data(self): - """ Test if the remote bus allows us to fire an event. """ + def test_fire_event(self): + """ Test Python API fire_event. """ test_value = [] def listener(event): # pylint: disable=unused-argument """ Helper method that will verify our event got called. """ test_value.append(1) - self.hass.listen_once_event("test_event_no_data", listener) + self.hass.listen_once_event("test.event_no_data", listener) - self.remote_eb.fire("test_event_no_data") + remote.fire_event(self.api, "test.event_no_data") # Allow the event to take place time.sleep(1) self.assertEqual(len(test_value), 1) - # pylint: disable=invalid-name - def test_remote_eb_fire_event_with_data(self): - """ Test if the remote bus allows us to fire an event. """ - test_value = [] + def test_get_state(self): + """ Test Python API get_state. """ - def listener(event): # pylint: disable=unused-argument - """ Helper method that will verify our event got called. """ - if event.data["test"] == 1: - test_value.append(1) + self.assertEqual( + remote.get_state(self.api, 'test'), self.hass.states.get('test')) - self.hass.listen_once_event("test_event_with_data", listener) + def test_get_states(self): + """ Test Python API get_state_entity_ids. """ - self.remote_eb.fire("test_event_with_data", {"test": 1}) + self.assertEqual( + remote.get_states(self.api), self.hass.states.all()) - # Allow the event to take place - time.sleep(1) + def test_set_state(self): + """ Test Python API set_state. """ + remote.set_state(self.api, 'test', 'set_test') - self.assertEqual(len(test_value), 1) + self.assertEqual(self.hass.states.get('test').state, 'set_test') - # pylint: disable=invalid-name - def test_remote_sr_call_service_with_no_data(self): - """ Test if the remote bus allows us to fire a service. """ + def test_is_state(self): + """ Test Python API is_state. """ + + self.assertEqual( + remote.is_state(self.api, 'test', + self.hass.states.get('test').state), + True) + + def test_get_services(self): + """ Test Python API get_services. """ + + self.assertEqual( + remote.get_services(self.api), self.hass.services.services) + + def test_call_service(self): + """ Test Python API call_service. """ test_value = [] def listener(service_call): # pylint: disable=unused-argument - """ Helper method that will verify our service got called. """ + """ Helper method that will verify that our service got called. """ test_value.append(1) self.hass.services.register("test_domain", "test_service", listener) - self.remote_sr.call_service("test_domain", "test_service") - - # Allow the service call to take place - time.sleep(1) - - self.assertEqual(len(test_value), 1) - - # pylint: disable=invalid-name - def test_remote_sr_call_service_with_data(self): - """ Test if the remote bus allows us to fire an event. """ - test_value = [] - - def listener(service_call): # pylint: disable=unused-argument - """ Helper method that will verify our service got called. """ - if service_call.data["test"] == 1: - test_value.append(1) - - self.hass.services.register("test_domain", "test_service", listener) - - self.remote_sr.call_service("test_domain", "test_service", {"test": 1}) + remote.call_service(self.api, "test_domain", "test_service") # Allow the event to take place time.sleep(1) self.assertEqual(len(test_value), 1) - def test_local_sm_with_remote_eb(self): - """ Test if we get the event if we change a state on a - StateMachine connected to a remote bus. """ + +class TestRemoteClasses(unittest.TestCase): + """ Test the homeassistant.remote module. """ + + @classmethod + def setUpClass(cls): # pylint: disable=invalid-name + """ things to be run when tests are started. """ + cls.hass = ensure_homeassistant_started() + cls.slave = ensure_slave_started() + + def test_statemachine_init(self): + """ Tests if remote.StateMachine copies all states on init. """ + self.assertEqual(self.hass.states.all(), self.slave.states.all()) + + def test_statemachine_set(self): + """ Tests if setting the state on a slave is recorded. """ + self.slave.states.set("test", "remote.statemachine test") + + # Allow interaction between 2 instances + time.sleep(1) + + self.assertEqual(self.slave.states.get("test").state, + "remote.statemachine test") + + def test_eventbus_fire(self): + """ Test if events fired from the eventbus get fired. """ test_value = [] def listener(event): # pylint: disable=unused-argument """ Helper method that will verify our event got called. """ test_value.append(1) - self.hass.listen_once_event(ha.EVENT_STATE_CHANGED, listener) + self.slave.listen_once_event("test.event_no_data", listener) - self.sm_with_remote_eb.set("test", "local sm with remote eb") + self.slave.bus.fire("test.event_no_data") # Allow the event to take place time.sleep(1) diff --git a/start.py b/start.py index eeb29a27a12..527110856df 100644 --- a/start.py +++ b/start.py @@ -3,4 +3,6 @@ import homeassistant import homeassistant.bootstrap -homeassistant.bootstrap.from_config_file("home-assistant.conf").start() +hass = homeassistant.bootstrap.from_config_file("home-assistant.conf") +hass.start() +hass.block_till_stopped()