From e7c9bdd8a88aca199b980d8091c6e0f3d43cd862 Mon Sep 17 00:00:00 2001 From: packerlschupfer <83344883+packerlschupfer@users.noreply.github.com> Date: Sat, 6 Jun 2026 05:12:42 +0200 Subject: [PATCH] Print Host: add Moonraker (Klipper) host type (#13991) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OrcaSlicer currently ships an "Octo/Klipper" host type that maps to the OctoPrint REST endpoints (api/version, api/files/local). It works for Klipper setups that run Moonraker with the OctoPrint-emulation plugin, but native Moonraker — and Moonraker-compatible firmwares like the Prusa-Firmware-Buddy buddy-klipper fork — speak a different shape: distinct paths, JSON body for /printer/print/start, {"result":...} envelope. There's no host type for that today. Add a new Moonraker class deriving from PrintHost. Endpoints used, matching the Moonraker spec: - GET /server/info — connection test, reads result.klippy_state - GET /server/files/roots — storage-picker dropdown (returns roots with 'w' permission); gracefully degrades if absent - POST /server/files/upload (multipart) — upload (form fields: file, root) - POST /printer/print/start (json) — {"filename":""}; the filename is whatever the upload response returned in result.item.path, so any server-side rename (collision suffix etc.) is respected. JSON body is built via property_tree write_json so exotic characters in the path are properly escaped. Auth: X-Api-Key header, only when printhost_apikey is non-empty (Moonraker can be configured to require it but doesn't by default). HTTP Basic / Digest are not part of the Moonraker spec and are not sent. Storage root is read from upload_data.storage with "gcodes" as the fallback default, so the existing storage-picker plumbing in PrintHostDialogs lights up automatically once enumerable roots are returned. UI: registers as the "Moonraker (Klipper)" entry under host_type; selectable via the existing Physical Printer dialog (sidebar's connection button on the printer card). Verified against a Prusa-Firmware-Buddy buddy-klipper fork (firmware identifies as moonraker_version "0.8.0-prusalink-shim"): /server/info test, multipart upload to /server/files/upload, and JSON /printer/print/start all work end-to-end. The existing "Octo/Klipper" entry is left untouched so users currently relying on Moonraker's OctoPrint-emulation plugin keep working. --- src/libslic3r/PrintConfig.cpp | 5 +- src/libslic3r/PrintConfig.hpp | 2 +- src/slic3r/CMakeLists.txt | 2 + src/slic3r/Utils/Moonraker.cpp | 311 +++++++++++++++++++++++++++++++++ src/slic3r/Utils/Moonraker.hpp | 63 +++++++ src/slic3r/Utils/PrintHost.cpp | 2 + 6 files changed, 383 insertions(+), 2 deletions(-) create mode 100644 src/slic3r/Utils/Moonraker.cpp create mode 100644 src/slic3r/Utils/Moonraker.hpp diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index a770258a4e..2a6891d587 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -149,7 +149,8 @@ static t_config_enum_values s_keys_map_PrintHostType { { "flashforge", htFlashforge }, { "simplyprint", htSimplyPrint }, { "elegoolink", htElegooLink }, - { "3dprinteros", ht3DPrinterOS } + { "3dprinteros", ht3DPrinterOS }, + { "moonraker", htMoonraker } }; CONFIG_OPTION_ENUM_DEFINE_STATIC_MAPS(PrintHostType) @@ -4862,6 +4863,7 @@ void PrintConfigDef::init_fff_params() def->enum_values.push_back("simplyprint"); def->enum_values.push_back("elegoolink"); def->enum_values.push_back("3dprinteros"); + def->enum_values.push_back("moonraker"); def->enum_labels.push_back("PrusaLink"); def->enum_labels.push_back("PrusaConnect"); def->enum_labels.push_back("Octo/Klipper"); @@ -4877,6 +4879,7 @@ void PrintConfigDef::init_fff_params() def->enum_labels.push_back("SimplyPrint"); def->enum_labels.push_back("Elegoo Link"); def->enum_labels.push_back("3DPrinterOS"); + def->enum_labels.push_back("Moonraker (Klipper)"); def->mode = comAdvanced; def->cli = ConfigOptionDef::nocli; def->set_default_value(new ConfigOptionEnum(htOctoPrint)); diff --git a/src/libslic3r/PrintConfig.hpp b/src/libslic3r/PrintConfig.hpp index c8dd8665c4..352d3b5f67 100644 --- a/src/libslic3r/PrintConfig.hpp +++ b/src/libslic3r/PrintConfig.hpp @@ -77,7 +77,7 @@ enum class WipeTowerType { }; enum PrintHostType { - htPrusaLink, htPrusaConnect, htOctoPrint, htDuet, htFlashAir, htAstroBox, htRepetier, htMKS, htESP3D, htCrealityPrint, htObico, htFlashforge, htSimplyPrint, htElegooLink, ht3DPrinterOS + htPrusaLink, htPrusaConnect, htOctoPrint, htDuet, htFlashAir, htAstroBox, htRepetier, htMKS, htESP3D, htCrealityPrint, htObico, htFlashforge, htSimplyPrint, htElegooLink, ht3DPrinterOS, htMoonraker }; enum AuthorizationType { diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 34286d1411..805cbafbd5 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -626,6 +626,8 @@ set(SLIC3R_GUI_SOURCES Utils/SnapmakerPrinterAgent.hpp Utils/MoonrakerPrinterAgent.cpp Utils/MoonrakerPrinterAgent.hpp + Utils/Moonraker.cpp + Utils/Moonraker.hpp Utils/BBLCloudServiceAgent.cpp Utils/BBLCloudServiceAgent.hpp Utils/BBLPrinterAgent.cpp diff --git a/src/slic3r/Utils/Moonraker.cpp b/src/slic3r/Utils/Moonraker.cpp new file mode 100644 index 0000000000..33a0960426 --- /dev/null +++ b/src/slic3r/Utils/Moonraker.cpp @@ -0,0 +1,311 @@ +#include "Moonraker.hpp" + +#include + +#include +#include +#include +#include + +#include "libslic3r/PrintConfig.hpp" +#include "slic3r/GUI/I18N.hpp" +#include "slic3r/GUI/GUI.hpp" +#include "slic3r/GUI/format.hpp" +#include "Http.hpp" + +namespace pt = boost::property_tree; + +namespace Slic3r { + +Moonraker::Moonraker(DynamicPrintConfig *config) + : m_host(config->opt_string("print_host")) + , m_apikey(config->opt_string("printhost_apikey")) + , m_cafile(config->opt_string("printhost_cafile")) + , m_ssl_revoke_best_effort(config->opt_bool("printhost_ssl_ignore_revoke")) +{} + +const char* Moonraker::get_name() const { return "Moonraker"; } + +wxString Moonraker::get_test_ok_msg() const +{ + return _(L("Connection to Moonraker is working correctly.")); +} + +wxString Moonraker::get_test_failed_msg(wxString &msg) const +{ + return GUI::format_wxstr("%s: %s", _L("Could not connect to Moonraker"), msg); +} + +std::string Moonraker::make_url(const std::string &path) const +{ + if (m_host.find("http://") == 0 || m_host.find("https://") == 0) { + if (m_host.back() == '/') + return (boost::format("%1%%2%") % m_host % path).str(); + return (boost::format("%1%/%2%") % m_host % path).str(); + } + return (boost::format("http://%1%/%2%") % m_host % path).str(); +} + +void Moonraker::set_auth(Http &http) const +{ + //ORCA: Moonraker accepts unauthenticated requests by default; X-Api-Key is the only auth header + // defined by the Moonraker spec. HTTP Basic / Digest do NOT belong here even if the user + // filled the user/password fields — those are PrusaLink/OctoPrint conventions. + if (!m_apikey.empty()) + http.header("X-Api-Key", m_apikey); + if (!m_cafile.empty()) + http.ca_file(m_cafile); +} + +bool Moonraker::test(wxString &msg) const +{ + //ORCA: Moonraker's /server/info returns + // { "result": { "klippy_state": "ready|startup|shutdown|error|disconnected", ... } } + // We treat the connection as healthy as long as the envelope is valid and `klippy_state` + // is present — matching the OctoPrint/PrusaLink convention of "can I reach this host?". + // Klipper state (idle, error, etc.) is surfaced to the log but does not gate the test: + // buddy-fork firmwares legitimately report non-`ready` states at idle, and any real upload + // problem will surface a contextual error at upload() time anyway. + const char *name = get_name(); + bool res = true; + auto url = make_url("server/info"); + + BOOST_LOG_TRIVIAL(info) << boost::format("%1%: Get server info at: %2%") % name % url; + + auto http = Http::get(std::move(url)); + set_auth(http); + http.on_error([&](std::string body, std::string error, unsigned status) { + BOOST_LOG_TRIVIAL(error) << boost::format("%1%: Error getting server info: %2%, HTTP %3%, body: `%4%`") + % name % error % status % body; + res = false; + msg = format_error(body, error, status); + }) + .on_complete([&, this](std::string body, unsigned) { + BOOST_LOG_TRIVIAL(debug) << boost::format("%1%: /server/info body: %2%") % name % body; + try { + std::stringstream ss(body); + pt::ptree ptree; + pt::read_json(ss, ptree); + + const auto klippy_state = ptree.get_optional("result.klippy_state"); + if (!klippy_state) { + //ORCA: response wasn't shaped like a Moonraker /server/info reply — likely an OctoPrint + // or PrusaLink host the user mis-selected as Moonraker, or a totally different + // service. Treat as a connection failure with a clear hint. + res = false; + msg = _L("The host responded but it doesn't look like Moonraker (missing result.klippy_state)."); + return; + } + BOOST_LOG_TRIVIAL(info) << boost::format("%1%: klippy_state = %2%") % name % (*klippy_state); + } catch (const std::exception &ex) { + res = false; + msg = GUI::format_wxstr(_L("Could not parse Moonraker server response: %s"), ex.what()); + } + }) +#ifdef WIN32 + .ssl_revoke_best_effort(m_ssl_revoke_best_effort) +#endif + .perform_sync(); + + return res; +} + +bool Moonraker::get_storage(wxArrayString &storage_path, wxArrayString &storage_name) const +{ + //ORCA: GET /server/files/roots enumerates Moonraker's storage roots (default "gcodes" plus any + // configured extras like "config", "logs", "timelapse"). Only roots with permissions + // including "rw" or "rwd" can receive uploads; we filter to those so the UI dropdown only + // offers usable destinations. The base class returns false (no per-host storage); returning + // true here populates the storage picker in PrintHostDialogs's send-to-print dialog. + // Failures (404 — older Moonraker, or a buddy-fork that doesn't implement the endpoint) + // gracefully degrade to false so upload() falls back to the hardcoded "gcodes" default. + const char *name = get_name(); + bool got_any = false; + auto url = make_url("server/files/roots"); + + BOOST_LOG_TRIVIAL(info) << boost::format("%1%: Enumerating storage roots at: %2%") % name % url; + + auto http = Http::get(std::move(url)); + set_auth(http); + http.on_error([&](std::string body, std::string error, unsigned status) { + //ORCA: /server/files/roots is optional in the Moonraker spec and absent on older versions + // and slimmer shims (e.g. Prusa-Firmware-Buddy 0.8.x prusalink-shim returns 501). A + // missing endpoint here is benign — upload() silently falls back to the hardcoded + // "gcodes" root — so don't pollute the log at warning level for it. Other HTTP + // errors still warn. + if (status == 404 || status == 501) { + BOOST_LOG_TRIVIAL(debug) << boost::format("%1%: /server/files/roots not implemented (HTTP %2%); upload() will fall back to the \"gcodes\" root.") + % name % status; + } else { + BOOST_LOG_TRIVIAL(warning) << boost::format("%1%: Could not enumerate roots: %2%, HTTP %3%, body: `%4%`") + % name % error % status % body; + } + }) + .on_complete([&, this](std::string body, unsigned) { + BOOST_LOG_TRIVIAL(debug) << boost::format("%1%: /server/files/roots body: %2%") % name % body; + try { + std::stringstream ss(body); + pt::ptree ptree; + pt::read_json(ss, ptree); + const auto result_node = ptree.get_child_optional("result"); + if (!result_node) + return; + for (const auto &child : *result_node) { + const std::string &root = child.second.get("name", ""); + const std::string &perms = child.second.get("permissions", ""); + if (root.empty() || perms.find('w') == std::string::npos) + continue; + storage_path.Add(wxString::FromUTF8(root)); + storage_name.Add(wxString::FromUTF8(root)); + got_any = true; + } + } catch (const std::exception &ex) { + BOOST_LOG_TRIVIAL(warning) << boost::format("%1%: Could not parse roots: %2%") % name % ex.what(); + } + }) +#ifdef WIN32 + .ssl_revoke_best_effort(m_ssl_revoke_best_effort) +#endif + .perform_sync(); + + return got_any; +} + +bool Moonraker::start_print(wxString &error_msg, const std::string &filename) const +{ + //ORCA: POST /printer/print/start with JSON body { "filename": ".gcode" }. + // `filename` is what /server/files/upload returned as result.item.path (the storage-relative + // path inside `root`, no leading slash, with extension). Build the body via property_tree + // so that special characters in the filename (server-side collision-suffix could produce + // paths with quotes / backslashes on exotic file systems) are properly escaped. + const char *name = get_name(); + bool res = true; + auto url = make_url("printer/print/start"); + pt::ptree body_tree; + body_tree.put("filename", filename); + std::ostringstream body_ss; + pt::write_json(body_ss, body_tree, /*pretty=*/false); + std::string body = body_ss.str(); + + BOOST_LOG_TRIVIAL(info) << boost::format("%1%: Starting print of %2% at %3%") % name % filename % url; + + auto http = Http::post(std::move(url)); + set_auth(http); + http.header("Content-Type", "application/json") + .set_post_body(body) + .on_complete([&](std::string body, unsigned status) { + BOOST_LOG_TRIVIAL(debug) << boost::format("%1%: print/start HTTP %2%: %3%") % name % status % body; + }) + .on_error([&](std::string body, std::string error, unsigned status) { + BOOST_LOG_TRIVIAL(error) << boost::format("%1%: Error starting print at %2%: %3%, HTTP %4%, body: `%5%`") + % name % url % error % status % body; + res = false; + error_msg = format_error(body, error, status); + }) +#ifdef WIN32 + .ssl_revoke_best_effort(m_ssl_revoke_best_effort) +#endif + .perform_sync(); + + return res; +} + +bool Moonraker::upload(PrintHostUpload upload_data, ProgressFn progress_fn, ErrorFn error_fn, InfoFn info_fn) const +{ + //ORCA: POST /server/files/upload as multipart/form-data with: + // file = + // root = (Moonraker default: "gcodes") + // Successful response shape: + // { "result": { "item": { "path": ".gcode", "root": "" }, "print_started": } } + // We always start the print explicitly via /printer/print/start regardless of `print_started` + // so the user can rely on a single call site for state. + wxString test_msg; + if (!test(test_msg)) { + error_fn(std::move(test_msg)); + return false; + } + + const char *name = get_name(); + const auto upload_filename = upload_data.upload_path.filename(); + const auto upload_parent_path = upload_data.upload_path.parent_path(); + //ORCA: upload_data.storage is plumbed from the (future) per-printer storage dropdown. When unset, + // fall back to the Moonraker-standard "gcodes" root. Reading it through here means a UI + // addition later (storage picker) needs no change to this method. + const std::string root = upload_data.storage.empty() ? std::string("gcodes") : upload_data.storage; + + std::string url = make_url("server/files/upload"); + bool result = true; + std::string uploaded_path; + + BOOST_LOG_TRIVIAL(info) << boost::format("%1%: Uploading file %2% to %3% (root=%4%, filename=%5%, start_print=%6%)") + % name + % upload_data.source_path + % url + % root + % upload_filename.string() + % (upload_data.post_action == PrintHostPostUploadAction::StartPrint ? "true" : "false"); + + auto http = Http::post(std::move(url)); + set_auth(http); + http.form_add("root", root) + .form_add_file("file", upload_data.source_path.string(), upload_filename.string()) + .on_complete([&](std::string body, unsigned status) { + BOOST_LOG_TRIVIAL(debug) << boost::format("%1%: upload HTTP %2%: %3%") % name % status % body; + try { + std::stringstream ss(body); + pt::ptree ptree; + pt::read_json(ss, ptree); + + //ORCA: Moonraker confirms the storage-relative path in result.item.path. We pass exactly + // that string to /printer/print/start so any server-side renaming (collision suffix, + // etc.) is respected. + const auto stored_path = ptree.get_optional("result.item.path"); + if (stored_path) { + uploaded_path = *stored_path; + } else { + //ORCA: fallback if the server response omits result.item.path (older Moonraker, or + // a buddy-fork that returns a slimmer envelope). Use the original filename. + uploaded_path = upload_filename.string(); + BOOST_LOG_TRIVIAL(warning) << boost::format( + "%1%: upload response missing result.item.path, falling back to original filename `%2%`") + % name % uploaded_path; + } + } catch (const std::exception &ex) { + BOOST_LOG_TRIVIAL(warning) << boost::format( + "%1%: could not parse upload response (%2%); falling back to original filename") + % name % ex.what(); + uploaded_path = upload_filename.string(); + } + }) + .on_error([&](std::string body, std::string error, unsigned status) { + BOOST_LOG_TRIVIAL(error) << boost::format("%1%: Error uploading to %2%: %3%, HTTP %4%, body: `%5%`") + % name % url % error % status % body; + error_fn(format_error(body, error, status)); + result = false; + }) + .on_progress([&](Http::Progress progress, bool &cancel) { + progress_fn(std::move(progress), cancel); + if (cancel) { + BOOST_LOG_TRIVIAL(info) << name << ": Upload canceled"; + result = false; + } + }) +#ifdef WIN32 + .ssl_revoke_best_effort(m_ssl_revoke_best_effort) +#endif + .perform_sync(); + + if (!result) + return false; + + if (upload_data.post_action == PrintHostPostUploadAction::StartPrint && !uploaded_path.empty()) { + wxString start_msg; + if (!start_print(start_msg, uploaded_path)) { + error_fn(std::move(start_msg)); + return false; + } + } + return true; +} + +} diff --git a/src/slic3r/Utils/Moonraker.hpp b/src/slic3r/Utils/Moonraker.hpp new file mode 100644 index 0000000000..740d6f0f2e --- /dev/null +++ b/src/slic3r/Utils/Moonraker.hpp @@ -0,0 +1,63 @@ +#ifndef slic3r_Moonraker_hpp_ +#define slic3r_Moonraker_hpp_ + +#include +#include +#include + +#include "PrintHost.hpp" +#include "libslic3r/PrintConfig.hpp" + + +namespace Slic3r { + +class DynamicPrintConfig; +class Http; + +// Moonraker is the JSON / WebSocket gateway that ships in front of Klipper +// (and on Klipper-API-compatible firmwares like the Prusa-Firmware-Buddy +// Buddy-Klipper fork). REST shape differs from OctoPrint: distinct paths, +// JSON body for print/start, {"result":...}/{"error":...} envelope. +// +// Endpoints used: +// GET /server/info -- connection test, reads klippy_state +// POST /server/files/upload (multipart) -- upload gcode (form fields: file, root) +// POST /printer/print/start (json) -- {"filename":".gcode"} starts print +// +// Auth: X-Api-Key header if `printhost_apikey` is non-empty; Moonraker accepts +// unauthenticated LAN access by default, so the key is optional. HTTP Basic / +// Digest are not part of the Moonraker spec and are not sent. +class Moonraker : public PrintHost +{ +public: + Moonraker(DynamicPrintConfig *config); + ~Moonraker() override = default; + + const char* get_name() const override; + + bool test(wxString &curl_msg) const override; + wxString get_test_ok_msg() const override; + wxString get_test_failed_msg(wxString &msg) const override; + bool upload(PrintHostUpload upload_data, ProgressFn progress_fn, ErrorFn error_fn, InfoFn info_fn) const override; + bool has_auto_discovery() const override { return false; } + bool can_test() const override { return true; } + PrintHostPostUploadActions get_post_upload_actions() const override { return PrintHostPostUploadAction::StartPrint; } + std::string get_host() const override { return m_host; } + bool get_storage(wxArrayString &storage_path, wxArrayString &storage_name) const override; + const std::string& get_apikey() const { return m_apikey; } + const std::string& get_cafile() const { return m_cafile; } + +protected: + std::string m_host; + std::string m_apikey; + std::string m_cafile; + bool m_ssl_revoke_best_effort; + + void set_auth(Http &http) const; + std::string make_url(const std::string &path) const; + bool start_print(wxString &error_msg, const std::string &filename) const; +}; + +} + +#endif diff --git a/src/slic3r/Utils/PrintHost.cpp b/src/slic3r/Utils/PrintHost.cpp index e64220d30a..7f69d5e087 100644 --- a/src/slic3r/Utils/PrintHost.cpp +++ b/src/slic3r/Utils/PrintHost.cpp @@ -28,6 +28,7 @@ #include "SimplyPrint.hpp" #include "ElegooLink.hpp" #include "3DPrinterOS.hpp" +#include "Moonraker.hpp" namespace fs = boost::filesystem; using boost::optional; @@ -69,6 +70,7 @@ PrintHost* PrintHost::get_print_host(DynamicPrintConfig *config) case htSimplyPrint: return new SimplyPrint(config); case htElegooLink: return new ElegooLink(config); case ht3DPrinterOS: return new C3DPrinterOS(config); + case htMoonraker: return new Moonraker(config); default: return nullptr; } } else {