diff --git a/README.md b/README.md index 9243c29f4f993667ba2601a6d35185868ec6c272..addc012804460db4828954c5cc689b01aee5d029 100644 --- a/README.md +++ b/README.md @@ -34,47 +34,54 @@ A screenshot of the debug interface (battery and charging states are controlled To interface with the API requests should include the parameter api_password which matches the api_password in home-assistant.conf. -The following API commands are currently supported: - - /api/state/categories - POST - parameter: api_password - string - Will list all the categories for which a state is currently tracked. Returns a json object like this: - - ```json - {"status": "OK", - "message":"State categories", - "categories": ["all_devices", "Paulus_Nexus_4"]} - ``` - - /api/state/get - POST - parameter: api_password - string - parameter: category - string - Will get the current state of a category. Returns a json object like this: - - ```json - {"status": "OK", - "message": "State of all_devices", - "category": "all_devices", - "state": "device_home", - "last_changed": "19:10:39 25-10-2013", - "attributes": {}} - ``` - - /api/state/change - POST - parameter: api_password - string - parameter: category - string - parameter: new_state - string - parameter: attributes - object encoded as JSON string (optional) - Changes category 'category' to 'new_state' - It is possible to sent multiple values for category and new_state. - If the number of values for category and new_state do not match only - combinations where both values are supplied will be set. - - /api/event/fire - POST - parameter: api_password - string - parameter: event_name - string - parameter: event_data - object encoded as JSON string (optional) - Fires an 'event_name' event containing data from 'event_data' +All API calls have to be accompanied by an 'api_password' parameter and will +return JSON. 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 categories for which a state is available +Example result: +```json{ + "categories": [ + "Paulus_Nexus_4", + "weather.sun", + "all_devices" + ] +}``` + +`/api/states/<category>` - GET +Returns the current state from a category +Example result: +```json{ + "attributes": { + "next_rising": "07:04:15 29-10-2013", + "next_setting": "18:00:31 29-10-2013" + }, + "category": "weather.sun", + "last_changed": "23:24:33 28-10-2013", + "state": "below_horizon" +}``` + +`/api/states/<category>` - POST +Updates the current state of a category. Returns status code 201 if successful +with location header of updated resource. +parameter: new_state - string +optional parameter: attributes - JSON encoded object + +`/api/events/<event_type>` - POST +Fires an event with event_type +optional parameter: event_data - JSON encoded object +Example result: +```json{ + "message": "Event download_file fired." +}``` Android remote control ---------------------- diff --git a/android-tasker/Home_Assistant.apk b/android-tasker/Home_Assistant.apk index 9f75e86569e92be7575400784529d87cd3fb408f..9e3924aa8f2fc42f16aad271b0675be0de965d1d 100644 Binary files a/android-tasker/Home_Assistant.apk and b/android-tasker/Home_Assistant.apk differ diff --git a/android-tasker/Home_Assistant.prj.xml b/android-tasker/Home_Assistant.prj.xml index 4b789d07dd9afdc8ec3300dbe28814cf41b47505..5a2ff8e8a575fe2c7acdc308a32212c9cf364fad 100644 --- a/android-tasker/Home_Assistant.prj.xml +++ b/android-tasker/Home_Assistant.prj.xml @@ -1,7 +1,8 @@ <TaskerData sr="" dvi="1" tv="4.1u3m"> <Profile sr="prof24" ve="2"> <cdate>1381116787665</cdate> - <edate>1381116787665</edate> + <clp>true</clp> + <edate>1382062270688</edate> <id>24</id> <mid0>20</mid0> <Event sr="con0" ve="2"> @@ -11,8 +12,7 @@ </Profile> <Profile sr="prof25" ve="2"> <cdate>1380613730755</cdate> - <clp>true</clp> - <edate>1381001553706</edate> + <edate>1382769497429</edate> <id>25</id> <mid0>23</mid0> <mid1>20</mid1> @@ -26,7 +26,7 @@ <Profile sr="prof26" ve="2"> <cdate>1380613730755</cdate> <clp>true</clp> - <edate>1381110280839</edate> + <edate>1383003483161</edate> <id>26</id> <mid0>22</mid0> <mid1>20</mid1> @@ -37,13 +37,27 @@ <Int sr="arg0" val="3"/> </State> </Profile> + <Profile sr="prof3" ve="2"> + <cdate>1380613730755</cdate> + <clp>true</clp> + <edate>1383003498566</edate> + <id>3</id> + <mid0>10</mid0> + <mid1>20</mid1> + <nme>HA Power AC</nme> + <pri>10</pri> + <State sr="con0"> + <code>10</code> + <Int sr="arg0" val="1"/> + </State> + </Profile> <Profile sr="prof5" ve="2"> <cdate>1380496514959</cdate> <cldm>1500</cldm> <clp>true</clp> - <edate>1381110261999</edate> + <edate>1382769618501</edate> <id>5</id> - <mid0>7</mid0> + <mid0>19</mid0> <nme>HA Battery Changed</nme> <Event sr="con0" ve="2"> <code>203</code> @@ -53,14 +67,14 @@ <Project sr="proj0"> <cdate>1381110247781</cdate> <name>Home Assistant</name> - <pids>24,26,5,25</pids> + <pids>5,3,25,26,24</pids> <scenes>Variable Query,Home Assistant Start</scenes> - <tids>14,16,4,15,7,20,6,8,22,23,9,11,12,13</tids> + <tids>19,8,10,6,16,9,20,14,11,4,23,15,12,13,22</tids> <Kid sr="Kid"> <launchID>12</launchID> <pkg>nl.paulus.homeassistant</pkg> - <vnme>1.0</vnme> - <vnum>10</vnum> + <vnme>1.1</vnme> + <vnum>14</vnum> </Kid> <Img sr="icon" ve="2"> <nme>cust_animal_penguin</nme> @@ -69,7 +83,7 @@ <Scene sr="sceneHome Assistant Start"> <backColour>-637534208</backColour> <cdate>1381113309678</cdate> - <edate>1381118413367</edate> + <edate>1381162068611</edate> <heightLand>-1</heightLand> <heightPort>688</heightPort> <nme>Home Assistant Start</nme> @@ -308,9 +322,24 @@ <Int sr="arg2" val="255"/> </ImageElement> </Scene> + <Task sr="task10"> + <cdate>1380613530339</cdate> + <edate>1383030846230</edate> + <id>10</id> + <nme>Charging AC</nme> + <Action sr="act0" ve="3"> + <code>130</code> + <Str sr="arg0" ve="3">Update Charging</Str> + <Int sr="arg1" val="0"/> + <Int sr="arg2" val="5"/> + <Str sr="arg3" ve="3">ac</Str> + <Str sr="arg4" ve="3"/> + <Str sr="arg5" ve="3"/> + </Action> + </Task> <Task sr="task11"> <cdate>1381110672417</cdate> - <edate>1381116046765</edate> + <edate>1383030844501</edate> <id>11</id> <nme>Open Debug Interface</nme> <pri>10</pri> @@ -321,7 +350,7 @@ </Task> <Task sr="task12"> <cdate>1381113015963</cdate> - <edate>1381116866174</edate> + <edate>1383030888271</edate> <id>12</id> <nme>Start Screen</nme> <pri>10</pri> @@ -338,6 +367,9 @@ <code>49</code> <Str sr="arg0" ve="3">Home Assistant Start</Str> </Action> + <Img sr="icn" ve="2"> + <nme>hd_aaa_ext_tiles_small</nme> + </Img> </Task> <Task sr="task13"> <cdate>1381114398467</cdate> @@ -354,16 +386,15 @@ </Task> <Task sr="task14"> <cdate>1381114829583</cdate> - <edate>1381115098684</edate> + <edate>1383030731979</edate> <id>14</id> <nme>API Fire Event</nme> <pri>10</pri> <Action sr="act0" ve="3"> <code>116</code> <Str sr="arg0" ve="3">%HA_HOST:%HA_PORT</Str> - <Str sr="arg1" ve="3">/api/event/fire</Str> - <Str sr="arg2" ve="3">api_password=%HA_API_PASSWORD -event_name=%par1</Str> + <Str sr="arg1" ve="3">/api/events/%par1</Str> + <Str sr="arg2" ve="3">api_password=%HA_API_PASSWORD</Str> <Str sr="arg3" ve="3"/> <Int sr="arg4" val="10"/> <Str sr="arg5" ve="3"/> @@ -372,7 +403,7 @@ event_name=%par1</Str> </Task> <Task sr="task15"> <cdate>1380262442154</cdate> - <edate>1381115642332</edate> + <edate>1383030894445</edate> <id>15</id> <nme>Light On</nme> <pri>10</pri> @@ -391,7 +422,7 @@ event_name=%par1</Str> </Task> <Task sr="task16"> <cdate>1380262442154</cdate> - <edate>1381115613658</edate> + <edate>1383030896170</edate> <id>16</id> <nme>Start Epic Sax</nme> <pri>10</pri> @@ -408,9 +439,29 @@ event_name=%par1</Str> <nme>hd_aaa_ext_guitar</nme> </Img> </Task> + <Task sr="task19"> + <cdate>1380262442154</cdate> + <edate>1383030903842</edate> + <id>19</id> + <nme>Update Battery</nme> + <pri>10</pri> + <Action sr="act0" ve="3"> + <code>116</code> + <Str sr="arg0" ve="3">%HA_HOST:%HA_PORT</Str> + <Str sr="arg1" ve="3">/api/state/change</Str> + <Str sr="arg2" ve="3">api_password=%HA_API_PASSWORD +category=%HA_DEVICE_NAME.charging +new_state=%HA_CHARGING +attributes={"battery":%BATT}</Str> + <Str sr="arg3" ve="3"/> + <Int sr="arg4" val="10"/> + <Str sr="arg5" ve="3"/> + <Str sr="arg6" ve="3"/> + </Action> + </Task> <Task sr="task20"> <cdate>1380613530339</cdate> - <edate>1381116102459</edate> + <edate>1383030848142</edate> <id>20</id> <nme>Charging None</nme> <Action sr="act0" ve="3"> @@ -425,7 +476,7 @@ event_name=%par1</Str> </Task> <Task sr="task22"> <cdate>1380613530339</cdate> - <edate>1381116000403</edate> + <edate>1383030909347</edate> <id>22</id> <nme>Charging Wireless</nme> <Action sr="act0" ve="3"> @@ -440,7 +491,7 @@ event_name=%par1</Str> </Task> <Task sr="task23"> <cdate>1380613530339</cdate> - <edate>1381115997137</edate> + <edate>1383030849758</edate> <id>23</id> <nme>Charging USB</nme> <Action sr="act0" ve="3"> @@ -455,7 +506,7 @@ event_name=%par1</Str> </Task> <Task sr="task4"> <cdate>1380262442154</cdate> - <edate>1381115633261</edate> + <edate>1383030892718</edate> <id>4</id> <nme>Light Off</nme> <pri>10</pri> @@ -474,7 +525,7 @@ event_name=%par1</Str> </Task> <Task sr="task6"> <cdate>1380522560890</cdate> - <edate>1381117976853</edate> + <edate>1383030900554</edate> <id>6</id> <nme>Setup</nme> <pri>10</pri> @@ -580,28 +631,9 @@ event_name=%par1</Str> <nme>hd_ab_action_settings</nme> </Img> </Task> - <Task sr="task7"> - <cdate>1380262442154</cdate> - <edate>1381111978825</edate> - <id>7</id> - <nme>Update Battery</nme> - <pri>10</pri> - <Action sr="act0" ve="3"> - <code>116</code> - <Str sr="arg0" ve="3">%HA_HOST:%HA_PORT</Str> - <Str sr="arg1" ve="3">/api/state/change</Str> - <Str sr="arg2" ve="3">api_password=%HA_API_PASSWORD -category=%HA_DEVICE_NAME.battery -new_state=%BATT</Str> - <Str sr="arg3" ve="3"/> - <Int sr="arg4" val="10"/> - <Str sr="arg5" ve="3"/> - <Str sr="arg6" ve="3"/> - </Action> - </Task> <Task sr="task8"> <cdate>1380262442154</cdate> - <edate>1381115955507</edate> + <edate>1383030906782</edate> <id>8</id> <nme>Update Charging</nme> <pri>10</pri> @@ -613,23 +645,18 @@ new_state=%BATT</Str> <Int sr="arg3" val="0"/> </Action> <Action sr="act1" ve="3"> - <code>116</code> - <Str sr="arg0" ve="3">%HA_HOST:%HA_PORT</Str> - <Str sr="arg1" ve="3">/api/state/change</Str> - <Str sr="arg2" ve="3">api_password=%HA_API_PASSWORD -category=%HA_DEVICE_NAME.charging -new_state=%HA_CHARGING -category=%HA_DEVICE_NAME.battery -new_state=%BATT</Str> + <code>130</code> + <Str sr="arg0" ve="3">Update Battery</Str> + <Int sr="arg1" val="0"/> + <Int sr="arg2" val="5"/> <Str sr="arg3" ve="3"/> - <Int sr="arg4" val="10"/> + <Str sr="arg4" ve="3"/> <Str sr="arg5" ve="3"/> - <Str sr="arg6" ve="3"/> </Action> </Task> <Task sr="task9"> <cdate>1380262442154</cdate> - <edate>1381115659673</edate> + <edate>1383030890674</edate> <id>9</id> <nme>Start Fireplace</nme> <pri>10</pri> diff --git a/docs/screenshot-debug-interface.png b/docs/screenshot-debug-interface.png index 35b0dd86ff831efd8573c59bf24634b7033a24f3..631e080c877bd20fc12a2cdfa56d0950173f673b 100644 Binary files a/docs/screenshot-debug-interface.png and b/docs/screenshot-debug-interface.png differ diff --git a/homeassistant/httpinterface.py b/homeassistant/httpinterface.py index 68a1e3086b0cac9af1d396015198377d4c916504..b2563c00dd282d17e00ab9df60ba21ae77009fac 100644 --- a/homeassistant/httpinterface.py +++ b/homeassistant/httpinterface.py @@ -4,31 +4,63 @@ homeassistant.httpinterface This module provides an API and a HTTP interface for debug purposes. -By default it will run on port 8080. +By default it will run on port 8123. -All API calls have to be accompanied by an 'api_password' parameter. +All API calls have to be accompanied by an 'api_password' parameter and will +return JSON. 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/state/change - POST -parameter: category - string +/api/states - GET +Returns a list of categories for which a state is available +Example result: +{ + "categories": [ + "Paulus_Nexus_4", + "weather.sun", + "all_devices" + ] +} + +/api/states/<category> - GET +Returns the current state from a category +Example result: +{ + "attributes": { + "next_rising": "07:04:15 29-10-2013", + "next_setting": "18:00:31 29-10-2013" + }, + "category": "weather.sun", + "last_changed": "23:24:33 28-10-2013", + "state": "below_horizon" +} + +/api/states/<category> - POST +Updates the current state of a category. Returns status code 201 if successful +with location header of updated resource. parameter: new_state - string -Changes category 'category' to 'new_state' -It is possible to sent multiple values for category and new_state. -If the number of values for category and new_state do not match only -combinations where both values are supplied will be set. +optional parameter: attributes - JSON encoded object -/api/event/fire - POST -parameter: event_name - string -parameter: event_data - JSON-string (optional) -Fires an 'event_name' event containing data from 'event_data' +/api/events/<event_type> - POST +Fires an event with event_type +optional parameter: event_data - JSON encoded object +Example result: +{ + "message": "Event download_file fired." +} """ import json import threading -import itertools import logging +import re from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer from urlparse import urlparse, parse_qs @@ -36,9 +68,24 @@ import homeassistant as ha SERVER_PORT = 8123 -MESSAGE_STATUS_OK = "OK" -MESSAGE_STATUS_ERROR = "ERROR" -MESSAGE_STATUS_UNAUTHORIZED = "UNAUTHORIZED" +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 + +URL_ROOT = "/" + +URL_STATES_CATEGORY = "/states/{}" +URL_API_STATES = "/api/states" +URL_API_STATES_CATEGORY = "/api/states/{}" + +URL_EVENTS_EVENT = "/events/{}" +URL_API_EVENTS = "/api/events" +URL_API_EVENTS_EVENT = "/api/events/{}" + class HTTPInterface(threading.Thread): """ Provides an HTTP interface for Home Assistant. """ @@ -76,277 +123,278 @@ class HTTPInterface(threading.Thread): class RequestHandler(BaseHTTPRequestHandler): """ Handles incoming HTTP requests """ - #Handler for the GET requests - def do_GET(self): # pylint: disable=invalid-name - """ Handle incoming GET requests. """ - write = lambda txt: self.wfile.write(txt+"\n") + PATHS = [ ('GET', '/', '_handle_get_root'), - url = urlparse(self.path) + # /states + ('GET', '/states', '_handle_get_states'), + ('GET', re.compile(r'/states/(?P<category>[a-zA-Z\.\_0-9]+)'), + '_handle_get_states_category'), + ('POST', re.compile(r'/states/(?P<category>[a-zA-Z\.\_0-9]+)'), + '_handle_post_states_category'), - get_data = parse_qs(url.query) + # /events + ('POST', re.compile(r'/events/(?P<event_type>\w+)'), + '_handle_post_events_event_type') + ] - api_password = get_data.get('api_password', [''])[0] - - if url.path == "/": - if self._verify_api_password(api_password, False): - self.send_response(200) - self.send_header('Content-type','text/html') - self.end_headers() - - - write(("<html>" - "<head><title>Home Assistant</title></head>" - "<body>")) + def _handle_request(self, method): # pylint: disable=too-many-branches + """ Does some common checks and calls appropriate method. """ + url = urlparse(self.path) - # Flash message support - if self.server.flash_message: - write("<h3>{}</h3>".format(self.server.flash_message)) + # Read query input + data = parse_qs(url.query) - self.server.flash_message = None + # Did we get post input ? + content_length = int(self.headers.get('Content-Length', 0)) - # Describe state machine: - categories = [] + if content_length: + data.update(parse_qs(self.rfile.read(content_length))) - write(("<table><tr>" - "<th>Name</th><th>State</th>" - "<th>Last Changed</th><th>Attributes</th></tr>")) + try: + api_password = data['api_password'][0] + except KeyError: + api_password = '' - for category in \ - sorted(self.server.statemachine.categories, - key=lambda key: key.lower()): + # We respond to API requests with JSON + # For other requests we respond with html + if url.path.startswith('/api/'): + path = url.path[4:] + # pylint: disable=attribute-defined-outside-init + self.use_json = True - categories.append(category) + else: + path = url.path + # pylint: disable=attribute-defined-outside-init + self.use_json = False - state = self.server.statemachine.get_state(category) - attributes = "<br>".join( - ["{}: {}".format(attr, state['attributes'][attr]) - for attr in state['attributes']]) + path_matched_but_not_method = False + handle_request_method = False - write(("<tr>" - "<td>{}</td><td>{}</td><td>{}</td><td>{}</td>" - "</tr>"). - format(category, - state['state'], - state['last_changed'], - attributes)) + # Check every url to find matching result + for t_method, t_path, t_handler in RequestHandler.PATHS: - write("</table>") + # we either do string-comparison or regular expression matching + if isinstance(t_path, str): + path_match = path == t_path + else: + path_match = t_path.match(path) #pylint:disable=maybe-no-member - # Small form to change the state - write(("<br />Change state:<br />" - "<form action='state/change' method='POST'>")) - write("<input type='hidden' name='api_password' value='{}' />". - format(self.server.api_password)) + if path_match and method == t_method: + # Call the method + handle_request_method = getattr(self, t_handler) + break - write("<select name='category'>") + elif path_match: + path_matched_but_not_method = True - for category in categories: - write("<option>{}</option>".format(category)) - write("</select>") + if handle_request_method: - write(("<input name='new_state' />" - "<input type='submit' value='set state' />" - "</form>")) + if self._verify_api_password(api_password): + handle_request_method(path_match, data) - # Describe event bus: - write(("<table><tr><th>Event</th><th>Listeners</th></tr>")) + elif path_matched_but_not_method: + self.send_response(HTTP_METHOD_NOT_ALLOWED) - for category in sorted(self.server.eventbus.listeners, - key=lambda key: key.lower()): - write("<tr><td>{}</td><td>{}</td></tr>". - format(category, - len(self.server.eventbus.listeners[category]))) + else: + self.send_response(HTTP_NOT_FOUND) - # Form to allow firing events - write(("</table><br />" - "<form action='event/fire' method='POST'>")) - write("<input type='hidden' name='api_password' value='{}' />". - format(self.server.api_password)) + def do_GET(self): # pylint: disable=invalid-name + """ GET request handler. """ + self._handle_request('GET') - write(("Event name: <input name='event_name' /><br />" - "Event data (json): <input name='event_data' /><br />" - "<input type='submit' value='fire event' />" - "</form>")) + def do_POST(self): # pylint: disable=invalid-name + """ POST request handler. """ + self._handle_request('POST') - write("</body></html>") + def _verify_api_password(self, api_password): + """ Helper method to verify the API password + and take action if incorrect. """ + if api_password == self.server.api_password: + return True + elif self.use_json: + self._message("API password missing or incorrect.", + HTTP_UNAUTHORIZED) else: - self.send_response(404) + self.send_response(HTTP_OK) + self.send_header('Content-type','text/html') + self.end_headers() + + self.wfile.write(( + "<html>" + "<head><title>Home Assistant</title></head>" + "<body>" + "<form action='/' method='GET'>" + "API password: <input name='api_password' />" + "<input type='submit' value='submit' />" + "</form>" + "</body></html>")) - # pylint: disable=invalid-name, too-many-branches, too-many-statements - def do_POST(self): - """ Handle incoming POST requests. """ + return False - length = int(self.headers['Content-Length']) - post_data = parse_qs(self.rfile.read(length)) + # pylint: disable=unused-argument + def _handle_get_root(self, path_match, data): + """ Renders the debug interface. """ - if self.path.startswith('/api/'): - action = self.path[5:] - use_json = True + write = lambda txt: self.wfile.write(txt+"\n") - else: - action = self.path[1:] - use_json = False + self.send_response(HTTP_OK) + self.send_header('Content-type','text/html') + self.end_headers() - given_api_password = post_data.get("api_password", [''])[0] + write(("<html>" + "<head><title>Home Assistant</title></head>" + "<body>")) - # Action to change the state - if action == "state/categories": - if self._verify_api_password(given_api_password, use_json): - self._response(use_json, "State categories", - json_data= - {'categories': self.server.statemachine.categories}) + # Flash message support + if self.server.flash_message: + write("<h3>{}</h3>".format(self.server.flash_message)) - elif action == "state/get": - if self._verify_api_password(given_api_password, use_json): - try: - category = post_data['category'][0] + self.server.flash_message = None - state = self.server.statemachine.get_state(category) + # Describe state machine: + categories = [] - state['category'] = category + write(("<table><tr>" + "<th>Name</th><th>State</th>" + "<th>Last Changed</th><th>Attributes</th></tr>")) - self._response(use_json, "State of {}".format(category), - json_data=state) + for category in \ + sorted(self.server.statemachine.categories, + key=lambda key: key.lower()): + categories.append(category) - except KeyError: - # If category or new_state don't exist in post data - self._response(use_json, "Invalid state received.", - MESSAGE_STATUS_ERROR) + state = self.server.statemachine.get_state(category) - elif action == "state/change": - if self._verify_api_password(given_api_password, use_json): - try: - changed = [] + attributes = "<br>".join( + ["{}: {}".format(attr, state['attributes'][attr]) + for attr in state['attributes']]) - for idx, category, new_state in zip(itertools.count(), - post_data['category'], - post_data['new_state'] - ): + write(("<tr>" + "<td>{}</td><td>{}</td><td>{}</td><td>{}</td>" + "</tr>"). + format(category, + state['state'], + state['last_changed'], + attributes)) - # See if we also received attributes for this state - try: - attributes = json.loads( - post_data['attributes'][idx]) - except KeyError: - # Happens if key 'attributes' or idx does not exist - attributes = None + write("</table>") - self.server.statemachine.set_state(category, - new_state, - attributes) + # Describe event bus: + write(("<table><tr><th>Event</th><th>Listeners</th></tr>")) - changed.append("{}={}".format(category, new_state)) + for category in sorted(self.server.eventbus.listeners, + key=lambda key: key.lower()): + write("<tr><td>{}</td><td>{}</td></tr>". + format(category, + len(self.server.eventbus.listeners[category]))) - self._response(use_json, "States changed: {}". - format( ", ".join(changed) ) ) + # Form to allow firing events + write("</table>") - except KeyError: - # If category or new_state don't exist in post data - self._response(use_json, "Invalid parameters received.", - MESSAGE_STATUS_ERROR) + write("</body></html>") - except ValueError: - # If json.loads doesn't understand the attributes - self._response(use_json, "Invalid state data received.", - MESSAGE_STATUS_ERROR) + # pylint: disable=unused-argument + def _handle_get_states(self, path_match, data): + """ Returns the categories which state is being tracked. """ + self._write_json({'categories': self.server.statemachine.categories}) - # Action to fire an event - elif action == "event/fire": - if self._verify_api_password(given_api_password, use_json): - try: - event_name = post_data['event_name'][0] + # pylint: disable=unused-argument + def _handle_get_states_category(self, path_match, data): + """ Returns the state of a specific category. """ + try: + category = path_match.group('category') - if (not 'event_data' in post_data or - post_data['event_data'][0] == ""): + state = self.server.statemachine.get_state(category) - event_data = None + state['category'] = category - else: - event_data = json.loads(post_data['event_data'][0]) + self._write_json(state) - self.server.eventbus.fire(event_name, event_data) + except KeyError: + # If category or new_state don't exist in post data + self._message("Invalid state received.", HTTP_BAD_REQUEST) - self._response(use_json, "Event {} fired.". - format(event_name)) - except ValueError: - # If JSON decode error - self._response(use_json, "Invalid event received (1).", - MESSAGE_STATUS_ERROR) + def _handle_post_states_category(self, path_match, data): + """ Handles updating the state of a category. """ + try: + category = path_match.group('category') - except KeyError: - # If "event_name" not in post_data - self._response(use_json, "Invalid event received (2).", - MESSAGE_STATUS_ERROR) + new_state = data['new_state'][0] - else: - self.send_response(404) + try: + attributes = json.loads(data['attributes'][0]) + except KeyError: + # Happens if key 'attributes' does not exist + attributes = None + self.server.statemachine.set_state(category, + new_state, + attributes) - def _verify_api_password(self, api_password, use_json): - """ Helper method to verify the API password - and take action if incorrect. """ - if api_password == self.server.api_password: - return True + self._redirect("/states/{}".format(category), + "State changed: {}={}".format(category, new_state), + HTTP_CREATED) - elif use_json: - self._response(True, "API password missing or incorrect.", - MESSAGE_STATUS_UNAUTHORIZED) + except KeyError: + # If category or new_state don't exist in post data + self._message("Invalid parameters received.", + HTTP_BAD_REQUEST) - else: - self.send_response(200) - self.send_header('Content-type','text/html') - self.end_headers() + except ValueError: + # Occurs during error parsing json + self._message("Invalid JSON for attributes", HTTP_BAD_REQUEST) - write = lambda txt: self.wfile.write(txt+"\n") + def _handle_post_events_event_type(self, path_match, data): + """ Handles firing of an event. """ + event_type = path_match.group('event_type') - write(("<html>" - "<head><title>Home Assistant</title></head>" - "<body>" - "<form action='/' method='GET'>" - "API password: <input name='api_password' />" - "<input type='submit' value='submit' />" - "</form>" - "</body></html>")) + try: + try: + event_data = json.loads(data['event_data'][0]) + except KeyError: + # Happens if key 'event_data' does not exist + event_data = None - return False + self.server.eventbus.fire(event_type, event_data) - def _response(self, use_json, message, - status=MESSAGE_STATUS_OK, json_data=None): - """ Helper method to show a message to the user. """ - log_message = "{}: {}".format(status, message) + self._message("Event {} fired.".format(event_type)) - if status == MESSAGE_STATUS_OK: - self.server.logger.info(log_message) - response_code = 200 + except ValueError: + # Occurs during error parsing json + self._message("Invalid JSON for event_data", HTTP_BAD_REQUEST) + def _message(self, message, status_code=HTTP_OK): + """ Helper method to return a message to the caller. """ + if self.use_json: + self._write_json({'message': message}, status_code=status_code) else: - self.server.logger.error(log_message) - response_code = (401 if status == MESSAGE_STATUS_UNAUTHORIZED - else 400) + self._redirect('/', message) - if use_json: - self.send_response(response_code) - self.send_header('Content-type','application/json') - self.end_headers() - - json_data = json_data or {} - json_data['status'] = status - json_data['message'] = message + def _redirect(self, location, message=None, + status_code=HTTP_MOVED_PERMANENTLY): + """ Helper method to redirect caller. """ + # Only save as flash message if we will go to debug interface next + if not self.use_json and message: + self.server.flash_message = message - self.wfile.write(json.dumps(json_data)) + self.send_response(status_code) + self.send_header("Location", "{}?api_password={}". + format(location, self.server.api_password)) + self.end_headers() - else: - self.server.flash_message = message + def _write_json(self, data=None, status_code=HTTP_OK): + """ Helper method to return JSON to the caller. """ + self.send_response(status_code) + self.send_header('Content-type','application/json') + self.end_headers() - self.send_response(301) - self.send_header("Location", "/?api_password={}". - format(self.server.api_password)) - self.end_headers() + if data: + self.wfile.write(json.dumps(data, indent=4, sort_keys=True)) diff --git a/homeassistant/remote.py b/homeassistant/remote.py index 93ab356d1317762d6633b988b8bac0eb383867f5..8e59f31c69a777c46a7a0f7932ef570cd845bdec 100644 --- a/homeassistant/remote.py +++ b/homeassistant/remote.py @@ -12,25 +12,33 @@ HomeAssistantException will be raised. import threading import logging import json +import urlparse import requests import homeassistant as ha -import homeassistant.httpinterface as httpinterface +import homeassistant.httpinterface as hah -def _setup_call_api(host, port, base_path, api_password): +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 httpinterface.SERVER_PORT + port = port or hah.SERVER_PORT - base_url = "http://{}:{}/api/{}".format(host, port, base_path) + base_url = "http://{}:{}".format(host, port) - def _call_api(action, data=None): + def _call_api(method, path, data=None): """ Makes a call to the Home Assistant api. """ data = data or {} - data['api_password'] = api_password - return requests.post(base_url + action, data=data) + url = urlparse.urljoin(base_url, path) + + if method == METHOD_GET: + return requests.get(url, params=data) + else: + return requests.request(method, url, data=data) return _call_api @@ -43,21 +51,19 @@ class EventBus(ha.EventBus): def __init__(self, host, api_password, port=None): ha.EventBus.__init__(self) - self._call_api = _setup_call_api(host, port, "event/", api_password) + self._call_api = _setup_call_api(host, port, api_password) self.logger = logging.getLogger(__name__) def fire(self, event_type, event_data=None): """ Fire an event. """ - if not event_data: - event_data = {} - - data = {'event_name': event_type, - 'event_data': json.dumps(event_data)} + data = {'event_data': json.dumps(event_data)} if event_data else None try: - req = self._call_api("fire", data) + 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( @@ -66,7 +72,6 @@ class EventBus(ha.EventBus): self.logger.error("EventBus:{}".format(error)) raise ha.HomeAssistantException(error) - except requests.exceptions.ConnectionError: self.logger.exception("EventBus:Error connecting to server") @@ -91,7 +96,7 @@ class StateMachine(ha.StateMachine): def __init__(self, host, api_password, port=None): ha.StateMachine.__init__(self, None) - self._call_api = _setup_call_api(host, port, "state/", api_password) + self._call_api = _setup_call_api(host, port, api_password) self.lock = threading.Lock() self.logger = logging.getLogger(__name__) @@ -101,7 +106,7 @@ class StateMachine(ha.StateMachine): """ List of categories which states are being tracked. """ try: - req = self._call_api("categories") + req = self._call_api(METHOD_GET, hah.URL_API_STATES) return req.json()['categories'] @@ -126,14 +131,15 @@ class StateMachine(ha.StateMachine): self.lock.acquire() - data = {'category': category, - 'new_state': new_state, + data = {'new_state': new_state, 'attributes': json.dumps(attributes)} try: - req = self._call_api('change', data) + req = self._call_api(METHOD_POST, + hah.URL_API_STATES_CATEGORY.format(category), + data) - if req.status_code != 200: + if req.status_code != 201: error = "Error changing state: {} - {}".format( req.status_code, req.text) @@ -152,7 +158,8 @@ class StateMachine(ha.StateMachine): the state of the specified category. """ try: - req = self._call_api("get", {'category': category}) + req = self._call_api(METHOD_GET, + hah.URL_API_STATES_CATEGORY.format(category)) data = req.json() diff --git a/homeassistant/test.py b/homeassistant/test.py index 1c45e1114fd9e1d905f0d880bbea6e517b628838..0ca1650b5335baca1922785ba24e5fe11bdf31f6 100644 --- a/homeassistant/test.py +++ b/homeassistant/test.py @@ -13,13 +13,13 @@ import requests import homeassistant as ha import homeassistant.remote as remote -import homeassistant.httpinterface as httpinterface +import homeassistant.httpinterface as hah API_PASSWORD = "test1234" -HTTP_BASE_URL = "http://127.0.0.1:{}".format(httpinterface.SERVER_PORT) +HTTP_BASE_URL = "http://127.0.0.1:{}".format(hah.SERVER_PORT) # pylint: disable=too-many-public-methods class TestHTTPInterface(unittest.TestCase): @@ -27,13 +27,16 @@ class TestHTTPInterface(unittest.TestCase): HTTP_init = False + def _url(self, path=""): + """ Helper method to generate urls. """ + return HTTP_BASE_URL + path + def setUp(self): # pylint: disable=invalid-name """ Initialize the HTTP interface if not started yet. """ if not TestHTTPInterface.HTTP_init: TestHTTPInterface.HTTP_init = True - httpinterface.HTTPInterface(self.eventbus, self.statemachine, - API_PASSWORD) + hah.HTTPInterface(self.eventbus, self.statemachine, API_PASSWORD) self.statemachine.set_state("test", "INIT_STATE") self.sm_with_remote_eb.set_state("test", "INIT_STATE") @@ -55,17 +58,21 @@ class TestHTTPInterface(unittest.TestCase): 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) + + with_pw = requests.get( + self._url("/?api_password={}".format(API_PASSWORD))) + + without_pw = requests.get(self._url()) + + self.assertNotEqual(without_pw.text, with_pw.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}) + requests.post( + self._url(hah.URL_STATES_CATEGORY.format("test")), + data={"new_state":"debug_state_change", + "api_password":API_PASSWORD}) self.assertEqual(self.statemachine.get_state("test")['state'], "debug_state_change") @@ -74,19 +81,21 @@ class TestHTTPInterface(unittest.TestCase): 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)) + req = requests.post( + self._url(hah.URL_API_STATES_CATEGORY.format("test"))) self.assertEqual(req.status_code, 401) - req = requests.post("{}/api/state/change".format(HTTP_BASE_URL, - data={"api_password":"not the password"})) + req = requests.post( + self._url(hah.URL_API_STATES_CATEGORY.format("test")), + data={"api_password":"not the password"}) self.assertEqual(req.status_code, 401) def test_api_list_state_categories(self): """ Test if the debug interface allows us to list state categories. """ - req = requests.post("{}/api/state/categories".format(HTTP_BASE_URL), + req = requests.get(self._url(hah.URL_API_STATES), data={"api_password":API_PASSWORD}) data = req.json() @@ -96,16 +105,15 @@ class TestHTTPInterface(unittest.TestCase): def test_api_get_state(self): - """ Test if the debug interface allows us to list state categories. """ - req = requests.post("{}/api/state/get".format(HTTP_BASE_URL), - data={"api_password":API_PASSWORD, - "category": "test"}) + """ Test if the debug interface allows us to get a state. """ + req = requests.get( + self._url(hah.URL_API_STATES_CATEGORY.format("test")), + data={"api_password":API_PASSWORD}) data = req.json() state = self.statemachine.get_state("test") - self.assertEqual(data['category'], "test") self.assertEqual(data['state'], state['state']) self.assertEqual(data['last_changed'], state['last_changed']) @@ -117,9 +125,8 @@ class TestHTTPInterface(unittest.TestCase): self.statemachine.set_state("test", "not_to_be_set_state") - requests.post("{}/api/state/change".format(HTTP_BASE_URL), - data={"category":"test", - "new_state":"debug_state_change2", + requests.post(self._url(hah.URL_API_STATES_CATEGORY.format("test")), + data={"new_state":"debug_state_change2", "api_password":API_PASSWORD}) self.assertEqual(self.statemachine.get_state("test")['state'], @@ -156,22 +163,6 @@ class TestHTTPInterface(unittest.TestCase): self.assertEqual(state['attributes']['test'], 1) - def test_api_multiple_state_change(self): - """ Test if we can change multiple states in 1 request. """ - - self.statemachine.set_state("test", "not_to_be_set_state") - self.statemachine.set_state("test2", "not_to_be_set_state") - - requests.post("{}/api/state/change".format(HTTP_BASE_URL), - data={"category": ["test", "test2"], - "new_state": ["test_state_1", "test_state_2"], - "api_password":API_PASSWORD}) - - self.assertEqual(self.statemachine.get_state("test")['state'], - "test_state_1") - self.assertEqual(self.statemachine.get_state("test2")['state'], - "test_state_2") - # pylint: disable=invalid-name def test_api_state_change_of_non_existing_category(self): """ Test if the API allows us to change a state of @@ -179,15 +170,16 @@ class TestHTTPInterface(unittest.TestCase): new_state = "debug_state_change" - req = requests.post("{}/api/state/change".format(HTTP_BASE_URL), - data={"category":"test_category_that_does_not_exist", - "new_state":new_state, - "api_password":API_PASSWORD}) + req = requests.post( + self._url(hah.URL_API_STATES_CATEGORY.format( + "test_category_that_does_not_exist")), + data={"new_state": new_state, + "api_password": API_PASSWORD}) cur_state = (self.statemachine. - get_state("test_category_that_does_not_exist")['state']) + get_state("test_category_that_does_not_exist")['state']) - self.assertEqual(req.status_code, 200) + self.assertEqual(req.status_code, 201) self.assertEqual(cur_state, new_state) # pylint: disable=invalid-name @@ -201,10 +193,9 @@ class TestHTTPInterface(unittest.TestCase): self.eventbus.listen_once("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}) + requests.post( + self._url(hah.URL_EVENTS_EVENT.format("test_event_no_data")), + data={"api_password":API_PASSWORD}) # Allow the event to take place time.sleep(1) @@ -224,9 +215,9 @@ class TestHTTPInterface(unittest.TestCase): self.eventbus.listen_once("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}', + requests.post( + self._url(hah.URL_EVENTS_EVENT.format("test_event_with_data")), + data={"event_data":'{"test": 1}', "api_password":API_PASSWORD}) # Allow the event to take place @@ -235,28 +226,6 @@ class TestHTTPInterface(unittest.TestCase): self.assertEqual(len(test_value), 1) - # pylint: disable=invalid-name - def test_api_fire_event_with_no_params(self): - """ Test how the API respsonds when we specify no event attributes. """ - 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_once("test_event_with_data", listener) - - requests.post("{}/api/event/fire".format(HTTP_BASE_URL), - data={"api_password":API_PASSWORD}) - - # Allow the event to take place - time.sleep(1) - - self.assertEqual(len(test_value), 0) - - # pylint: disable=invalid-name def test_api_fire_event_with_invalid_json(self): """ Test if the API allows us to fire an event. """ @@ -268,9 +237,9 @@ class TestHTTPInterface(unittest.TestCase): self.eventbus.listen_once("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', + req = requests.post( + self._url(hah.URL_API_EVENTS_EVENT.format("test_event")), + data={"event_data":'not json', "api_password":API_PASSWORD})