You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

237 lines
8.0 KiB

  1. """CherryPy-based server for running NILM filters via HTTP"""
  2. import cherrypy
  3. import sys
  4. import os
  5. import socket
  6. import simplejson as json
  7. import decorator
  8. import psutil
  9. import traceback
  10. import argparse
  11. import nilmdb
  12. from nilmdb.utils.printf import *
  13. from nilmdb.server.serverutil import (
  14. chunked_response,
  15. response_type,
  16. workaround_cp_bug_1200,
  17. exception_to_httperror,
  18. CORS_allow,
  19. json_to_request_params,
  20. json_error_page,
  21. cherrypy_start,
  22. cherrypy_stop,
  23. )
  24. import nilmrun
  25. import nilmrun.trainola
  26. # Add CORS_allow tool
  27. cherrypy.tools.CORS_allow = cherrypy.Tool('on_start_resource', CORS_allow)
  28. # CherryPy apps
  29. class App(object):
  30. """Root application for NILM runner"""
  31. def __init__(self):
  32. pass
  33. # /
  34. @cherrypy.expose
  35. def index(self):
  36. cherrypy.response.headers['Content-Type'] = 'text/plain'
  37. msg = sprintf("This is NilmRun version %s, running on host %s.\n",
  38. nilmrun.__version__, socket.getfqdn())
  39. return msg
  40. # /favicon.ico
  41. @cherrypy.expose
  42. def favicon_ico(self):
  43. raise cherrypy.NotFound()
  44. # /version
  45. @cherrypy.expose
  46. @cherrypy.tools.json_out()
  47. def version(self):
  48. return nilmrun.__version__
  49. class AppProcess(object):
  50. def __init__(self, manager):
  51. self.manager = manager
  52. def process_status(self, pid):
  53. return {
  54. "pid": pid,
  55. "alive": self.manager[pid].alive,
  56. "error": self.manager[pid].error,
  57. "name": self.manager[pid].name,
  58. "start_time": self.manager[pid].start_time,
  59. "parameters": self.manager[pid].parameters,
  60. "log": self.manager[pid].log,
  61. }
  62. # /process/status
  63. @cherrypy.expose
  64. @cherrypy.tools.json_out()
  65. def status(self, pid, clear = False):
  66. if pid not in self.manager:
  67. raise cherrypy.HTTPError("404 Not Found", "No such PID")
  68. status = self.process_status(pid)
  69. if clear:
  70. self.manager[pid].clear_log()
  71. return status
  72. # /process/list
  73. @cherrypy.expose
  74. @cherrypy.tools.json_out()
  75. def list(self):
  76. return list(self.manager)
  77. # /process/kill
  78. @cherrypy.expose
  79. @cherrypy.tools.json_in()
  80. @cherrypy.tools.json_out()
  81. @cherrypy.tools.CORS_allow(methods = ["POST"])
  82. def kill(self, pid):
  83. if pid not in self.manager:
  84. raise cherrypy.HTTPError("404 Not Found", "No such PID")
  85. if not self.manager.terminate(pid):
  86. raise cherrypy.HTTPError("503 Service Unavailable",
  87. "Failed to stop process")
  88. status = self.process_status(pid)
  89. manager.remove(pid)
  90. class AppFilter(object):
  91. def __init__(self, manager):
  92. self.manager = manager
  93. # /filter/trainola
  94. @cherrypy.expose
  95. @cherrypy.tools.json_in()
  96. @cherrypy.tools.json_out()
  97. @exception_to_httperror(KeyError, ValueError)
  98. @cherrypy.tools.CORS_allow(methods = ["POST"])
  99. def trainola(self, data):
  100. return self.manager.run("trainola", nilmrun.trainola.trainola, data)
  101. class Server(object):
  102. def __init__(self, host = '127.0.0.1', port = 8080,
  103. embedded = True, # hide diagnostics and output, etc
  104. force_traceback = False, # include traceback in all errors
  105. basepath = '', # base URL path for cherrypy.tree
  106. ):
  107. self.embedded = embedded
  108. # Build up global server configuration
  109. cherrypy.config.update({
  110. 'server.socket_host': host,
  111. 'server.socket_port': port,
  112. 'engine.autoreload_on': False,
  113. 'server.max_request_body_size': 8*1024*1024,
  114. })
  115. if self.embedded:
  116. cherrypy.config.update({ 'environment': 'embedded' })
  117. # Build up application specific configuration
  118. app_config = {}
  119. app_config.update({
  120. 'error_page.default': self.json_error_page,
  121. })
  122. # Some default headers to just help identify that things are working
  123. app_config.update({ 'response.headers.X-Jim-Is-Awesome': 'yeah' })
  124. # Set up Cross-Origin Resource Sharing (CORS) handler so we
  125. # can correctly respond to browsers' CORS preflight requests.
  126. # This also limits verbs to GET and HEAD by default.
  127. app_config.update({ 'tools.CORS_allow.on': True,
  128. 'tools.CORS_allow.methods': ['GET', 'HEAD'] })
  129. # Configure the 'json_in' tool to also allow other content-types
  130. # (like x-www-form-urlencoded), and to treat JSON as a dict that
  131. # fills requests.param.
  132. app_config.update({ 'tools.json_in.force': False,
  133. 'tools.json_in.processor': json_to_request_params })
  134. # Send tracebacks in error responses. They're hidden by the
  135. # error_page function for client errors (code 400-499).
  136. app_config.update({ 'request.show_tracebacks' : True })
  137. self.force_traceback = force_traceback
  138. # Patch CherryPy error handler to never pad out error messages.
  139. # This isn't necessary, but then again, neither is padding the
  140. # error messages.
  141. cherrypy._cperror._ie_friendly_error_sizes = {}
  142. # Build up the application and mount it
  143. manager = nilmrun.processmanager.ProcessManager()
  144. root = App()
  145. root.process = AppProcess(manager)
  146. root.filter = AppFilter(manager)
  147. cherrypy.tree.apps = {}
  148. cherrypy.tree.mount(root, basepath, config = { "/" : app_config })
  149. # Set up the WSGI application pointer for external programs
  150. self.wsgi_application = cherrypy.tree
  151. def json_error_page(self, status, message, traceback, version):
  152. """Return a custom error page in JSON so the client can parse it"""
  153. return json_error_page(status, message, traceback, version,
  154. self.force_traceback)
  155. def start(self, blocking = False, event = None):
  156. cherrypy_start(blocking, event, self.embedded)
  157. def stop(self):
  158. cherrypy_stop()
  159. # Multiple processes and threads should be OK here, but we'll still
  160. # follow the NilmDB approach of having just one globally initialized
  161. # copy of the server object.
  162. _wsgi_server = None
  163. def wsgi_application(basepath): # pragma: no cover
  164. """Return a WSGI application object.
  165. 'basepath' is the URL path of the application base, which
  166. is the same as the first argument to Apache's WSGIScriptAlias
  167. directive.
  168. """
  169. def application(environ, start_response):
  170. global _wsgi_server
  171. if _wsgi_server is None:
  172. # Try to start the server
  173. try:
  174. _wsgi_server = nilmrun.server.Server(
  175. embedded = True,
  176. basepath = basepath.rstrip('/'))
  177. except Exception:
  178. # Build an error message on failure
  179. import pprint
  180. err = sprintf("Initializing nilmrun failed:\n\n",
  181. dbpath)
  182. err += traceback.format_exc()
  183. try:
  184. import pwd
  185. import grp
  186. err += sprintf("\nRunning as: uid=%d (%s), gid=%d (%s) "
  187. "on host %s, pid %d\n",
  188. os.getuid(), pwd.getpwuid(os.getuid())[0],
  189. os.getgid(), grp.getgrgid(os.getgid())[0],
  190. socket.gethostname(), os.getpid())
  191. except ImportError:
  192. pass
  193. err += sprintf("\nEnvironment:\n%s\n", pprint.pformat(environ))
  194. if _wsgi_server is None:
  195. # Serve up the error with our own mini WSGI app.
  196. headers = [ ('Content-type', 'text/plain'),
  197. ('Content-length', str(len(err))) ]
  198. start_response("500 Internal Server Error", headers)
  199. return [err]
  200. # Call the normal application
  201. return _wsgi_server.wsgi_application(environ, start_response)
  202. return application