diff --git a/klippy/webhooks.py b/klippy/webhooks.py new file mode 100644 index 00000000..374e0b1c --- /dev/null +++ b/klippy/webhooks.py @@ -0,0 +1,270 @@ +# Klippy WebHooks registration and server connection +# +# Copyright (C) 2020 Eric Callahan +# +# This file may be distributed under the terms of the GNU GPLv3 license +import logging +import socket +import errno +import json +import homing +from klippy import message_startup, message_ready + +SOCKET_LOCATION = "/tmp/moonraker" + +# Json decodes strings as unicode types in Python 2.x. This doesn't +# play well with some parts of Klipper (particuarly displays), so we +# need to create an object hook. This solution borrowed from: +# +# https://stackoverflow.com/questions/956867/ +# +def byteify(data, ignore_dicts=False): + if isinstance(data, unicode): + return data.encode('utf-8') + if isinstance(data, list): + return [byteify(i, True) for i in data] + if isinstance(data, dict) and not ignore_dicts: + return {byteify(k, True): byteify(v, True) + for k, v in data.items()} + return data + +def json_loads_byteified(data): + return byteify( + json.loads(data, object_hook=byteify), True) + +class WebRequestError(homing.CommandError): + def __init__(self, message,): + Exception.__init__(self, message) + + def to_dict(self): + return { + 'error': 'WebRequestError', + 'message': self.message} + +class Sentinel: + pass + +class WebRequest: + error = WebRequestError + def __init__(self, base_request): + self.id = base_request['id'] + self.path = base_request['path'] + self.method = base_request['method'] + self.args = base_request['args'] + self.response = None + + def get(self, item, default=Sentinel): + if item not in self.args: + if default == Sentinel: + raise WebRequestError("Invalid Argument [%s]" % item) + return default + return self.args[item] + + def get_int(self, item): + return int(self.get(item)) + + def get_float(self, item): + return float(self.get(item)) + + def get_args(self): + return self.args + + def get_path(self): + return self.path + + def get_method(self): + return self.method + + def set_error(self, error): + self.response = error.to_dict() + + def send(self, data): + if self.response is not None: + raise WebRequestError("Multiple calls to send not allowed") + self.response = data + + def finish(self): + if self.response is None: + # No error was set and the user never executed + # send, default response is "ok" + self.response = "ok" + return {"request_id": self.id, "response": self.response} + +class ServerConnection: + def __init__(self, webhooks, printer): + self.printer = printer + self.webhooks = webhooks + self.reactor = printer.get_reactor() + + # Klippy Connection + self.fd_handler = self.mutex = None + self.is_server_connected = False + self.partial_data = "" + is_fileoutput = (printer.get_start_args().get('debugoutput') + is not None) + if is_fileoutput: + # Do not try to connect in klippy batch mode + return + self.socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.socket.setblocking(0) + try: + self.socket.connect(SOCKET_LOCATION) + except socket.error: + logging.debug( + "ServerConnection: Moonraker server not detected") + return + logging.debug("ServerConnection: Moonraker connection established") + self.is_server_connected = True + self.fd_handler = self.reactor.register_fd( + self.socket.fileno(), self.process_received) + self.mutex = self.reactor.mutex() + printer.register_event_handler('klippy:disconnect', self.close_socket) + + def close_socket(self): + if self.is_server_connected: + logging.info("ServerConnection: lost connection to Moonraker") + self.is_server_connected = False + self.reactor.unregister_fd(self.fd_handler) + try: + self.socket.close() + except socket.error: + pass + + def is_connected(self): + return self.is_server_connected + + def process_received(self, eventtime): + try: + data = self.socket.recv(4096) + except socket.error as e: + # If bad file descriptor allow connection to be + # closed by the data check + if e.errno == errno.EBADF: + data = '' + else: + return + if data == '': + # Socket Closed + self.close_socket() + return + requests = data.split('\x03') + requests[0] = self.partial_data + requests[0] + self.partial_data = requests.pop() + for req in requests: + logging.debug( + "ServerConnection: Request received from Moonraker %s" % (req)) + try: + decoded_req = json_loads_byteified(req) + self._process_request(decoded_req) + except Exception: + logging.exception( + "ServerConnection: Error processing Server Request %s" + % (req)) + + def _process_request(self, req): + web_request = WebRequest(req) + try: + func = self.webhooks.get_callback( + web_request.get_path()) + func(web_request) + except homing.CommandError as e: + web_request.set_error(WebRequestError(e.message)) + except Exception as e: + msg = "Internal Error on WebRequest: %s" % (web_request.get_path()) + logging.exception(msg) + web_request.set_error(WebRequestError(e.message)) + self.printer.invoke_shutdown(msg) + result = web_request.finish() + logging.debug( + "ServerConnection: Sending response - %s" % (str(result))) + self.send({'method': "response", 'params': result}) + + def send(self, data): + if not self.is_server_connected: + return + with self.mutex: + retries = 10 + data = json.dumps(data) + "\x03" + while data: + try: + sent = self.socket.send(data) + except socket.error as e: + if e.errno == errno.EBADF or e.errno == errno.EPIPE \ + or not retries: + sent = 0 + else: + retries -= 1 + waketime = self.reactor.monotonic() + .001 + self.reactor.pause(waketime) + continue + retries = 10 + if sent > 0: + data = data[sent:] + else: + logging.info( + "ServerConnection: Error sending server data," + " closing socket") + self.close_socket() + break + +class WebHooks: + def __init__(self, printer): + self.printer = printer + self._endpoints = {"list_endpoints": self._handle_list_endpoints} + self._static_paths = [] + self.register_endpoint("info", self._handle_info_request) + self.register_endpoint("emergency_stop", self._handle_estop_request) + start_args = printer.get_start_args() + log_file = start_args.get('log_file') + if log_file is not None: + self.register_static_path("klippy.log", log_file) + self.sconn = ServerConnection(self, printer) + + def register_endpoint(self, path, callback): + if path in self._endpoints: + raise WebRequestError("Path already registered to an endpoint") + self._endpoints[path] = callback + + + def register_static_path(self, resource_id, file_path): + static_path_info = { + 'resource_id': resource_id, 'file_path': file_path} + self._static_paths.append(static_path_info) + + def _handle_list_endpoints(self, web_request): + web_request.send({ + 'hooks': self._endpoints.keys(), + 'static_paths': self._static_paths}) + + def _handle_info_request(self, web_request): + if web_request.get_method() != 'GET': + raise web_request.error("Invalid Request Method") + start_args = self.printer.get_start_args() + state_message = self.printer.get_state_message() + version = start_args['software_version'] + cpu_info = start_args['cpu_info'] + error = state_message != message_startup and \ + state_message != message_ready + web_request.send( + {'cpu': cpu_info, 'version': version, + 'hostname': socket.gethostname(), + 'is_ready': state_message == message_ready, + 'error_detected': error, + 'message': state_message}) + + def _handle_estop_request(self, web_request): + if web_request.get_method() != 'POST': + raise web_request.error("Invalid Request Method") + gcode = self.printer.lookup_object('gcode') + gcode.cmd_M112(None) + + def get_connection(self): + return self.sconn + + def get_callback(self, path): + cb = self._endpoints.get(path, None) + if cb is None: + msg = "webhooks: No registered callback for path '%s'" % (path) + logging.info(msg) + raise WebRequestError(msg) + return cb