moonraker/moonraker/server.py

647 lines
23 KiB
Python
Executable File

#!/usr/bin/env python3
# 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
from __future__ import annotations
import pathlib
import sys
import argparse
import importlib
import os
import io
import time
import socket
import logging
import signal
import asyncio
import uuid
import traceback
from . import confighelper
from .eventloop import EventLoop
from .app import MoonrakerApp
from .klippy_connection import KlippyConnection
from .utils import ServerError, Sentinel, get_software_info
from .loghelper import LogManager
# Annotation imports
from typing import (
TYPE_CHECKING,
Any,
Optional,
Callable,
Coroutine,
Dict,
List,
Tuple,
Union,
TypeVar,
)
if TYPE_CHECKING:
from .common import WebRequest
from .websockets import WebsocketManager
from .components.file_manager.file_manager import FileManager
from .components.machine import Machine
from .components.extensions import ExtensionManager
FlexCallback = Callable[..., Optional[Coroutine]]
_T = TypeVar("_T", Sentinel, Any)
API_VERSION = (1, 3, 0)
CORE_COMPONENTS = [
'dbus_manager', 'database', 'file_manager', 'klippy_apis',
'machine', 'data_store', 'shell_command', 'proc_stats',
'job_state', 'job_queue', 'http_client', 'announcements',
'webcam', 'extensions',
]
class Server:
error = ServerError
config_error = confighelper.ConfigError
def __init__(self,
args: Dict[str, Any],
log_manager: LogManager,
event_loop: EventLoop
) -> None:
self.event_loop = event_loop
self.log_manager = log_manager
self.app_args = args
self.events: Dict[str, List[FlexCallback]] = {}
self.components: Dict[str, Any] = {}
self.failed_components: List[str] = []
self.warnings: Dict[str, str] = {}
self._is_configured: bool = False
self.config = config = self._parse_config()
self.host: str = config.get('host', "0.0.0.0")
self.port: int = config.getint('port', 7125)
self.ssl_port: int = config.getint('ssl_port', 7130)
self.exit_reason: str = ""
self.server_running: bool = False
# Configure Debug Logging
config.getboolean('enable_debug_logging', False, deprecate=True)
self.debug = args["debug"]
log_level = logging.DEBUG if args["verbose"] else logging.INFO
logging.getLogger().setLevel(log_level)
self.event_loop.set_debug(args["asyncio_debug"])
self.klippy_connection = KlippyConnection(self)
# Tornado Application/Server
self.moonraker_app = app = MoonrakerApp(config)
self.register_endpoint = app.register_local_handler
self.register_debug_endpoint = app.register_debug_handler
self.register_static_file_handler = app.register_static_file_handler
self.register_upload_handler = app.register_upload_handler
self.register_api_transport = app.register_api_transport
self.log_manager.set_server(self)
for warning in args.get("startup_warnings", []):
self.add_warning(warning)
self.register_endpoint(
"/server/info", ['GET'], self._handle_info_request)
self.register_endpoint(
"/server/config", ['GET'], self._handle_config_request)
self.register_endpoint(
"/server/restart", ['POST'], self._handle_server_restart)
self.register_notification("server:klippy_ready")
self.register_notification("server:klippy_shutdown")
self.register_notification("server:klippy_disconnect",
"klippy_disconnected")
self.register_notification("server:gcode_response")
def get_app_args(self) -> Dict[str, Any]:
return dict(self.app_args)
def get_event_loop(self) -> EventLoop:
return self.event_loop
def get_api_version(self) -> Tuple[int, int, int]:
return API_VERSION
def get_warnings(self) -> List[str]:
return list(self.warnings.values())
def is_running(self) -> bool:
return self.server_running
def is_configured(self) -> bool:
return self._is_configured
def is_debug_enabled(self) -> bool:
return self.debug
def is_verbose_enabled(self) -> bool:
return self.app_args["verbose"]
def _parse_config(self) -> confighelper.ConfigHelper:
config = confighelper.get_configuration(self, self.app_args)
# log config file
cfg_files = "\n".join(config.get_config_files())
strio = io.StringIO()
config.write_config(strio)
cfg_item = f"\n{'#'*20} Moonraker Configuration {'#'*20}\n\n"
cfg_item += strio.getvalue()
cfg_item += "#"*65
cfg_item += f"\nAll Configuration Files:\n{cfg_files}\n"
cfg_item += "#"*65
strio.close()
self.add_log_rollover_item('config', cfg_item)
return config
async def server_init(self, start_server: bool = True) -> None:
self.event_loop.add_signal_handler(
signal.SIGTERM, self._handle_term_signal)
# Perform asynchronous init after the event loop starts
optional_comps: List[Coroutine] = []
for name, component in self.components.items():
if not hasattr(component, "component_init"):
continue
if name in CORE_COMPONENTS:
# Process core components in order synchronously
await self._initialize_component(name, component)
else:
optional_comps.append(
self._initialize_component(name, component))
# Asynchronous Optional Component Initialization
if optional_comps:
await asyncio.gather(*optional_comps)
if not self.warnings:
await self.event_loop.run_in_thread(self.config.create_backup)
machine: Machine = self.lookup_component("machine")
if await machine.validate_installation():
return
if start_server:
await self.start_server()
async def start_server(self, connect_to_klippy: bool = True) -> None:
# Open Unix Socket Server
extm: ExtensionManager = self.lookup_component("extensions")
await extm.start_unix_server()
# Start HTTP Server
logging.info(
f"Starting Moonraker on ({self.host}, {self.port}), "
f"Hostname: {socket.gethostname()}")
self.moonraker_app.listen(self.host, self.port, self.ssl_port)
self.server_running = True
if connect_to_klippy:
self.klippy_connection.connect()
def add_log_rollover_item(
self, name: str, item: str, log: bool = True
) -> None:
self.log_manager.set_rollover_info(name, item)
if log and item is not None:
logging.info(item)
def add_warning(
self, warning: str, warn_id: Optional[str] = None, log: bool = True
) -> str:
if warn_id is None:
warn_id = str(id(warning))
self.warnings[warn_id] = warning
if log:
logging.warning(warning)
return warn_id
def remove_warning(self, warn_id: str) -> None:
self.warnings.pop(warn_id, None)
# ***** Component Management *****
async def _initialize_component(self, name: str, component: Any) -> None:
logging.info(f"Performing Component Post Init: [{name}]")
try:
ret = component.component_init()
if ret is not None:
await ret
except Exception as e:
logging.exception(f"Component [{name}] failed post init")
self.add_warning(f"Component '{name}' failed to load with "
f"error: {e}")
self.set_failed_component(name)
def load_components(self) -> None:
config = self.config
cfg_sections = set([s.split()[0] for s in config.sections()])
cfg_sections.remove('server')
# load core components
for component in CORE_COMPONENTS:
self.load_component(config, component)
if component in cfg_sections:
cfg_sections.remove(component)
# load remaining optional components
for section in cfg_sections:
self.load_component(config, section, None)
self.klippy_connection.configure(config)
config.validate_config()
self._is_configured = True
def load_component(
self,
config: confighelper.ConfigHelper,
component_name: str,
default: _T = Sentinel.MISSING
) -> Union[_T, Any]:
if component_name in self.components:
return self.components[component_name]
if self.is_configured():
raise self.error(
"Cannot load components after configuration", 500
)
if component_name in self.failed_components:
raise self.error(
f"Component {component_name} previously failed to load", 500
)
try:
full_name = f"moonraker.components.{component_name}"
module = importlib.import_module(full_name)
is_core = component_name in CORE_COMPONENTS
fallback: Optional[str] = "server" if is_core else None
config = config.getsection(component_name, fallback)
load_func = getattr(module, "load_component")
component = load_func(config)
except Exception:
msg = f"Unable to load component: ({component_name})"
logging.exception(msg)
if component_name not in self.failed_components:
self.failed_components.append(component_name)
if default is Sentinel.MISSING:
raise
return default
self.components[component_name] = component
logging.info(f"Component ({component_name}) loaded")
return component
def lookup_component(
self, component_name: str, default: _T = Sentinel.MISSING
) -> Union[_T, Any]:
component = self.components.get(component_name, default)
if component is Sentinel.MISSING:
raise ServerError(f"Component ({component_name}) not found")
return component
def set_failed_component(self, component_name: str) -> None:
if component_name not in self.failed_components:
self.failed_components.append(component_name)
def register_component(self, component_name: str, component: Any) -> None:
if component_name in self.components:
raise self.error(
f"Component '{component_name}' already registered")
self.components[component_name] = component
def register_notification(
self, event_name: str, notify_name: Optional[str] = None
) -> None:
wsm: WebsocketManager = self.lookup_component("websockets")
wsm.register_notification(event_name, notify_name)
def register_event_handler(
self, event: str, callback: FlexCallback
) -> None:
self.events.setdefault(event, []).append(callback)
def send_event(self, event: str, *args) -> asyncio.Future:
fut = self.event_loop.create_future()
self.event_loop.register_callback(
self._process_event, fut, event, *args)
return fut
async def _process_event(
self, fut: asyncio.Future, event: str, *args
) -> None:
events = self.events.get(event, [])
coroutines: List[Coroutine] = []
for func in events:
try:
ret = func(*args)
except Exception:
logging.exception(f"Error processing callback in event {event}")
else:
if ret is not None:
coroutines.append(ret)
if coroutines:
results = await asyncio.gather(*coroutines, return_exceptions=True)
for val in results:
if isinstance(val, Exception):
if sys.version_info < (3, 10):
exc_info = "".join(traceback.format_exception(
type(val), val, val.__traceback__
))
else:
exc_info = "".join(traceback.format_exception(val))
logging.info(
f"\nError processing callback in event {event}\n{exc_info}"
)
if not fut.done():
fut.set_result(None)
def register_remote_method(
self, method_name: str, cb: FlexCallback
) -> None:
self.klippy_connection.register_remote_method(method_name, cb)
def get_host_info(self) -> Dict[str, Any]:
return {
'hostname': socket.gethostname(),
'address': self.host,
'port': self.port,
'ssl_port': self.ssl_port
}
def get_klippy_info(self) -> Dict[str, Any]:
return self.klippy_connection.klippy_info
def get_klippy_state(self) -> str:
return self.klippy_connection.state
def _handle_term_signal(self) -> None:
logging.info(f"Exiting with signal SIGTERM")
self.event_loop.register_callback(self._stop_server, "terminate")
async def _stop_server(self, exit_reason: str = "restart") -> None:
self.server_running = False
# Call each component's "on_exit" method
for name, component in self.components.items():
if hasattr(component, "on_exit"):
func: FlexCallback = getattr(component, "on_exit")
try:
ret = func()
if ret is not None:
await ret
except Exception:
logging.exception(
f"Error executing 'on_exit()' for component: {name}")
# Sleep for 100ms to allow connected websockets to write out
# remaining data
await asyncio.sleep(.1)
try:
await self.moonraker_app.close()
except Exception:
logging.exception("Error Closing App")
# Disconnect from Klippy
try:
await asyncio.wait_for(
asyncio.shield(self.klippy_connection.close(
wait_closed=True)), 2.)
except Exception:
logging.exception("Klippy Disconnect Error")
# Close all components
for name, component in self.components.items():
if name in ["application", "websockets", "klippy_connection"]:
# These components have already been closed
continue
if hasattr(component, "close"):
func = getattr(component, "close")
try:
ret = func()
if ret is not None:
await ret
except Exception:
logging.exception(
f"Error executing 'close()' for component: {name}")
# Allow cancelled tasks a chance to run in the eventloop
await asyncio.sleep(.001)
self.exit_reason = exit_reason
self.event_loop.remove_signal_handler(signal.SIGTERM)
self.event_loop.stop()
async def _handle_server_restart(self, web_request: WebRequest) -> str:
self.event_loop.register_callback(self._stop_server)
return "ok"
async def _handle_info_request(self, web_request: WebRequest) -> Dict[str, Any]:
raw = web_request.get_boolean("raw", False)
file_manager: Optional[FileManager] = self.lookup_component(
'file_manager', None)
reg_dirs = []
if file_manager is not None:
reg_dirs = file_manager.get_registered_dirs()
wsm: WebsocketManager = self.lookup_component('websockets')
mreqs = self.klippy_connection.missing_requirements
if raw:
warnings = list(self.warnings.values())
else:
warnings = [
w.replace("\n", "<br/>") for w in self.warnings.values()
]
return {
'klippy_connected': self.klippy_connection.is_connected(),
'klippy_state': self.klippy_connection.state,
'components': list(self.components.keys()),
'failed_components': self.failed_components,
'registered_directories': reg_dirs,
'warnings': warnings,
'websocket_count': wsm.get_count(),
'moonraker_version': self.app_args['software_version'],
'missing_klippy_requirements': mreqs,
'api_version': API_VERSION,
'api_version_string': ".".join([str(v) for v in API_VERSION])
}
async def _handle_config_request(self, web_request: WebRequest) -> Dict[str, Any]:
cfg_file_list: List[Dict[str, Any]] = []
cfg_parent = pathlib.Path(
self.app_args["config_file"]
).expanduser().resolve().parent
for fname, sections in self.config.get_file_sections().items():
path = pathlib.Path(fname)
try:
rel_path = str(path.relative_to(str(cfg_parent)))
except ValueError:
rel_path = fname
cfg_file_list.append({"filename": rel_path, "sections": sections})
return {
'config': self.config.get_parsed_config(),
'orig': self.config.get_orig_config(),
'files': cfg_file_list
}
def main(from_package: bool = True) -> None:
def get_env_bool(key: str) -> bool:
return os.getenv(key, "").lower() in ["y", "yes", "true"]
# Parse start arguments
parser = argparse.ArgumentParser(
description="Moonraker - Klipper API Server")
parser.add_argument(
"-d", "--datapath",
default=os.getenv("MOONRAKER_DATA_PATH"),
metavar='<data path>',
help="Location of Moonraker Data File Path"
)
parser.add_argument(
"-c", "--configfile",
default=os.getenv("MOONRAKER_CONFIG_PATH"),
metavar='<configfile>',
help="Path to Moonraker's configuration file"
)
parser.add_argument(
"-l", "--logfile",
default=os.getenv("MOONRAKER_LOG_PATH"),
metavar='<logfile>',
help="Path to Moonraker's log file"
)
parser.add_argument(
"-u", "--unixsocket",
default=os.getenv("MOONRAKER_UDS_PATH"),
metavar="<unixsocket>",
help="Path to Moonraker's unix domain socket"
)
parser.add_argument(
"-n", "--nologfile",
action='store_const',
const=True,
default=get_env_bool("MOONRAKER_DISABLE_FILE_LOG"),
help="disable logging to a file"
)
parser.add_argument(
"-v", "--verbose",
action='store_const',
const=True,
default=get_env_bool("MOONRAKER_VERBOSE_LOGGING"),
help="Enable verbose logging"
)
parser.add_argument(
"-g", "--debug",
action='store_const',
const=True,
default=get_env_bool("MOONRAKER_ENABLE_DEBUG"),
help="Enable Moonraker debug features"
)
parser.add_argument(
"-o", "--asyncio-debug",
action='store_const',
const=True,
default=get_env_bool("MOONRAKER_ASYNCIO_DEBUG"),
help="Enable asyncio debug flag"
)
cmd_line_args = parser.parse_args()
startup_warnings: List[str] = []
dp: str = cmd_line_args.datapath or "~/printer_data"
data_path = pathlib.Path(dp).expanduser().resolve()
if not data_path.exists():
try:
data_path.mkdir()
except Exception:
startup_warnings.append(
f"Unable to create data path folder at {data_path}"
)
uuid_path = data_path.joinpath(".moonraker.uuid")
if not uuid_path.is_file():
instance_uuid = uuid.uuid4().hex
uuid_path.write_text(instance_uuid)
else:
instance_uuid = uuid_path.read_text().strip()
if cmd_line_args.configfile is not None:
cfg_file: str = cmd_line_args.configfile
else:
cfg_file = str(data_path.joinpath("config/moonraker.conf"))
if cmd_line_args.unixsocket is not None:
unix_sock: str = cmd_line_args.unixsocket
else:
comms_dir = data_path.joinpath("comms")
if not comms_dir.exists():
comms_dir.mkdir()
unix_sock = str(comms_dir.joinpath("moonraker.sock"))
app_args = {
"data_path": str(data_path),
"is_default_data_path": cmd_line_args.datapath is None,
"config_file": cfg_file,
"startup_warnings": startup_warnings,
"verbose": cmd_line_args.verbose,
"debug": cmd_line_args.debug,
"asyncio_debug": cmd_line_args.asyncio_debug,
"is_backup_config": False,
"is_python_package": from_package,
"instance_uuid": instance_uuid,
"unix_socket_path": unix_sock
}
# Setup Logging
app_args.update(get_software_info())
if cmd_line_args.nologfile:
app_args["log_file"] = ""
elif cmd_line_args.logfile:
app_args["log_file"] = os.path.normpath(
os.path.expanduser(cmd_line_args.logfile))
else:
app_args["log_file"] = str(data_path.joinpath("logs/moonraker.log"))
app_args["python_version"] = sys.version.replace("\n", " ")
log_manager = LogManager(app_args, startup_warnings)
# Start asyncio event loop and server
event_loop = EventLoop()
alt_config_loaded = False
estatus = 0
while True:
try:
server = Server(app_args, log_manager, event_loop)
server.load_components()
except confighelper.ConfigError as e:
backup_cfg = confighelper.find_config_backup(cfg_file)
logging.exception("Server Config Error")
if alt_config_loaded or backup_cfg is None:
estatus = 1
break
app_args["config_file"] = backup_cfg
app_args["is_backup_config"] = True
warn_list = list(startup_warnings)
app_args["startup_warnings"] = warn_list
warn_list.append(
f"Server configuration error: {e}\n"
f"Loaded server from most recent working configuration:"
f" '{app_args['config_file']}'\n"
f"Please fix the issue in moonraker.conf and restart "
f"the server."
)
alt_config_loaded = True
continue
except Exception:
logging.exception("Moonraker Error")
estatus = 1
break
try:
event_loop.register_callback(server.server_init)
event_loop.start()
except Exception:
logging.exception("Server Running Error")
estatus = 1
break
if server.exit_reason == "terminate":
break
# Restore the original config and clear the warning
# before the server restarts
if alt_config_loaded:
app_args["config_file"] = cfg_file
app_args["startup_warnings"] = startup_warnings
app_args["is_backup_config"] = False
alt_config_loaded = False
event_loop.close()
# 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...")
event_loop.reset()
event_loop.close()
logging.info("Server Shutdown")
log_manager.stop_logging()
exit(estatus)