2020-07-02 04:21:35 +03:00
|
|
|
# Moonraker - HTTP/Websocket API Server for Klipper
|
|
|
|
#
|
|
|
|
# Copyright (C) 2020 Eric Callahan <arksine.code@gmail.com>
|
|
|
|
#
|
|
|
|
# This file may be distributed under the terms of the GNU GPLv3 license
|
|
|
|
import argparse
|
2020-07-27 21:56:23 +03:00
|
|
|
import sys
|
2020-07-02 04:21:35 +03:00
|
|
|
import importlib
|
|
|
|
import os
|
|
|
|
import time
|
|
|
|
import socket
|
|
|
|
import logging
|
|
|
|
import json
|
2020-08-06 03:44:21 +03:00
|
|
|
import confighelper
|
2020-08-19 16:14:30 +03:00
|
|
|
import utils
|
2020-10-11 15:48:47 +03:00
|
|
|
import asyncio
|
|
|
|
from tornado import iostream, gen
|
2020-09-01 15:41:17 +03:00
|
|
|
from tornado.ioloop import IOLoop
|
2020-07-02 04:21:35 +03:00
|
|
|
from tornado.util import TimeoutError
|
|
|
|
from tornado.locks import Event
|
|
|
|
from app import MoonrakerApp
|
2020-08-19 16:14:30 +03:00
|
|
|
from utils import ServerError
|
2020-07-02 04:21:35 +03:00
|
|
|
|
2020-09-01 15:41:17 +03:00
|
|
|
INIT_TIME = .25
|
|
|
|
LOG_ATTEMPT_INTERVAL = int(2. / INIT_TIME + .5)
|
2020-09-01 14:41:53 +03:00
|
|
|
MAX_LOG_ATTEMPTS = 10 * LOG_ATTEMPT_INTERVAL
|
2020-07-02 04:21:35 +03:00
|
|
|
|
|
|
|
CORE_PLUGINS = [
|
2020-08-14 03:45:03 +03:00
|
|
|
'file_manager', 'klippy_apis', 'machine',
|
2020-09-28 21:08:40 +03:00
|
|
|
'data_store', 'shell_command']
|
2020-07-02 04:21:35 +03:00
|
|
|
|
|
|
|
class Sentinel:
|
|
|
|
pass
|
|
|
|
|
|
|
|
class Server:
|
|
|
|
error = ServerError
|
|
|
|
def __init__(self, args):
|
2020-08-06 03:44:21 +03:00
|
|
|
config = confighelper.get_configuration(self, args)
|
|
|
|
self.host = config.get('host', "0.0.0.0")
|
|
|
|
self.port = config.getint('port', 7125)
|
2020-07-02 04:21:35 +03:00
|
|
|
|
|
|
|
# Event initialization
|
|
|
|
self.events = {}
|
|
|
|
|
|
|
|
# Klippy Connection Handling
|
2020-08-08 18:28:05 +03:00
|
|
|
self.klippy_address = config.get(
|
|
|
|
'klippy_uds_address', "/tmp/klippy_uds")
|
2020-08-15 22:22:17 +03:00
|
|
|
self.klippy_connection = KlippyConnection(
|
|
|
|
self.process_command, self.on_connection_closed)
|
2020-11-18 15:57:08 +03:00
|
|
|
self.klippy_info = {}
|
2020-08-16 02:12:15 +03:00
|
|
|
self.init_list = []
|
2020-09-03 19:27:13 +03:00
|
|
|
self.init_handle = None
|
2020-09-01 14:41:53 +03:00
|
|
|
self.init_attempts = 0
|
2020-08-14 03:45:03 +03:00
|
|
|
self.klippy_state = "disconnected"
|
2020-11-10 04:54:00 +03:00
|
|
|
self.subscriptions = {}
|
2020-07-02 04:21:35 +03:00
|
|
|
|
|
|
|
# Server/IOLoop
|
|
|
|
self.server_running = False
|
2020-08-06 03:44:21 +03:00
|
|
|
self.moonraker_app = app = MoonrakerApp(config)
|
2020-07-02 04:21:35 +03:00
|
|
|
self.register_endpoint = app.register_local_handler
|
|
|
|
self.register_static_file_handler = app.register_static_file_handler
|
|
|
|
self.register_upload_handler = app.register_upload_handler
|
2020-08-08 00:27:01 +03:00
|
|
|
self.ioloop = IOLoop.current()
|
2020-07-02 04:21:35 +03:00
|
|
|
|
2020-09-26 13:29:02 +03:00
|
|
|
self.register_endpoint(
|
|
|
|
"/server/info", ['GET'], self._handle_info_request)
|
2020-10-11 15:48:47 +03:00
|
|
|
self.register_endpoint(
|
|
|
|
"/server/restart", ['POST'], self._handle_server_restart)
|
2020-09-26 13:29:02 +03:00
|
|
|
|
2020-07-02 04:21:35 +03:00
|
|
|
# Setup remote methods accessable to Klippy. Note that all
|
|
|
|
# registered remote methods should be of the notification type,
|
|
|
|
# they do not return a response to Klippy after execution
|
|
|
|
self.pending_requests = {}
|
|
|
|
self.remote_methods = {}
|
2020-10-27 16:10:53 +03:00
|
|
|
self.klippy_reg_methods = []
|
2020-07-02 04:21:35 +03:00
|
|
|
self.register_remote_method(
|
2020-10-27 16:10:53 +03:00
|
|
|
'process_gcode_response', self._process_gcode_response,
|
|
|
|
need_klippy_reg=False)
|
2020-07-02 04:21:35 +03:00
|
|
|
self.register_remote_method(
|
2020-10-27 16:10:53 +03:00
|
|
|
'process_status_update', self._process_status_update,
|
|
|
|
need_klippy_reg=False)
|
2020-07-02 04:21:35 +03:00
|
|
|
|
2020-08-06 03:44:21 +03:00
|
|
|
# Plugin initialization
|
|
|
|
self.plugins = {}
|
2020-08-14 03:45:03 +03:00
|
|
|
self.klippy_apis = self.load_plugin(config, 'klippy_apis')
|
2020-08-06 03:44:21 +03:00
|
|
|
self._load_plugins(config)
|
|
|
|
|
2020-07-02 04:21:35 +03:00
|
|
|
def start(self):
|
2020-10-13 14:50:18 +03:00
|
|
|
hostname, hostport = self.get_host_info()
|
2020-07-02 04:21:35 +03:00
|
|
|
logging.info(
|
2020-10-13 14:50:18 +03:00
|
|
|
f"Starting Moonraker on ({self.host}, {hostport}), "
|
|
|
|
f"Hostname: {hostname}")
|
2020-07-02 04:21:35 +03:00
|
|
|
self.moonraker_app.listen(self.host, self.port)
|
|
|
|
self.server_running = True
|
2020-08-08 18:28:05 +03:00
|
|
|
self.ioloop.spawn_callback(self._connect_klippy)
|
2020-07-02 04:21:35 +03:00
|
|
|
|
|
|
|
# ***** Plugin Management *****
|
2020-08-06 03:44:21 +03:00
|
|
|
def _load_plugins(self, config):
|
|
|
|
# load core plugins
|
|
|
|
for plugin in CORE_PLUGINS:
|
|
|
|
self.load_plugin(config, plugin)
|
|
|
|
|
|
|
|
# check for optional plugins
|
2020-11-14 14:15:08 +03:00
|
|
|
opt_sections = set([s.split()[0] for s in config.sections()]) - \
|
2020-11-18 04:03:49 +03:00
|
|
|
set(['server', 'authorization', 'system_args'])
|
2020-08-06 03:44:21 +03:00
|
|
|
for section in opt_sections:
|
2020-09-26 13:09:08 +03:00
|
|
|
self.load_plugin(config, section, None)
|
2020-08-06 03:44:21 +03:00
|
|
|
|
|
|
|
def load_plugin(self, config, plugin_name, default=Sentinel):
|
2020-07-02 04:21:35 +03:00
|
|
|
if plugin_name in self.plugins:
|
|
|
|
return self.plugins[plugin_name]
|
|
|
|
# Make sure plugin exists
|
|
|
|
mod_path = os.path.join(
|
|
|
|
os.path.dirname(__file__), 'plugins', plugin_name + '.py')
|
|
|
|
if not os.path.exists(mod_path):
|
2020-08-11 19:59:47 +03:00
|
|
|
msg = f"Plugin ({plugin_name}) does not exist"
|
2020-08-03 03:48:34 +03:00
|
|
|
logging.info(msg)
|
|
|
|
if default == Sentinel:
|
|
|
|
raise ServerError(msg)
|
|
|
|
return default
|
2020-07-02 04:21:35 +03:00
|
|
|
module = importlib.import_module("plugins." + plugin_name)
|
|
|
|
try:
|
2020-11-14 14:15:08 +03:00
|
|
|
func_name = "load_plugin"
|
|
|
|
if hasattr(module, "load_plugin_multi"):
|
|
|
|
func_name = "load_plugin_multi"
|
|
|
|
if plugin_name not in CORE_PLUGINS and func_name == "load_plugin":
|
2020-09-26 13:09:08 +03:00
|
|
|
config = config[plugin_name]
|
2020-11-14 14:15:08 +03:00
|
|
|
load_func = getattr(module, func_name)
|
2020-08-06 03:44:21 +03:00
|
|
|
plugin = load_func(config)
|
2020-07-02 04:21:35 +03:00
|
|
|
except Exception:
|
2020-08-11 19:59:47 +03:00
|
|
|
msg = f"Unable to load plugin ({plugin_name})"
|
2020-09-26 13:09:08 +03:00
|
|
|
logging.exception(msg)
|
2020-07-02 04:21:35 +03:00
|
|
|
if default == Sentinel:
|
|
|
|
raise ServerError(msg)
|
|
|
|
return default
|
|
|
|
self.plugins[plugin_name] = plugin
|
2020-08-11 19:59:47 +03:00
|
|
|
logging.info(f"Plugin ({plugin_name}) loaded")
|
2020-07-02 04:21:35 +03:00
|
|
|
return plugin
|
|
|
|
|
|
|
|
def lookup_plugin(self, plugin_name, default=Sentinel):
|
|
|
|
plugin = self.plugins.get(plugin_name, default)
|
|
|
|
if plugin == Sentinel:
|
2020-08-11 19:59:47 +03:00
|
|
|
raise ServerError(f"Plugin ({plugin_name}) not found")
|
2020-07-02 04:21:35 +03:00
|
|
|
return plugin
|
|
|
|
|
|
|
|
def register_event_handler(self, event, callback):
|
|
|
|
self.events.setdefault(event, []).append(callback)
|
|
|
|
|
|
|
|
def send_event(self, event, *args):
|
|
|
|
events = self.events.get(event, [])
|
|
|
|
for evt in events:
|
2020-08-08 00:27:01 +03:00
|
|
|
self.ioloop.spawn_callback(evt, *args)
|
2020-07-02 04:21:35 +03:00
|
|
|
|
2020-10-27 16:10:53 +03:00
|
|
|
def register_remote_method(self, method_name, cb, need_klippy_reg=True):
|
2020-07-02 04:21:35 +03:00
|
|
|
if method_name in self.remote_methods:
|
|
|
|
# XXX - may want to raise an exception here
|
2020-08-11 19:59:47 +03:00
|
|
|
logging.info(f"Remote method ({method_name}) already registered")
|
2020-07-02 04:21:35 +03:00
|
|
|
return
|
|
|
|
self.remote_methods[method_name] = cb
|
2020-10-27 16:10:53 +03:00
|
|
|
if need_klippy_reg:
|
|
|
|
# These methods need to be registered with Klippy
|
|
|
|
self.klippy_reg_methods.append(method_name)
|
2020-07-02 04:21:35 +03:00
|
|
|
|
2020-10-13 14:50:18 +03:00
|
|
|
def get_host_info(self):
|
|
|
|
hostname = socket.gethostname()
|
|
|
|
return hostname, self.port
|
|
|
|
|
2020-11-18 15:57:08 +03:00
|
|
|
def get_klippy_info(self):
|
|
|
|
return dict(self.klippy_info)
|
|
|
|
|
2020-07-02 04:21:35 +03:00
|
|
|
# ***** Klippy Connection *****
|
2020-08-08 18:28:05 +03:00
|
|
|
async def _connect_klippy(self):
|
2020-10-11 15:48:47 +03:00
|
|
|
if not self.server_running:
|
|
|
|
return
|
2020-08-15 22:22:17 +03:00
|
|
|
ret = await self.klippy_connection.connect(self.klippy_address)
|
|
|
|
if not ret:
|
2020-09-01 14:41:53 +03:00
|
|
|
self.ioloop.call_later(.25, self._connect_klippy)
|
2020-08-08 18:28:05 +03:00
|
|
|
return
|
2020-07-02 04:21:35 +03:00
|
|
|
# begin server iniialization
|
2020-09-01 15:41:17 +03:00
|
|
|
self.ioloop.spawn_callback(self._initialize)
|
2020-07-02 04:21:35 +03:00
|
|
|
|
2020-08-15 22:22:17 +03:00
|
|
|
def process_command(self, cmd):
|
|
|
|
method = cmd.get('method', None)
|
|
|
|
if method is not None:
|
|
|
|
# This is a remote method called from klippy
|
2020-11-11 12:39:53 +03:00
|
|
|
if method in self.remote_methods:
|
2020-08-15 22:22:17 +03:00
|
|
|
params = cmd.get('params', {})
|
2020-11-11 12:39:53 +03:00
|
|
|
self.ioloop.spawn_callback(
|
|
|
|
self._execute_method, method, **params)
|
2020-08-15 22:22:17 +03:00
|
|
|
else:
|
|
|
|
logging.info(f"Unknown method received: {method}")
|
|
|
|
return
|
|
|
|
# This is a response to a request, process
|
|
|
|
req_id = cmd.get('id', None)
|
|
|
|
request = self.pending_requests.pop(req_id, None)
|
|
|
|
if request is None:
|
|
|
|
logging.info(
|
|
|
|
f"No request matching request ID: {req_id}, "
|
|
|
|
f"response: {cmd}")
|
|
|
|
return
|
|
|
|
if 'result' in cmd:
|
|
|
|
result = cmd['result']
|
|
|
|
if not result:
|
|
|
|
result = "ok"
|
|
|
|
else:
|
|
|
|
err = cmd.get('error', "Malformed Klippy Response")
|
|
|
|
result = ServerError(err, 400)
|
|
|
|
request.notify(result)
|
2020-07-02 04:21:35 +03:00
|
|
|
|
2020-11-11 12:39:53 +03:00
|
|
|
async def _execute_method(self, method_name, **kwargs):
|
|
|
|
try:
|
|
|
|
ret = self.remote_methods[method_name](**kwargs)
|
|
|
|
if asyncio.iscoroutine(ret):
|
|
|
|
await ret
|
|
|
|
except Exception:
|
|
|
|
logging.exception(f"Error running remote method: {method_name}")
|
|
|
|
|
2020-08-15 22:22:17 +03:00
|
|
|
def on_connection_closed(self):
|
2020-08-16 02:12:15 +03:00
|
|
|
self.init_list = []
|
2020-08-14 03:45:03 +03:00
|
|
|
self.klippy_state = "disconnected"
|
2020-08-08 00:27:01 +03:00
|
|
|
for request in self.pending_requests.values():
|
|
|
|
request.notify(ServerError("Klippy Disconnected", 503))
|
|
|
|
self.pending_requests = {}
|
2020-11-10 04:54:00 +03:00
|
|
|
self.subscriptions = {}
|
2020-08-08 00:27:01 +03:00
|
|
|
logging.info("Klippy Connection Removed")
|
2020-08-16 03:09:17 +03:00
|
|
|
self.send_event("server:klippy_disconnect")
|
2020-09-03 19:27:13 +03:00
|
|
|
if self.init_handle is not None:
|
|
|
|
self.ioloop.remove_timeout(self.init_handle)
|
2020-10-11 15:48:47 +03:00
|
|
|
if self.server_running:
|
|
|
|
self.ioloop.call_later(.25, self._connect_klippy)
|
2020-08-08 00:27:01 +03:00
|
|
|
|
2020-07-02 04:21:35 +03:00
|
|
|
async def _initialize(self):
|
2020-10-11 15:48:47 +03:00
|
|
|
if not self.server_running:
|
|
|
|
return
|
2020-08-16 02:12:15 +03:00
|
|
|
await self._check_ready()
|
2020-07-02 04:21:35 +03:00
|
|
|
await self._request_endpoints()
|
2020-08-16 02:12:15 +03:00
|
|
|
# Subscribe to "webhooks"
|
|
|
|
# Register "webhooks" subscription
|
|
|
|
if "webhooks_sub" not in self.init_list:
|
|
|
|
try:
|
|
|
|
await self.klippy_apis.subscribe_objects({'webhooks': None})
|
|
|
|
except ServerError as e:
|
|
|
|
logging.info(f"{e}\nUnable to subscribe to webhooks object")
|
|
|
|
else:
|
|
|
|
logging.info("Webhooks Subscribed")
|
|
|
|
self.init_list.append("webhooks_sub")
|
|
|
|
# Subscribe to Gcode Output
|
|
|
|
if "gcode_output_sub" not in self.init_list:
|
|
|
|
try:
|
|
|
|
await self.klippy_apis.subscribe_gcode_output()
|
|
|
|
except ServerError as e:
|
|
|
|
logging.info(
|
|
|
|
f"{e}\nUnable to register gcode output subscription")
|
|
|
|
else:
|
|
|
|
logging.info("GCode Output Subscribed")
|
|
|
|
self.init_list.append("gcode_output_sub")
|
2020-09-03 19:27:13 +03:00
|
|
|
if "klippy_ready" in self.init_list or \
|
|
|
|
not self.klippy_connection.is_connected():
|
|
|
|
# Either Klippy is ready or the connection dropped
|
|
|
|
# during initialization. Exit initialization
|
2020-09-01 14:41:53 +03:00
|
|
|
self.init_attempts = 0
|
2020-09-03 19:27:13 +03:00
|
|
|
self.init_handle = None
|
2020-09-01 15:41:17 +03:00
|
|
|
else:
|
|
|
|
self.init_attempts += 1
|
2020-09-03 19:27:13 +03:00
|
|
|
self.init_handle = self.ioloop.call_later(
|
|
|
|
INIT_TIME, self._initialize)
|
2020-07-02 04:21:35 +03:00
|
|
|
|
|
|
|
async def _request_endpoints(self):
|
2020-08-14 03:45:03 +03:00
|
|
|
result = await self.klippy_apis.list_endpoints(default=None)
|
|
|
|
if result is None:
|
2020-08-12 15:43:37 +03:00
|
|
|
return
|
2020-08-14 03:45:03 +03:00
|
|
|
endpoints = result.get('endpoints', {})
|
2020-08-12 15:43:37 +03:00
|
|
|
for ep in endpoints:
|
|
|
|
self.moonraker_app.register_remote_handler(ep)
|
2020-07-02 04:21:35 +03:00
|
|
|
|
2020-08-06 03:44:21 +03:00
|
|
|
async def _check_ready(self):
|
2020-08-26 14:34:29 +03:00
|
|
|
send_id = "identified" not in self.init_list
|
2020-08-12 15:43:37 +03:00
|
|
|
try:
|
2020-08-26 14:34:29 +03:00
|
|
|
result = await self.klippy_apis.get_klippy_info(send_id)
|
2020-08-12 15:43:37 +03:00
|
|
|
except ServerError as e:
|
2020-09-01 14:41:53 +03:00
|
|
|
if self.init_attempts % LOG_ATTEMPT_INTERVAL == 0 and \
|
|
|
|
self.init_attempts <= MAX_LOG_ATTEMPTS:
|
|
|
|
logging.info(
|
|
|
|
f"{e}\nKlippy info request error. This indicates that\n"
|
|
|
|
f"Klippy may have experienced an error during startup.\n"
|
|
|
|
f"Please check klippy.log for more information")
|
2020-08-12 15:43:37 +03:00
|
|
|
return
|
2020-08-26 14:34:29 +03:00
|
|
|
if send_id:
|
|
|
|
self.init_list.append("identified")
|
2020-11-18 15:57:08 +03:00
|
|
|
self.klippy_info = result
|
2020-08-14 03:45:03 +03:00
|
|
|
# Update filemanager fixed paths
|
|
|
|
fixed_paths = {k: result[k] for k in
|
|
|
|
['klipper_path', 'python_path',
|
|
|
|
'log_file', 'config_file']}
|
|
|
|
file_manager = self.lookup_plugin('file_manager')
|
|
|
|
file_manager.update_fixed_paths(fixed_paths)
|
2020-09-26 13:29:02 +03:00
|
|
|
self.klippy_state = result.get('state', "unknown")
|
|
|
|
if self.klippy_state == "ready":
|
2020-08-16 02:12:15 +03:00
|
|
|
await self._verify_klippy_requirements()
|
|
|
|
logging.info("Klippy ready")
|
|
|
|
self.init_list.append('klippy_ready')
|
2020-10-27 16:10:53 +03:00
|
|
|
# register methods with klippy
|
|
|
|
for method in self.klippy_reg_methods:
|
|
|
|
try:
|
|
|
|
await self.klippy_apis.register_method(method)
|
|
|
|
except ServerError:
|
|
|
|
logging.exception(f"Unable to register method '{method}'")
|
2020-08-16 03:09:17 +03:00
|
|
|
self.send_event("server:klippy_ready")
|
2020-09-01 14:41:53 +03:00
|
|
|
elif self.init_attempts % LOG_ATTEMPT_INTERVAL == 0 and \
|
|
|
|
self.init_attempts <= MAX_LOG_ATTEMPTS:
|
2020-08-14 03:45:03 +03:00
|
|
|
msg = result.get('state_message', "Klippy Not Ready")
|
2020-08-12 15:43:37 +03:00
|
|
|
logging.info("\n" + msg)
|
|
|
|
|
2020-08-16 02:12:15 +03:00
|
|
|
async def _verify_klippy_requirements(self):
|
|
|
|
result = await self.klippy_apis.get_object_list(default=None)
|
2020-08-14 03:45:03 +03:00
|
|
|
if result is None:
|
2020-08-16 02:12:15 +03:00
|
|
|
logging.info(
|
|
|
|
f"Unable to retreive Klipper Object List")
|
|
|
|
return
|
|
|
|
req_objs = set(["virtual_sdcard", "display_status", "pause_resume"])
|
|
|
|
missing_objs = req_objs - set(result)
|
|
|
|
if missing_objs:
|
|
|
|
err_str = ", ".join([f"[{o}]" for o in missing_objs])
|
|
|
|
logging.info(
|
|
|
|
f"\nWarning, unable to detect the following printer "
|
|
|
|
f"objects:\n{err_str}\nPlease add the the above sections "
|
|
|
|
f"to printer.cfg for full Moonraker functionality.")
|
|
|
|
if "virtual_sdcard" not in missing_objs:
|
|
|
|
# Update the gcode path
|
|
|
|
result = await self.klippy_apis.query_objects(
|
|
|
|
{'configfile': None}, default=None)
|
|
|
|
if result is None:
|
|
|
|
logging.info(f"Unable to set SD Card path")
|
2020-08-14 03:45:03 +03:00
|
|
|
else:
|
2020-08-16 02:12:15 +03:00
|
|
|
config = result.get('configfile', {}).get('config', {})
|
|
|
|
vsd_config = config.get('virtual_sdcard', {})
|
|
|
|
vsd_path = vsd_config.get('path', None)
|
|
|
|
if vsd_path is not None:
|
|
|
|
file_manager = self.lookup_plugin('file_manager')
|
2020-09-04 15:04:16 +03:00
|
|
|
file_manager.register_directory('gcodes', vsd_path)
|
2020-08-16 02:12:15 +03:00
|
|
|
else:
|
|
|
|
logging.info(
|
|
|
|
"Configuration for [virtual_sdcard] not found,"
|
|
|
|
" unable to set SD Card path")
|
2020-07-02 04:21:35 +03:00
|
|
|
|
|
|
|
def _process_gcode_response(self, response):
|
|
|
|
self.send_event("server:gcode_response", response)
|
|
|
|
|
2020-08-14 03:45:03 +03:00
|
|
|
def _process_status_update(self, eventtime, status):
|
|
|
|
if 'webhooks' in status:
|
|
|
|
# XXX - process other states (startup, ready, error, etc)?
|
|
|
|
state = status['webhooks'].get('state', None)
|
|
|
|
if state is not None:
|
|
|
|
if state == "shutdown":
|
2020-08-16 02:12:15 +03:00
|
|
|
logging.info("Klippy has shutdown")
|
2020-08-16 03:09:17 +03:00
|
|
|
self.send_event("server:klippy_shutdown")
|
2020-08-14 03:45:03 +03:00
|
|
|
self.klippy_state = state
|
2020-11-10 04:54:00 +03:00
|
|
|
for conn, sub in self.subscriptions.items():
|
|
|
|
conn_status = {}
|
|
|
|
for name, fields in sub.items():
|
|
|
|
if name in status:
|
|
|
|
val = status[name]
|
|
|
|
if fields is None:
|
|
|
|
conn_status[name] = dict(val)
|
|
|
|
else:
|
|
|
|
conn_status[name] = {
|
|
|
|
k: v for k, v in val.items() if k in fields}
|
|
|
|
conn.send_status(conn_status)
|
2020-07-02 04:21:35 +03:00
|
|
|
|
2020-11-09 15:00:20 +03:00
|
|
|
async def make_request(self, web_request):
|
|
|
|
rpc_method = web_request.get_endpoint()
|
2020-08-14 03:45:03 +03:00
|
|
|
if rpc_method == "objects/subscribe":
|
2020-11-11 15:41:33 +03:00
|
|
|
return await self._request_subscripton(web_request)
|
|
|
|
else:
|
|
|
|
return await self._request_standard(web_request)
|
|
|
|
|
|
|
|
async def _request_subscripton(self, web_request):
|
|
|
|
args = web_request.get_args()
|
|
|
|
conn = web_request.get_connection()
|
|
|
|
|
|
|
|
# Build the subscription request from a superset of all client
|
|
|
|
# subscriptions
|
|
|
|
sub = args.get('objects', {})
|
|
|
|
if conn is None:
|
|
|
|
raise self.error(
|
|
|
|
"No connection associated with subscription request")
|
|
|
|
self.subscriptions[conn] = sub
|
|
|
|
all_subs = {}
|
|
|
|
# request superset of all client subscriptions
|
|
|
|
for sub in self.subscriptions.values():
|
|
|
|
for obj, items in sub.items():
|
|
|
|
if obj in all_subs:
|
|
|
|
pi = all_subs[obj]
|
|
|
|
if items is None or pi is None:
|
|
|
|
all_subs[obj] = None
|
2020-08-14 03:45:03 +03:00
|
|
|
else:
|
2020-11-11 15:41:33 +03:00
|
|
|
uitems = list(set(pi) | set(items))
|
|
|
|
all_subs[obj] = uitems
|
|
|
|
else:
|
|
|
|
all_subs[obj] = items
|
|
|
|
args['objects'] = all_subs
|
|
|
|
args['response_template'] = {'method': "process_status_update"}
|
|
|
|
|
|
|
|
result = await self._request_standard(web_request)
|
|
|
|
|
|
|
|
# prune the status response
|
|
|
|
pruned_status = {}
|
|
|
|
all_status = result['status']
|
|
|
|
sub = self.subscriptions.get(conn, {})
|
|
|
|
for obj, fields in all_status.items():
|
|
|
|
if obj in sub:
|
|
|
|
valid_fields = sub[obj]
|
|
|
|
if valid_fields is None:
|
|
|
|
pruned_status[obj] = fields
|
|
|
|
else:
|
|
|
|
pruned_status[obj] = {k: v for k, v in fields.items()
|
|
|
|
if k in valid_fields}
|
|
|
|
result['status'] = pruned_status
|
|
|
|
return result
|
2020-08-14 03:45:03 +03:00
|
|
|
|
2020-11-11 15:41:33 +03:00
|
|
|
async def _request_standard(self, web_request):
|
|
|
|
rpc_method = web_request.get_endpoint()
|
|
|
|
args = web_request.get_args()
|
2020-11-09 15:00:20 +03:00
|
|
|
# Create a base klippy request
|
|
|
|
base_request = BaseRequest(rpc_method, args)
|
2020-07-02 04:21:35 +03:00
|
|
|
self.pending_requests[base_request.id] = base_request
|
2020-08-08 00:27:01 +03:00
|
|
|
self.ioloop.spawn_callback(
|
2020-08-15 22:22:17 +03:00
|
|
|
self.klippy_connection.send_request, base_request)
|
2020-11-11 15:41:33 +03:00
|
|
|
return await base_request.wait()
|
2020-07-02 04:21:35 +03:00
|
|
|
|
2020-11-10 04:54:00 +03:00
|
|
|
def remove_subscription(self, conn):
|
|
|
|
self.subscriptions.pop(conn, None)
|
|
|
|
|
2020-08-14 03:45:03 +03:00
|
|
|
async def _stop_server(self):
|
2020-10-11 15:48:47 +03:00
|
|
|
self.server_running = False
|
|
|
|
for name, plugin in self.plugins.items():
|
2020-07-02 04:21:35 +03:00
|
|
|
if hasattr(plugin, "close"):
|
2020-10-11 15:48:47 +03:00
|
|
|
ret = plugin.close()
|
|
|
|
if asyncio.iscoroutine(ret):
|
|
|
|
await ret
|
2020-08-15 22:22:17 +03:00
|
|
|
self.klippy_connection.close()
|
2020-10-11 15:48:47 +03:00
|
|
|
while self.klippy_state != "disconnected":
|
|
|
|
await gen.sleep(.1)
|
|
|
|
await self.moonraker_app.close()
|
|
|
|
self.ioloop.stop()
|
|
|
|
|
2020-11-09 15:00:20 +03:00
|
|
|
async def _handle_server_restart(self, web_request):
|
2020-10-11 15:48:47 +03:00
|
|
|
self.ioloop.spawn_callback(self._stop_server)
|
|
|
|
return "ok"
|
2020-07-02 04:21:35 +03:00
|
|
|
|
2020-11-09 15:00:20 +03:00
|
|
|
async def _handle_info_request(self, web_request):
|
2020-09-26 13:29:02 +03:00
|
|
|
return {
|
|
|
|
'klippy_connected': self.klippy_connection.is_connected(),
|
|
|
|
'klippy_state': self.klippy_state,
|
|
|
|
'plugins': list(self.plugins.keys())}
|
|
|
|
|
2020-08-15 22:22:17 +03:00
|
|
|
class KlippyConnection:
|
|
|
|
def __init__(self, on_recd, on_close):
|
|
|
|
self.ioloop = IOLoop.current()
|
|
|
|
self.iostream = None
|
|
|
|
self.on_recd = on_recd
|
|
|
|
self.on_close = on_close
|
|
|
|
|
|
|
|
async def connect(self, address):
|
|
|
|
ksock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
|
|
kstream = iostream.IOStream(ksock)
|
|
|
|
try:
|
|
|
|
await kstream.connect(address)
|
|
|
|
except iostream.StreamClosedError:
|
|
|
|
return False
|
|
|
|
logging.info("Klippy Connection Established")
|
|
|
|
self.iostream = kstream
|
|
|
|
self.iostream.set_close_callback(self.on_close)
|
|
|
|
self.ioloop.spawn_callback(self._read_stream, self.iostream)
|
|
|
|
return True
|
|
|
|
|
|
|
|
async def _read_stream(self, stream):
|
|
|
|
while not stream.closed():
|
|
|
|
try:
|
|
|
|
data = await stream.read_until(b'\x03')
|
|
|
|
except iostream.StreamClosedError as e:
|
|
|
|
return
|
|
|
|
except Exception:
|
|
|
|
logging.exception("Klippy Stream Read Error")
|
|
|
|
continue
|
|
|
|
try:
|
|
|
|
decoded_cmd = json.loads(data[:-1])
|
|
|
|
self.on_recd(decoded_cmd)
|
|
|
|
except Exception:
|
|
|
|
logging.exception(
|
|
|
|
f"Error processing Klippy Host Response: {data.decode()}")
|
|
|
|
|
|
|
|
async def send_request(self, request):
|
|
|
|
if self.iostream is None:
|
|
|
|
request.notify(ServerError("Klippy Host not connected", 503))
|
|
|
|
return
|
|
|
|
data = json.dumps(request.to_dict()).encode() + b"\x03"
|
|
|
|
try:
|
|
|
|
await self.iostream.write(data)
|
|
|
|
except iostream.StreamClosedError:
|
|
|
|
request.notify(ServerError("Klippy Host not connected", 503))
|
|
|
|
|
|
|
|
def is_connected(self):
|
|
|
|
return self.iostream is not None and not self.iostream.closed()
|
|
|
|
|
|
|
|
def close(self):
|
|
|
|
if self.iostream is not None and \
|
|
|
|
not self.iostream.closed():
|
|
|
|
self.iostream.close()
|
|
|
|
|
2020-07-02 04:21:35 +03:00
|
|
|
# Basic WebRequest class, easily converted to dict for json encoding
|
|
|
|
class BaseRequest:
|
2020-08-14 01:15:17 +03:00
|
|
|
def __init__(self, rpc_method, params):
|
2020-07-02 04:21:35 +03:00
|
|
|
self.id = id(self)
|
2020-08-14 01:15:17 +03:00
|
|
|
self.rpc_method = rpc_method
|
|
|
|
self.params = params
|
2020-07-02 04:21:35 +03:00
|
|
|
self._event = Event()
|
|
|
|
self.response = None
|
|
|
|
|
|
|
|
async def wait(self):
|
2020-08-05 04:27:58 +03:00
|
|
|
# Log pending requests every 60 seconds
|
|
|
|
start_time = time.time()
|
|
|
|
while True:
|
|
|
|
timeout = time.time() + 60.
|
|
|
|
try:
|
|
|
|
await self._event.wait(timeout=timeout)
|
|
|
|
except TimeoutError:
|
|
|
|
pending_time = time.time() - start_time
|
2020-08-14 00:49:29 +03:00
|
|
|
logging.info(
|
2020-08-14 01:15:17 +03:00
|
|
|
f"Request '{self.rpc_method}' pending: "
|
2020-08-14 00:49:29 +03:00
|
|
|
f"{pending_time:.2f} seconds")
|
2020-08-05 04:27:58 +03:00
|
|
|
self._event.clear()
|
|
|
|
continue
|
|
|
|
break
|
2020-08-12 15:43:37 +03:00
|
|
|
if isinstance(self.response, ServerError):
|
|
|
|
raise self.response
|
2020-07-02 04:21:35 +03:00
|
|
|
return self.response
|
|
|
|
|
|
|
|
def notify(self, response):
|
|
|
|
self.response = response
|
|
|
|
self._event.set()
|
|
|
|
|
|
|
|
def to_dict(self):
|
2020-08-14 01:15:17 +03:00
|
|
|
return {'id': self.id, 'method': self.rpc_method,
|
|
|
|
'params': self.params}
|
2020-07-02 04:21:35 +03:00
|
|
|
|
|
|
|
def main():
|
|
|
|
# Parse start arguments
|
|
|
|
parser = argparse.ArgumentParser(
|
|
|
|
description="Moonraker - Klipper API Server")
|
|
|
|
parser.add_argument(
|
2020-08-06 03:44:21 +03:00
|
|
|
"-c", "--configfile", default="~/moonraker.conf",
|
|
|
|
metavar='<configfile>',
|
|
|
|
help="Location of moonraker configuration file")
|
2020-07-02 04:21:35 +03:00
|
|
|
parser.add_argument(
|
|
|
|
"-l", "--logfile", default="/tmp/moonraker.log", metavar='<logfile>',
|
|
|
|
help="log file name and location")
|
2020-11-18 04:03:49 +03:00
|
|
|
system_args = parser.parse_args()
|
2020-07-02 04:21:35 +03:00
|
|
|
|
|
|
|
# Setup Logging
|
2020-11-18 04:13:10 +03:00
|
|
|
version = utils.get_software_version()
|
2020-11-18 04:03:49 +03:00
|
|
|
log_file = os.path.normpath(os.path.expanduser(system_args.logfile))
|
|
|
|
system_args.logfile = log_file
|
2020-11-18 04:13:10 +03:00
|
|
|
system_args.software_version = version
|
|
|
|
ql = utils.setup_logging(log_file, version)
|
2020-07-02 04:21:35 +03:00
|
|
|
|
2020-07-27 21:56:23 +03:00
|
|
|
if sys.version_info < (3, 7):
|
2020-08-11 19:59:47 +03:00
|
|
|
msg = f"Moonraker requires Python 3.7 or above. " \
|
|
|
|
f"Detected Version: {sys.version}"
|
2020-07-27 21:56:23 +03:00
|
|
|
logging.info(msg)
|
|
|
|
print(msg)
|
2020-08-31 22:22:48 +03:00
|
|
|
ql.stop()
|
2020-07-27 21:56:23 +03:00
|
|
|
exit(1)
|
|
|
|
|
2020-07-02 04:21:35 +03:00
|
|
|
# Start IOLoop and Server
|
|
|
|
io_loop = IOLoop.current()
|
2020-10-11 15:48:47 +03:00
|
|
|
estatus = 0
|
|
|
|
while True:
|
|
|
|
try:
|
2020-11-18 04:03:49 +03:00
|
|
|
server = Server(system_args)
|
2020-10-11 15:48:47 +03:00
|
|
|
except Exception:
|
|
|
|
logging.exception("Moonraker Error")
|
|
|
|
estatus = 1
|
|
|
|
break
|
|
|
|
try:
|
|
|
|
server.start()
|
|
|
|
io_loop.start()
|
|
|
|
except Exception:
|
|
|
|
logging.exception("Server Running Error")
|
|
|
|
estatus = 1
|
|
|
|
break
|
|
|
|
# Since we are running outside of the the server
|
|
|
|
# it is ok to use a blocking sleep here
|
|
|
|
time.sleep(.5)
|
|
|
|
logging.info("Attempting Server Restart...")
|
2020-07-02 04:21:35 +03:00
|
|
|
io_loop.close(True)
|
|
|
|
logging.info("Server Shutdown")
|
2020-08-31 22:22:48 +03:00
|
|
|
ql.stop()
|
2020-10-11 15:48:47 +03:00
|
|
|
exit(estatus)
|
2020-07-02 04:21:35 +03:00
|
|
|
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
|
|
|
main()
|