From 8ac8700154be508c34c6ba37148b5a1bc4f2a711 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 Sep 2013 11:09:36 -0700 Subject: [PATCH] Added API --- home-assistant.conf.default | 1 + homeassistant/__init__.py | 4 +- homeassistant/core.py | 19 +++- homeassistant/httpinterface.py | 165 +++++++++++++++++++++++++++++---- homeassistant/test.py | 162 ++++++++++++++++++++++++++++++++ run_tests | 2 + start.py | 2 +- 7 files changed, 331 insertions(+), 24 deletions(-) create mode 100644 homeassistant/test.py create mode 100755 run_tests diff --git a/home-assistant.conf.default b/home-assistant.conf.default index 0d3cd27b442..21090ddf77d 100644 --- a/home-assistant.conf.default +++ b/home-assistant.conf.default @@ -1,6 +1,7 @@ [common] latitude=32.87336 longitude=-117.22743 +api_password=mypass [tomato] host=192.168.1.1 diff --git a/homeassistant/__init__.py b/homeassistant/__init__.py index 141f08d2be9..28a1e22a6c7 100644 --- a/homeassistant/__init__.py +++ b/homeassistant/__init__.py @@ -40,11 +40,11 @@ class HomeAssistant(object): LightTrigger(self.eventbus, self.statemachine, self._setup_weather_watcher(), devicetracker, light_control) - def setup_http_interface(self): + def setup_http_interface(self, api_password): """ Sets up the HTTP interface. """ if self.httpinterface is None: self.logger.info("Setting up HTTP interface") - self.httpinterface = HTTPInterface(self.eventbus, self.statemachine) + self.httpinterface = HTTPInterface(self.eventbus, self.statemachine, api_password) return self.httpinterface diff --git a/homeassistant/core.py b/homeassistant/core.py index 6c77994b802..03110c6ebec 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -121,9 +121,9 @@ class StateMachine(object): def set_state(self, category, new_state): """ Set the state of a category. """ - self.lock.acquire() + self._validate_category(category) - assert category in self.states, "Category does not exist: {}".format(category) + self.lock.acquire() old_state = self.states[category] @@ -136,16 +136,27 @@ class StateMachine(object): def is_state(self, category, state): """ Returns True if category is specified state. """ - assert category in self.states, "Category does not exist: {}".format(category) + self._validate_category(category) return self.get_state(category).state == state def get_state(self, category): """ Returns a tuple (state,last_changed) describing the state of the specified category. """ - assert category in self.states, "Category does not exist: {}".format(category) + self._validate_category(category) return self.states[category] def get_states(self): """ Returns a list of tuples (category, state, last_changed) sorted by category. """ return [(category, self.states[category].state, self.states[category].last_changed) for category in sorted(self.states.keys())] + + def _validate_category(self, category): + if category not in self.states: + raise CategoryDoesNotExistException("Category {} does not exist.".format(category)) + + +class HomeAssistantException(Exception): + """ General Home Assistant exception occured. """ + +class CategoryDoesNotExistException(HomeAssistantException): + """ Specified category does not exist within the state machine. """ diff --git a/homeassistant/httpinterface.py b/homeassistant/httpinterface.py index 6394b4d670c..dc486190383 100644 --- a/homeassistant/httpinterface.py +++ b/homeassistant/httpinterface.py @@ -2,32 +2,59 @@ homeassistant.httpinterface ~~~~~~~~~~~~~~~~~~~~~~~~~~~ -This module provides an HTTP interface for debug purposes. +This module provides an API and a HTTP interface for debug purposes. + +By default it will run on port 8080. + +All API calls have to be accompanied by an 'api_password' parameter. + +The api supports the following actions: + +/api/state/change - POST +parameter: category - string +parameter: new_state - string +Changes category 'category' to 'new_state' + +/api/event/fire - POST +parameter: event_name - string +parameter: event_data - JSON-string (optional) +Fires an 'event_name' event containing data from 'event_data' """ +import json import threading -import urlparse import logging from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer +from urlparse import urlparse, parse_qs import requests -from .core import EVENT_START, EVENT_SHUTDOWN +from .core import EVENT_START, EVENT_SHUTDOWN, Event, CategoryDoesNotExistException -SERVER_HOST = '127.0.0.1' SERVER_PORT = 8080 +MESSAGE_STATUS_OK = "OK" +MESSAGE_STATUS_ERROR = "ERROR" +MESSAGE_STATUS_UNAUTHORIZED = "UNAUTHORIZED" + class HTTPInterface(threading.Thread): """ Provides an HTTP interface for Home Assistant. """ - def __init__(self, eventbus, statemachine): + def __init__(self, eventbus, statemachine, api_password, server_port=SERVER_PORT, server_host=None): threading.Thread.__init__(self) - self.server = HTTPServer((SERVER_HOST, SERVER_PORT), RequestHandler) + # If no server host is given, accept all incoming requests + if server_host is None: + server_host = '0.0.0.0' + self.server = HTTPServer((server_host, server_port), RequestHandler) + + self.server.flash_message = None + self.server.logger = logging.getLogger(__name__) self.server.eventbus = eventbus self.server.statemachine = statemachine + self.server.api_password = api_password self._stop = threading.Event() @@ -36,7 +63,7 @@ class HTTPInterface(threading.Thread): def run(self): """ Start the HTTP interface. """ - logging.getLogger(__name__).info("Starting") + self.server.logger.info("Starting") while not self._stop.is_set(): self.server.handle_request() @@ -47,7 +74,7 @@ class HTTPInterface(threading.Thread): self._stop.set() # Trigger a fake request to get the server to quit - requests.get("http://{}:{}".format(SERVER_HOST, SERVER_PORT)) + requests.get("http://127.0.0.1:{}".format(SERVER_PORT)) class RequestHandler(BaseHTTPRequestHandler): """ Handles incoming HTTP requests """ @@ -55,13 +82,40 @@ class RequestHandler(BaseHTTPRequestHandler): #Handler for the GET requests def do_GET(self): """ Handle incoming GET requests. """ + write = lambda txt: self.wfile.write(txt+"\n") - if self.path == "/": + url = urlparse(self.path) + + get_data = parse_qs(url.query) + + # Verify API password + if get_data.get('api_password', [''])[0] != self.server.api_password: self.send_response(200) self.send_header('Content-type','text/html') self.end_headers() - write = self.wfile.write + write("") + write("
") + write("API password: ") + write("") + write("
") + write("") + + + # Serve debug URL + elif url.path == "/": + self.send_response(200) + self.send_header('Content-type','text/html') + self.end_headers() + + + write("") + + # Flash message support + if self.server.flash_message is not None: + write("

{}

".format(self.server.flash_message)) + + self.server.flash_message = None # Describe state machine: categories = [] @@ -78,7 +132,8 @@ class RequestHandler(BaseHTTPRequestHandler): # Small form to change the state write("
Change state:
") - write("
") + write("") + write("".format(self.server.api_password)) write("".format(self.server.api_password)) + write("Event name:
") + write("Event data (json):
") + write("") + write("
") + + write("") + + else: self.send_response(404) @@ -102,14 +169,78 @@ class RequestHandler(BaseHTTPRequestHandler): """ Handle incoming POST requests. """ length = int(self.headers['Content-Length']) - post_data = urlparse.parse_qs(self.rfile.read(length)) + post_data = parse_qs(self.rfile.read(length)) - if self.path == "/change_state": - self.server.statemachine.set_state(post_data['category'][0], post_data['new_state'][0]) + if self.path.startswith('/api/'): + action = self.path[5:] + use_json = True + + else: + action = self.path[1:] + use_json = False + + self.server.logger.info(post_data) + self.server.logger.info(action) + + + # Verify API password + if post_data.get("api_password", [''])[0] != self.server.api_password: + self._message(use_json, "API password missing or incorrect.", MESSAGE_STATUS_UNAUTHORIZED) + + + # Action to change the state + elif action == "state/change": + category, new_state = post_data['category'][0], post_data['new_state'][0] + + try: + self.server.statemachine.set_state(category, new_state) + + self._message(use_json, "State of {} changed to {}.".format(category, new_state)) + + except CategoryDoesNotExistException: + self._message(use_json, "Category does not exist.", MESSAGE_STATUS_ERROR) + + # Action to fire an event + elif action == "event/fire": + try: + event_name = post_data['event_name'][0] + event_data = None if 'event_data' not in post_data or post_data['event_data'][0] == "" else json.loads(post_data['event_data'][0]) + + self.server.eventbus.fire(Event(event_name, event_data)) + + self._message(use_json, "Event {} fired.".format(event_name)) + + except ValueError: + # If JSON decode error + self._message(use_json, "Invalid event received.", MESSAGE_STATUS_ERROR) - self.send_response(301) - self.send_header("Location", "/") - self.end_headers() else: self.send_response(404) + + + def _message(self, use_json, message, status=MESSAGE_STATUS_OK): + """ Helper method to show a message to the user. """ + log_message = "{}: {}".format(status, message) + + if status == MESSAGE_STATUS_OK: + self.server.logger.info(log_message) + response_code = 200 + + else: + self.server.logger.error(log_message) + response_code = 401 if status == MESSAGE_STATUS_UNAUTHORIZED else 400 + + if use_json: + self.send_response(response_code) + self.send_header('Content-type','application/json' if use_json else 'text/html') + self.end_headers() + + self.wfile.write(json.dumps({'status': status, 'message':message})) + + else: + self.server.flash_message = message + + self.send_response(301) + self.send_header("Location", "/?api_password={}".format(self.server.api_password)) + self.end_headers() diff --git a/homeassistant/test.py b/homeassistant/test.py new file mode 100644 index 00000000000..aa05ece11f5 --- /dev/null +++ b/homeassistant/test.py @@ -0,0 +1,162 @@ +""" +homeassistant.test +~~~~~~~~~~~~~~~~~~ + +Provides tests to verify that Home Assistant modules do what they should do. + +""" + +import unittest +import time + +import requests + +from .core import EventBus, StateMachine, Event, EVENT_START, EVENT_SHUTDOWN +from .httpinterface import HTTPInterface, SERVER_PORT + + +API_PASSWORD = "test1234" + +HTTP_BASE_URL = "http://127.0.0.1:{}".format(SERVER_PORT) + + +class HomeAssistantTestCase(unittest.TestCase): + """ Base class for Home Assistant test cases. """ + + @classmethod + def setUpClass(cls): + cls.eventbus = EventBus() + cls.statemachine = StateMachine(cls.eventbus) + cls.init_ha = False + + @classmethod + def tearDownClass(cls): + cls.eventbus.fire(Event(EVENT_SHUTDOWN)) + + time.sleep(1) + + +class TestHTTPInterface(HomeAssistantTestCase): + """ Test the HTTP debug interface and API. """ + + HTTP_init = False + + def setUp(self): + """ Initialize the HTTP interface if not started yet. """ + if not TestHTTPInterface.HTTP_init: + TestHTTPInterface.HTTP_init = True + + HTTPInterface(self.eventbus, self.statemachine, API_PASSWORD) + + self.statemachine.add_category("test", "INIT_STATE") + + self.eventbus.fire(Event(EVENT_START)) + + # Give objects time to start up + time.sleep(1) + + + def test_debug_interface(self): + """ Test if we can login by comparing not logged in screen to logged in screen. """ + self.assertNotEqual(requests.get(HTTP_BASE_URL).text, + requests.get("{}/?api_password={}".format(HTTP_BASE_URL, API_PASSWORD)).text) + + + def test_debug_state_change(self): + """ Test if the debug interface allows us to change a state. """ + requests.post("{}/state/change".format(HTTP_BASE_URL), data={"category":"test", + "new_state":"debug_state_change", + "api_password":API_PASSWORD}) + + self.assertEqual(self.statemachine.get_state("test").state, "debug_state_change") + + + def test_api_password(self): + """ Test if we get access denied if we omit or provide a wrong api password. """ + req = requests.post("{}/api/state/change".format(HTTP_BASE_URL)) + + self.assertEqual(req.status_code, 401) + + req = requests.post("{}/api/state/change".format(HTTP_BASE_URL, data={"api_password":"not the password"})) + + self.assertEqual(req.status_code, 401) + + + def test_api_state_change(self): + """ Test if the API allows us to change a state. """ + requests.post("{}/api/state/change".format(HTTP_BASE_URL), data={"category":"test", + "new_state":"debug_state_change2", + "api_password":API_PASSWORD}) + + self.assertEqual(self.statemachine.get_state("test").state, "debug_state_change2") + + def test_api_state_change_of_non_existing_category(self): + """ Test if the API allows us to change a state of a non existing category. """ + req = requests.post("{}/api/state/change".format(HTTP_BASE_URL), data={"category":"test_category_that_does_not_exist", + "new_state":"debug_state_change", + "api_password":API_PASSWORD}) + + self.assertEqual(req.status_code, 400) + + def test_api_fire_event_with_no_data(self): + """ Test if the API allows us to fire an event. """ + test_value = [] + + def listener(event): + """ Helper method that will verify our event got called. """ + test_value.append(1) + + self.eventbus.listen("test_event_no_data", listener) + + requests.post("{}/api/event/fire".format(HTTP_BASE_URL), data={"event_name":"test_event_no_data", + "event_data":"", + "api_password":API_PASSWORD}) + + # Allow the event to take place + time.sleep(1) + + self.assertEqual(len(test_value), 1) + + + def test_api_fire_event_with_data(self): + """ Test if the API allows us to fire an event. """ + test_value = [] + + def listener(event): + """ Helper method that will verify that our event got called and + that test if our data came through. """ + if "test" in event.data: + test_value.append(1) + + self.eventbus.listen("test_event_with_data", listener) + + requests.post("{}/api/event/fire".format(HTTP_BASE_URL), data={"event_name":"test_event_with_data", + "event_data":'{"test": 1}', + "api_password":API_PASSWORD}) + + # Allow the event to take place + time.sleep(1) + + self.assertEqual(len(test_value), 1) + + + def test_api_fire_event_with_invalid_json(self): + """ Test if the API allows us to fire an event. """ + test_value = [] + + def listener(event): + """ Helper method that will verify our event got called. """ + test_value.append(1) + + self.eventbus.listen("test_event_with_bad_data", listener) + + req = requests.post("{}/api/event/fire".format(HTTP_BASE_URL), data={"event_name":"test_event_with_bad_data", + "event_data":'not json', + "api_password":API_PASSWORD}) + + + # It shouldn't but if it fires, allow the event to take place + time.sleep(1) + + self.assertEqual(req.status_code, 400) + self.assertEqual(len(test_value), 0) diff --git a/run_tests b/run_tests new file mode 100755 index 00000000000..74c7159154c --- /dev/null +++ b/run_tests @@ -0,0 +1,2 @@ +python -B -m unittest homeassistant.test + \ No newline at end of file diff --git a/start.py b/start.py index 79ff571cc1d..d5184f19375 100644 --- a/start.py +++ b/start.py @@ -16,6 +16,6 @@ ha = HomeAssistant(config.get("common","latitude"), config.get("common","longitu ha.setup_light_trigger(tomato, HueLightControl()) -ha.setup_http_interface() +ha.setup_http_interface(config.get("common","api_password")) ha.start()