Merge branch 'main' into pr/Noisyfox/13712

This commit is contained in:
SoftFever
2026-06-22 10:56:11 +08:00
2782 changed files with 331956 additions and 215963 deletions

667
src/slic3r/Utils/3DPrinterOS.cpp Executable file
View File

@@ -0,0 +1,667 @@
#include "3DPrinterOS.hpp"
#include <algorithm>
#include <sstream>
#include <exception>
#include <boost/format.hpp>
#include <boost/log/trivial.hpp>
#include <boost/property_tree/ptree.hpp>
#include <boost/property_tree/json_parser.hpp>
#include <boost/algorithm/string/predicate.hpp>
#include <wx/progdlg.h>
#include <wx/string.h>
#include <wx/event.h>
#include <wx/dialog.h>
#include <wx/radiobut.h>
#include "libslic3r/PrintConfig.hpp"
#include "libslic3r/Utils.hpp"
#include "slic3r/GUI/I18N.hpp"
#include "slic3r/GUI/GUI.hpp"
#include "slic3r/GUI/format.hpp"
#include "slic3r/GUI/GUI_Utils.hpp"
#include "slic3r/GUI/MsgDialog.hpp"
#include "slic3r/GUI/Widgets/ComboBox.hpp"
#include "slic3r/GUI/Widgets/Button.hpp"
#include "slic3r/GUI/GUI_App.hpp"
#include "Http.hpp"
#include <wx/busyinfo.h>
namespace fs = boost::filesystem;
namespace pt = boost::property_tree;
namespace {
class UploadOptionsDialog : public Slic3r::GUI::DPIDialog
{
public:
UploadOptionsDialog(wxWindow* parent,
const wxArrayString& cloud_projects,
const wxArrayString& cloud_printer_types,
const wxString preset_name)
: Slic3r::GUI::DPIDialog(parent,
wxID_ANY,
_L("3DPrinterOS Cloud upload options"),
wxDefaultPosition,
wxSize(100 * Slic3r::GUI::wxGetApp().em_unit(), -1),
wxDEFAULT_DIALOG_STYLE),
okButton(nullptr)
{
SetFont(Slic3r::GUI::wxGetApp().normal_font());
SetBackgroundColour(*wxWHITE);
SetForegroundColour(*wxBLACK);
singleRadio = new wxRadioButton(this, wxID_ANY, _L("Single file"), wxDefaultPosition, wxDefaultSize, wxRB_GROUP);
projectRadio = new wxRadioButton(this, wxID_ANY, _L("Project File"));
projectsLabel = new wxStaticText(this, wxID_ANY, _L("Project:"));
wxStaticText* printerLabel = new wxStaticText(this, wxID_ANY, _L("Printer type:"));
projectsComboBox = new wxComboBox(this, wxID_ANY, wxString(""), wxDefaultPosition, wxDefaultSize, 0, nullptr, DD_NO_CHECK_ICON);
printerTypeComboBox = new wxComboBox(this, wxID_ANY, wxString(""), wxDefaultPosition, wxDefaultSize, 0, nullptr, DD_NO_CHECK_ICON | wxTE_READONLY);
printerWarningLabel = new wxStaticText(this, wxID_ANY, _L("Printer type not found, please select manually."));
printerWarningLabel->SetForegroundColour(*wxRED);
printerWarningLabel->Hide();
for (int i = 0; i < cloud_projects.size(); i++) {
projectsComboBox->Append(cloud_projects[i]);
}
if (cloud_printer_types.size() > 0) {
for (int i = 0; i < cloud_printer_types.size(); i++) {
printerTypeComboBox->Append(cloud_printer_types[i]);
if (cloud_printer_types[i].Find(preset_name) != wxNOT_FOUND && printerTypeComboBox->GetSelection() == -1) {
printerTypeComboBox->SetSelection(i);
}
}
if (printerTypeComboBox->GetCount() > 1) {
printerWarningLabel->Show();
} else {
printerTypeComboBox->SetSelection(0);
}
}
okButton = new wxButton(this, wxID_OK, _L("OK"));
wxButton* cancelButton = new wxButton(this, wxID_CANCEL, _L("Cancel"));
wxBoxSizer* radioSizer = new wxBoxSizer(wxHORIZONTAL);
wxBoxSizer* btnSizer = new wxBoxSizer(wxHORIZONTAL);
radioSizer->Add(singleRadio, 0, wxALL, 5);
radioSizer->Add(projectRadio, 0, wxALL, 5);
btnSizer->Add(okButton, 0, wxALL | wxALIGN_CENTER, 5);
btnSizer->Add(cancelButton, 0, wxALL | wxALIGN_CENTER, 5);
wxBoxSizer* sizer = new wxBoxSizer(wxVERTICAL);
sizer->Add(radioSizer, 0, wxALL, 5);
sizer->Add(projectsLabel, 0, wxALL, 5);
sizer->Add(projectsComboBox, 0, wxALL | wxEXPAND, 5);
sizer->Add(printerLabel, 0, wxALL, 5);
sizer->Add(printerTypeComboBox, 0, wxALL | wxEXPAND, 5);
sizer->Add(printerWarningLabel, 0, wxLEFT | wxRIGHT | wxBOTTOM, 5);
sizer->Add(btnSizer, 0, wxALL | wxALIGN_CENTER, 5);
SetSizer(sizer);
sizer->Fit(this);
projectsComboBox->Hide();
projectsLabel->Hide();
projectRadio->Bind(wxEVT_RADIOBUTTON, &UploadOptionsDialog::OnRadioButtonSelected, this);
singleRadio->Bind(wxEVT_RADIOBUTTON, &UploadOptionsDialog::OnRadioButtonSelected, this);
// Bind combo box selection change to validation
printerTypeComboBox->Bind(wxEVT_COMBOBOX, &UploadOptionsDialog::OnPrinterTypeChanged, this);
ValidateOkButton(); // Initial validation
Slic3r::GUI::wxGetApp().UpdateDlgDarkUI(this);
CenterOnParent();
}
void OnRadioButtonSelected(wxCommandEvent& event)
{
wxRadioButton* selectedRadio = dynamic_cast<wxRadioButton*>(event.GetEventObject());
if (selectedRadio) {
wxString label = selectedRadio->GetLabel();
if (label == _L("Project File")) {
projectsComboBox->Show();
projectsLabel->Show();
} else {
projectsComboBox->Hide();
projectsLabel->Hide();
}
Layout();
}
}
void on_dpi_changed(const wxRect& suggested_rect) {}
void OnPrinterTypeChanged(wxCommandEvent& event)
{
ValidateOkButton();
event.Skip();
}
void ValidateOkButton()
{
bool hasSelection = (printerTypeComboBox->GetSelection() != wxNOT_FOUND);
okButton->Enable(hasSelection);
}
void GetValues(std::string& project, std::string& printer_type)
{
project = projectRadio->GetValue() ? std::string(projectsComboBox->GetValue().c_str()) : "";
printer_type = std::string(printerTypeComboBox->GetValue().c_str());
}
private:
wxComboBox* projectsComboBox;
wxComboBox* printerTypeComboBox;
wxStaticText* projectsLabel;
wxStaticText* printerWarningLabel;
wxRadioButton* singleRadio;
wxRadioButton* projectRadio;
wxButton* okButton;
};
class TokenAuthDialog : public Slic3r::GUI::DPIDialog
{
public:
TokenAuthDialog(wxWindow* parent, const std::string &url, const std::string& token, const std::string &cafile, pt::ptree& resp)
: Slic3r::GUI::DPIDialog(parent,
wxID_ANY,
"3DPrinterOS",
wxDefaultPosition,
wxSize(45 * Slic3r::GUI::wxGetApp().em_unit(), -1),
wxDEFAULT_DIALOG_STYLE)
, m_url(url)
, m_token(token)
, m_cafile(cafile)
, m_resp(resp)
{
SetFont(Slic3r::GUI::wxGetApp().normal_font());
SetBackgroundColour(*wxWHITE);
SetForegroundColour(*wxBLACK);
auto* sizer = new wxBoxSizer(wxVERTICAL);
sizer->Add(new wxStaticText(this, wxID_ANY, _L("Authorizing...")), 1, wxALL | wxCENTER, 10);
auto* cancelBtn = new wxButton(this, wxID_CANCEL, _L("Cancel"));
sizer->Add(cancelBtn, 0, wxALL | wxALIGN_CENTER, 10);
SetSizerAndFit(sizer);
Bind(wxEVT_THREAD, [this](wxThreadEvent& e) { EndModal(e.GetId()); });
Bind(wxEVT_TIMER, &TokenAuthDialog::OnRetry, this);
Bind(wxEVT_SHOW, &TokenAuthDialog::OnShow, this);
Bind(wxEVT_BUTTON, &TokenAuthDialog::OnCancel, this, wxID_CANCEL);
m_timer.SetOwner(this);
Slic3r::GUI::wxGetApp().UpdateDlgDarkUI(this);
CenterOnParent();
}
void on_dpi_changed(const wxRect& suggested_rect) {}
private:
void OnShow(wxShowEvent& event)
{
if (event.IsShown() && !m_started) {
m_started = true;
SendRequest();
}
event.Skip();
}
void OnCancel(wxCommandEvent&)
{
m_cancelled = true;
if (m_http_ptr) {
m_http_ptr->cancel(); // abort the background request
}
EndModal(wxID_CANCEL);
}
void OnRetry(wxTimerEvent&) { SendRequest(); }
void SendRequest()
{
if (m_cancelled || m_attempt >= m_max_retries) {
if (m_attempt >= m_max_retries) {
m_resp.put("result", false);
m_resp.put("message", "Maximum login retries exceeded");
}
wxQueueEvent(this, new wxThreadEvent(wxEVT_THREAD, wxID_ABORT));
return;
}
m_attempt++;
std::string postBody = "token=" + m_token;
auto http = Slic3r::Http::post(m_url);
http.timeout_max(60);
if (!m_cafile.empty()) {
http.ca_file(m_cafile);
}
http.header("Content-Length", std::to_string(postBody.size()));
http.set_post_body(postBody);
http.on_error([this](std::string, std::string error, unsigned status) {
if (!m_cancelled) {
m_resp.put("result", false);
m_resp.put("message", (status != 200) ? "HTTP error: " + std::to_string(status) : error);
wxQueueEvent(this, new wxThreadEvent(wxEVT_THREAD, wxID_ABORT));
}
})
.on_complete([this](std::string body, unsigned status) {
if (!m_cancelled) {
if (status != 200) {
m_resp.put("result", false);
m_resp.put("message", "HTTP error: " + std::to_string(status));
wxQueueEvent(this, new wxThreadEvent(wxEVT_THREAD, wxID_ABORT));
return;
}
try {
std::stringstream ss(body);
pt::read_json(ss, m_resp);
} catch (...) {
m_resp.put("result", false);
m_resp.put("message", "Could not parse server response");
}
if (m_resp.get<bool>("result", false) && m_resp.get_optional<std::string>("message.session").has_value()) {
wxQueueEvent(this, new wxThreadEvent(wxEVT_THREAD, wxID_OK));
} else if (m_resp.get<bool>("result", false)) {
if (m_attempt < m_max_retries)
m_timer.StartOnce(m_retry_delay_ms);
else
wxQueueEvent(this, new wxThreadEvent(wxEVT_THREAD, wxID_ABORT));
} else {
wxQueueEvent(this, new wxThreadEvent(wxEVT_THREAD, wxID_ABORT));
}
}
});
m_http_ptr = http.perform();
}
private:
std::string m_token;
std::string m_url;
std::string m_cafile;
pt::ptree& m_resp;
wxTimer m_timer;
std::shared_ptr<Slic3r::Http> m_http_ptr;
bool m_cancelled{false};
bool m_started{false};
int m_attempt{0};
const int m_max_retries{10};
const int m_retry_delay_ms{500};
};
} // namespace
namespace Slic3r {
static const std::string API_CREDENTIALS_PATH = "3dprinteros_api_cred.json";
C3DPrinterOS::C3DPrinterOS(DynamicPrintConfig *config)
: m_host(config->opt_string("print_host"))
, m_apikey(config->opt_string("printhost_apikey"))
, m_preset_name(config->opt_string("printer_model"))
{
m_api_session_file_path = (boost::filesystem::path(Slic3r::data_dir()) / API_CREDENTIALS_PATH)
.make_preferred()
.string();
load_api_session();
}
const char *C3DPrinterOS::get_name() const { return "3DPrinterOS"; }
bool C3DPrinterOS::test(wxString &msg) const
{
return check_session(msg);
}
bool C3DPrinterOS::login(wxString& msg) const
{
// Get token for auth
msg.clear();
std::string token = get_api_auth_token(msg);
if (token.empty()) {
msg = _L("Error. Can't get api token for authorization");
return false;
}
auto login_url = make_url("noauth/apiglobal_login_with_token/" + token);
wxLaunchDefaultBrowser(login_url);
pt::ptree login_resp;
login_with_token(login_resp, token);
std::string session, email;
try {
if (login_resp.get<bool>("result")) {
session = login_resp.get<std::string>("message.session");
email = login_resp.get<std::string>("message.email");
} else {
msg = wxString(login_resp.get<std::string>("message").c_str());
return false;
}
} catch (const std::exception&) {
msg = _L("Could not parse server response.");
return false;
}
bool res = save_api_session(session, email);
if (!res) {
msg = _L("Error saving session to file");
}
return res;
}
wxString C3DPrinterOS::get_test_ok_msg() const
{
return _("Connection to 3DPrinterOS cloud works correctly.") + (!m_username.empty() ? "" + _(" Logined as user: ") + m_username : "");
}
wxString C3DPrinterOS::get_test_failed_msg(wxString &msg) const
{
return GUI::format_wxstr("%s: %s\n\n", _L("Error session check"), msg);
}
bool C3DPrinterOS::upload(
PrintHostUpload upload_data, ProgressFn prorgess_fn, ErrorFn error_fn, InfoFn info_fn
) const
{
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();
wxString test_msg;
if (!check_session(test_msg)) {
error_fn(std::move(test_msg));
return false;
}
pt::ptree cloud_project_resp;
pt::ptree cloud_printer_types_resp;
get_cloud_projects_list(cloud_project_resp);
get_cloud_printer_types(cloud_printer_types_resp, m_preset_name);
wxArrayString cloud_projects_list;
wxArrayString cloud_printer_types_list;
try {
if (cloud_project_resp.get<bool>("result")) {
for (const auto &messageItem : cloud_project_resp.get_child("message")) {
cloud_projects_list.Add(messageItem.second.get<std::string>("name"));
}
}
if (cloud_printer_types_resp.get<bool>("result")) {
for (const auto &messageItem : cloud_printer_types_resp.get_child("message")) {
cloud_printer_types_list.Add(messageItem.second.get<std::string>("description"));
}
}
} catch (const std::exception &) {
error_fn(_L("Could not parse server response."));
return false;
}
// Show "Confirm cloud printer type and project for 3DPrinterOS upload
UploadOptionsDialog dlg(GUI::wxGetApp().GetTopWindow(), cloud_projects_list, cloud_printer_types_list, m_preset_name);
if (dlg.ShowModal() != wxID_OK) {
error_fn(_L("Canceled"));
return false;
}
std::string selected_project;
std::string selected_printer_type;
dlg.GetValues(selected_project, selected_printer_type);
std::string project_id;
std::string printer_type_id;
// search for cloud project_id by name
if (!selected_project.empty()) {
for (const auto& messageItem : cloud_project_resp.get_child("message")) {
if (messageItem.second.get<std::string>("name", "") == selected_project) {
project_id = messageItem.second.get<std::string>("id", "");
break;
}
}
}
// search for cloud printer_type_id by name
for (const auto& messageItem : cloud_printer_types_resp.get_child("message")) {
if (messageItem.second.get<std::string>("description", "") == selected_printer_type) {
printer_type_id = messageItem.second.get<std::string>("id", "");
break;
}
}
bool res = true;
auto url = make_url("apiglobal/upload");
std::string file_id;
pt::ptree uploadResponse;
auto http = Http::post(std::move(url));
if (!m_cafile.empty()) {
http.ca_file(m_cafile);
}
http.form_add("session", m_apikey)
.form_add("upload_type_id", "7")
.form_add("upload_soft_name", "OrcaSlicer")
.form_add("zip", "false")
.form_add_file("file", upload_data.source_path.string(), upload_filename.string());
if (!project_id.empty()) {
http.form_add("project_id", project_id);
} else if (!selected_project.empty()) {
http.form_add("project_name", selected_project);
http.form_add("project_color", "grey");
}
http.on_complete([&](std::string body, unsigned status) {
std::stringstream ss(body);
try {
pt::read_json(ss, uploadResponse);
} catch (const std::exception &) {
uploadResponse.put("result", false);
uploadResponse.put("message", "Could not parse server response");
}
})
.on_error([&](std::string body, std::string error, unsigned status) {
error_fn(format_error(body, error, status));
res = false;
})
.on_progress([&](Http::Progress progress, bool &cancel) {
prorgess_fn(std::move(progress), cancel);
if (cancel) {
res = false;
}
})
.perform_sync();
try {
if (uploadResponse.get<bool>("result")) {
file_id = uploadResponse.get<std::string>("message.file_id");
} else {
res = false;
error_fn(uploadResponse.get<std::string>("message"));
}
} catch (const std::exception &) {
res = false;
error_fn(_L("Error during file upload"));
}
// set printer type for uploaded gcode
if (res) {
pt::ptree update_file_response;
update_file(update_file_response, file_id, printer_type_id, "OrcaSlicer");
try {
if (!update_file_response.get<bool>("result")) {
const std::string msg = update_file_response.get<std::string>("message", "Unknown update error");
BOOST_LOG_TRIVIAL(warning) << "Failed to update uploaded file: " << msg;
}
} catch (const std::exception& ex) {
BOOST_LOG_TRIVIAL(warning) << "Could not parse update response: " << ex.what();
}
if (upload_data.post_action == PrintHostPostUploadAction::StartPrint && !upload_data.use_3mf) {
auto quick_print_url = make_url("quickprint?file_id=" + file_id);
wxLaunchDefaultBrowser(quick_print_url);
}
}
return res;
}
void C3DPrinterOS::log_out() const
{
boost::filesystem::remove(m_api_session_file_path.c_str());
}
bool C3DPrinterOS::validate_version_text(const boost::optional<std::string> &version_text) const
{
return version_text ? boost::starts_with(*version_text, "3DPrinterOS") : true;
}
std::string C3DPrinterOS::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();
} else {
return (boost::format("%1%/%2%") % m_host % path).str();
}
} else {
return (boost::format("https://%1%/%2%") % m_host % path).str();
}
}
std::string C3DPrinterOS::get_api_auth_token(wxString &err) const
{
std::string result;
pt::ptree resp;
std::string postBody = "app_type=plugin&app_name=" + Http::url_encode("OrcaSlicer");
send_form("apiglobal/generate_login_token", postBody, resp);
try {
if (resp.get<bool>("result")) {
result = resp.get<std::string>("message");
} else {
err = wxString(resp.get<std::string>("message").c_str());
}
} catch (const std::exception &) {
err = _L("Could not parse server response.");
}
return result;
}
void C3DPrinterOS::login_with_token(pt::ptree &resp, const std::string &token) const {
auto url = make_url("apiglobal/login_with_token");
TokenAuthDialog dlg(GUI::wxGetApp().GetTopWindow(), url, token, m_cafile, resp);
dlg.ShowModal();
}
bool C3DPrinterOS::check_session(wxString &msg) const {
std::string postBody = "session=" + m_apikey;
pt::ptree resp;
send_form("apiglobal/check_session", postBody, resp);
try {
if (resp.get<bool>("result")) {
return true;
} else {
msg = wxString(resp.get<std::string>("message").c_str());
return false;
}
} catch (const std::exception &) {
msg = _L("Could not parse server response.");
return false;
}
return false;
}
bool C3DPrinterOS::save_api_session(const std::string &session, const std::string &email) const {
pt::ptree j;
j.put("session", session);
j.put("email", email);
try {
auto temp_path = m_api_session_file_path + ".tmp";
pt::write_json(temp_path, j);
boost::filesystem::rename(temp_path, m_api_session_file_path);
} catch (const std::exception &err) {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ": failed to write json to file. Path = "
<< m_api_session_file_path
<< " Reason = " << err.what();
return false;
}
return true;
}
void C3DPrinterOS::load_api_session()
{
m_apikey.clear();
if (boost::filesystem::exists(m_api_session_file_path)) {
pt::ptree j;
try {
pt::read_json(m_api_session_file_path, j);
m_apikey = j.get<std::string>("session");
m_username = j.get<std::string>("email");
} catch (const std::exception &err) {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ": load_api_session failed, reason = " << err.what();
// remove corrupted file to avoid repeated failures
try {
boost::filesystem::remove(m_api_session_file_path);
} catch (...) {}
}
};
}
void C3DPrinterOS::send_form(
const std::string &endpoint,
const std::string &postBody,
boost::property_tree::ptree &responseTree
) const
{
responseTree.clear();
auto url = make_url(endpoint);
auto http = Http::post(std::move(url));
if (!m_cafile.empty()) {
http.ca_file(m_cafile);
}
http.header("Content-length", std::to_string(postBody.size()));
http.set_post_body(postBody);
http.on_error([&](std::string body, std::string error, unsigned status) {
BOOST_LOG_TRIVIAL(error) << boost::format("Error sending form: %1%") % error;
responseTree.put("result", false);
responseTree.put("message", error);
})
.on_complete([&, this](std::string body, unsigned) {
std::stringstream ss(body);
try {
pt::read_json(ss, responseTree);
} catch (const std::exception &) {
responseTree.put("result", false);
responseTree.put("message", "Could not parse server response");
}
})
.perform_sync();
}
void C3DPrinterOS::get_cloud_projects_list(boost::property_tree::ptree &response) const
{
std::string postBody = std::string("session=" + m_apikey);
send_form("apiglobal/get_projects", postBody, response);
}
void C3DPrinterOS::get_cloud_printer_types(boost::property_tree::ptree &response, const std::string &query) const
{
std::string postBody = std::string("session=" + m_apikey);
if (!query.empty()) {
postBody += "&description=" + Http::url_encode(query) + "&software_version=" + Http::url_encode("OrcaSlicer");
}
send_form("apiglobal/get_printer_types", postBody, response);
}
void C3DPrinterOS::update_file(boost::property_tree::ptree &response, const std::string &file_id, const std::string &ptype, const std::string &gtype) const
{
std::string postBody = "session=" + m_apikey
+ "&updates[" + file_id + "][ptype]=" + ptype
+ "&updates[" + file_id + "][gtype]=" + Http::url_encode(gtype)
+ "&updates[" + file_id + "][zip]=false";
send_form("apiglobal/file_update", postBody, response);
}
};
// namespace Slic3r

View File

@@ -0,0 +1,80 @@
#ifndef slic3r_3DPrinterOS_hpp_
#define slic3r_3DPrinterOS_hpp_
#include <string>
#include <wx/string.h>
#include <boost/optional.hpp>
#include <boost/property_tree/ptree.hpp>
#include "PrintHost.hpp"
#include "slic3r/GUI/GUI.hpp"
namespace Slic3r {
class DynamicPrintConfig;
class Http;
class C3DPrinterOS : public PrintHost
{
public:
C3DPrinterOS(DynamicPrintConfig *config);
~C3DPrinterOS() override = default;
const char* get_name() const override;
bool test(wxString &curl_msg) const override;
bool login(wxString &msg) const;
wxString get_test_ok_msg () const override;
wxString get_test_failed_msg (wxString &msg) const override;
bool upload(PrintHostUpload upload_data, ProgressFn prorgess_fn, ErrorFn error_fn, InfoFn info_fn) const override;
bool has_auto_discovery() const override { return false; }
bool can_test() const override { return true; }
bool is_cloud() const override { return true; }
void log_out() const override;
bool is_logged_in() const override { return !m_apikey.empty(); }
PrintHostPostUploadActions get_post_upload_actions() const override { return PrintHostPostUploadAction::StartPrint | PrintHostPostUploadAction::QueuePrint; }
std::string get_host() const override { return m_host; }
static std::string default_host() { return "https://cloud.3dprinteros.com"; }
protected:
bool validate_version_text(const boost::optional<std::string> &version_text) const;
private:
std::string m_host;
std::string m_apikey;
std::string m_cafile;
std::string m_username;
std::string m_host_type;
std::string m_preset_name;
std::string m_api_session_file_path;
void load_api_session();
bool save_api_session(const std::string &session, const std::string &email) const;
std::string parse_printer_model(const std::string& input) const;
std::string make_url(const std::string &path) const;
std::string get_api_auth_token(wxString &err) const;
void login_with_token(boost::property_tree::ptree &resp, const std::string &token) const;
bool check_session(wxString &msg) const;
void send_form(
const std::string &endpoint,
const std::string &postBody,
boost::property_tree::ptree &responseTree
) const;
void get_cloud_projects_list(boost::property_tree::ptree &response) const;
void get_cloud_printer_types(boost::property_tree::ptree &response, const std::string &querry) const;
void update_file(
boost::property_tree::ptree &response,
const std::string &file_id,
const std::string &ptype,
const std::string &gtype
) const;
};
}
#endif

View File

@@ -71,7 +71,7 @@ bool AstroBox::test(wxString &msg) const
}
catch (const std::exception &) {
res = false;
msg = "Could not parse server response";
msg = _L("Could not parse server response.");
}
})
.perform_sync();

View File

@@ -433,7 +433,7 @@ std::string BBLCloudServiceAgent::request_setting_id(std::string name, std::map<
return "";
}
int BBLCloudServiceAgent::put_setting(std::string setting_id, std::string name, std::map<std::string, std::string>* values_map, unsigned int* http_code)
int BBLCloudServiceAgent::put_setting(std::string setting_id, std::string name, std::map<std::string, std::string>* values_map, unsigned int* http_code, bool force)
{
auto& plugin = BBLNetworkPlugin::instance();
auto agent = plugin.get_agent();

View File

@@ -70,7 +70,7 @@ public:
// Settings Synchronization
int get_user_presets(std::map<std::string, std::map<std::string, std::string>>* user_presets) override;
std::string request_setting_id(std::string name, std::map<std::string, std::string>* values_map, unsigned int* http_code) override;
int put_setting(std::string setting_id, std::string name, std::map<std::string, std::string>* values_map, unsigned int* http_code) override;
int put_setting(std::string setting_id, std::string name, std::map<std::string, std::string>* values_map, unsigned int* http_code, bool force = false) override;
int get_setting_list(std::string bundle_version, ProgressFn pro_fn = nullptr, WasCancelledFn cancel_fn = nullptr) override;
int get_setting_list2(std::string bundle_version, CheckFn chk_fn, ProgressFn pro_fn = nullptr, WasCancelledFn cancel_fn = nullptr) override;
int delete_setting(std::string setting_id) override;

View File

@@ -20,7 +20,7 @@ public:
int extruder_id = 0;
int ams_id = 0;
int slot_id = 0;
float nozzle_diameter;
float nozzle_diameter = 0.0f;
ExtruderType extruder_type{ExtruderType::etDirectDrive};
NozzleVolumeType nozzle_volume_type;
Calib_Params params;

View File

@@ -0,0 +1,128 @@
#include "CrealityHostDiscovery.hpp"
#include "cxmdns.h"
#include "Http.hpp"
#include <boost/log/trivial.hpp>
#include <nlohmann/json.hpp>
#include <algorithm>
namespace Slic3r {
namespace {
struct ModelEntry { const char* code; const char* name; };
constexpr ModelEntry kCfsCapableModels[] = {
{"F008", "K2 Plus"},
{"F012", "K2 Pro"},
{"F021", "K2"},
};
bool is_cfs_capable(const std::string& code)
{
for (const auto& m : kCfsCapableModels)
if (code == m.code) return true;
return false;
}
std::string model_name_for(const std::string& code)
{
for (const auto& m : kCfsCapableModels)
if (code == m.code) return m.name;
return {};
}
// Extract the device suffix from a service name like
// "_Creality-543324280CDB19._udp.local." and synthesise a hostname-ish label
// (e.g. "K2-DB19" using the last 4 hex of the MAC-derived suffix).
std::string hostname_from_service(const std::string& service_name)
{
auto dash = service_name.find_last_of('-');
if (dash == std::string::npos) return {};
auto dot = service_name.find('.', dash);
if (dot == std::string::npos) return {};
std::string suffix = service_name.substr(dash + 1, dot - dash - 1);
if (suffix.size() >= 4) {
return "K2-" + suffix.substr(suffix.size() - 4);
}
return suffix.empty() ? std::string{} : "K2-" + suffix;
}
// Synchronously probe http://<ip>/info for {model, mac}. Short timeout --
// we don't want one slow host to drag down discovery.
void probe_info(CrealityHost& host)
{
const std::string url = "http://" + host.ip + "/info";
auto http = Http::get(url);
http.timeout_connect(2)
.timeout_max(4)
.on_complete([&host](std::string body, unsigned /*status*/) {
try {
auto j = nlohmann::json::parse(body);
if (j.contains("model") && j["model"].is_string())
host.model_code = j["model"].get<std::string>();
if (j.contains("mac") && j["mac"].is_string())
host.mac = j["mac"].get<std::string>();
if (is_cfs_capable(host.model_code)) {
host.cfs_capable = true;
host.model_name = model_name_for(host.model_code);
}
} catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(warning)
<< "CrealityHostDiscovery: /info parse failed for "
<< host.ip << ": " << e.what();
}
})
.on_error([&host](std::string /*body*/, std::string error, unsigned status) {
BOOST_LOG_TRIVIAL(info)
<< "CrealityHostDiscovery: /info GET failed for "
<< host.ip << ": " << error << " (HTTP " << status << ")";
})
.perform_sync();
}
} // namespace
std::vector<CrealityHost> CrealityHostDiscovery::scan(bool probe)
{
const std::vector<std::string> prefixes{ "Creality", "creality" };
BOOST_LOG_TRIVIAL(info)
<< "CrealityHostDiscovery: starting DNS-SD discovery (prefixes: Creality, creality)";
auto raw = cxnet::syncDiscoveryService(prefixes);
BOOST_LOG_TRIVIAL(info)
<< "CrealityHostDiscovery: mDNS returned " << raw.size() << " match(es)";
std::vector<CrealityHost> hosts;
hosts.reserve(raw.size());
// Dedupe by IP -- one printer may announce twice if multi-homed or if
// we capture both IPv4/IPv6 replies.
std::vector<std::string> seen_ips;
for (const auto& m : raw) {
if (m.machineIp.empty()) continue;
if (std::find(seen_ips.begin(), seen_ips.end(), m.machineIp) != seen_ips.end())
continue;
seen_ips.push_back(m.machineIp);
CrealityHost h;
h.ip = m.machineIp;
h.service_name = m.answer;
h.hostname = hostname_from_service(m.answer);
if (probe) {
probe_info(h);
}
hosts.push_back(std::move(h));
}
BOOST_LOG_TRIVIAL(info)
<< "CrealityHostDiscovery: " << hosts.size() << " unique host(s) after dedup";
return hosts;
}
} // namespace Slic3r

View File

@@ -0,0 +1,45 @@
#ifndef slic3r_CrealityHostDiscovery_hpp_
#define slic3r_CrealityHostDiscovery_hpp_
#include <string>
#include <vector>
namespace Slic3r {
// One discovered Creality K-series host on the LAN.
struct CrealityHost
{
std::string ip; // dotted-quad IPv4
std::string service_name; // raw mDNS service type, e.g. "_Creality-543324280CDB19._udp.local."
std::string hostname; // e.g. "K2-DB19" (derived from service-name suffix)
std::string model_code; // "F008" / "F012" / "F021" (empty if /info probe failed)
std::string model_name; // "K2 Plus" / "K2 Pro" / "K2" (empty if model not in our table)
std::string mac; // from /info if probed
bool cfs_capable = false; // true when model_code is in the K2 family
};
// Synchronous LAN discovery for Creality K-series printers via DNS-SD mDNS.
//
// Sends a meta-discovery query (_services._dns-sd._udp.local.) and listens
// for ~5 seconds for service announcements whose type-name contains the
// "Creality" / "creality" substring. K-series firmware announces each
// printer under a per-device-unique type _Creality-<MAC-derived-hex>._udp.local,
// so a fixed-name query does not work -- the meta-discovery is the only
// reliable way to find them.
//
// When probe_info is true, each discovered host is followed up with an HTTP
// GET http://<ip>/info call to fetch the printer's model code (F008/F012/F021)
// and MAC. The probe step adds ~2-4 seconds per host but yields enriched
// results that let the UI display "K2" / "K2 Plus" / "K2 Pro" instead of
// just an IP.
//
// Call from a background thread -- the function blocks for at least 5 seconds.
class CrealityHostDiscovery
{
public:
static std::vector<CrealityHost> scan(bool probe_info = true);
};
} // namespace Slic3r
#endif

View File

@@ -1,6 +1,7 @@
#include "CrealityPrint.hpp"
#include <algorithm>
#include <map>
#include <sstream>
#include <exception>
#include <boost/format.hpp>
@@ -88,7 +89,8 @@ bool CrealityPrint::test(wxString& msg) const
// Here we do not have to add custom "Host" header - the url contains host filled by user and libCurl will set the header by itself.
auto http = Http::get(std::move(url));
set_auth(http);
http.on_error([&](std::string body, std::string error, unsigned status) {
http.timeout_max(5)
.on_error([&](std::string body, std::string error, unsigned status) {
BOOST_LOG_TRIVIAL(error) << boost::format("%1%: Error getting version: %2%, HTTP %3%, body: `%4%`") % name % error % status %
body;
res = false;
@@ -96,6 +98,15 @@ bool CrealityPrint::test(wxString& msg) const
})
.on_complete([&, this](std::string body, unsigned) {
BOOST_LOG_TRIVIAL(debug) << boost::format("%1%: Got version: %2%") % name % body;
try {
auto info = json::parse(body);
if (info.contains("model")) {
m_model = info["model"].get<std::string>();
BOOST_LOG_TRIVIAL(info) << boost::format("%1%: Detected model: %2%") % name % m_model;
}
} catch (const json::exception& e) {
BOOST_LOG_TRIVIAL(warning) << boost::format("%1%: Failed to parse /info response: %2%") % name % e.what();
}
})
#ifdef WIN32
.ssl_revoke_best_effort(m_ssl_revoke_best_effort)
@@ -126,18 +137,25 @@ bool CrealityPrint::upload(PrintHostUpload upload_data, ProgressFn prorgess_fn,
}
bool res = true;
auto url = make_url("upload/" + safe_filename(upload_filename.string()));
const auto safe_upload_filename = safe_filename(upload_filename.string());
// Only encode the URL path segment; keep the multipart filename and start-print path as the stored filename.
auto url = make_url("upload/" + Http::url_encode(safe_upload_filename));
auto http = Http::post(url); // std::move(url));
set_auth(http);
http.form_add("path", upload_parent_path.string())
.form_add_file("file", upload_data.source_path.string(), upload_filename.string())
if (!supports_multi_color_print())
http.form_add("path", upload_parent_path.string());
http.form_add_file("file", upload_data.source_path.string(), safe_upload_filename)
.on_complete([&](std::string body, unsigned status) {
BOOST_LOG_TRIVIAL(debug) << boost::format("%1%: File uploaded: HTTP %2%: %3%") % name % status % body;
if (upload_data.post_action == PrintHostPostUploadAction::StartPrint) {
start_print(safe_filename(upload_filename.string()));
wxString errormsg;
if (!start_print(errormsg, safe_upload_filename, upload_data.extended_info)) {
error_fn(std::move(errormsg));
res = false;
}
}
})
.on_error([&](std::string body, std::string error, unsigned status) {
@@ -182,53 +200,231 @@ std::string CrealityPrint::safe_filename(const std::string &filename) const
return safe_filename;
}
void CrealityPrint::start_print(const std::string &filename) const
static void ws_connect(net::io_context& ioc, websocket::stream<beast::tcp_stream>& ws,
const std::string& host_url, const std::string& port)
{
std::string host = Http::get_host_from_url(host_url);
tcp::resolver resolver{ioc};
beast::get_lowest_layer(ws).expires_after(std::chrono::seconds(5));
auto const results = resolver.resolve(host, port);
beast::get_lowest_layer(ws).connect(results);
host += ':' + std::to_string(beast::get_lowest_layer(ws).socket().remote_endpoint().port());
ws.set_option(websocket::stream_base::decorator(
[](websocket::request_type& req) {
req.set(http::field::user_agent,
std::string(BOOST_BEAST_VERSION_STRING) + " websocket-client-coro");
}));
ws.handshake(host, "/");
#ifdef _WIN32
DWORD recv_timeout = 3000;
#else
struct timeval recv_timeout = {3, 0};
#endif
setsockopt(beast::get_lowest_layer(ws).socket().native_handle(),
SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast<const char*>(&recv_timeout), sizeof(recv_timeout));
}
static std::string ws_send_and_read(websocket::stream<beast::tcp_stream>& ws, const json& cmd, const std::string& expected_key, int max_reads = 20)
{
ws.write(net::buffer(to_string(cmd)));
for (int i = 0; i < max_reads; i++) {
beast::flat_buffer buf;
beast::error_code ec;
ws.read(buf, ec);
if (ec == net::error::would_block)
break;
if (ec)
throw beast::system_error{ec};
std::string msg = beast::buffers_to_string(buf.data());
if (msg.find(expected_key) != std::string::npos)
return msg;
}
BOOST_LOG_TRIVIAL(warning) << "CrealityPrint: No '" << expected_key << "' response after " << max_reads << " messages";
return {};
}
void CrealityPrint::query_model() const
{
if (!m_model.empty())
return;
wxString msg;
test(msg);
}
bool CrealityPrint::supports_multi_color_print() const
{
query_model();
// K2-platform printers with CFS support
return m_model == "F008" // K2 Plus
|| m_model == "F012" // K2 Pro
|| m_model == "F021" // K2
|| m_model == "F022"; // SPARKX i7
}
std::string CrealityPrint::model_name() const
{
static const std::map<std::string, std::string> names = {
{"F008", "K2 Plus"},
{"F012", "K2 Pro"},
{"F021", "K2"},
{"F022", "SPARKX i7"},
};
query_model();
if (m_model.empty())
return "unreachable";
auto it = names.find(m_model);
return it != names.end() ? it->second : "unknown (" + m_model + ")";
}
std::string CrealityPrint::query_boxes_info() const
{
try {
std::string host = m_host;
auto const port = "9999";
net::io_context ioc;
websocket::stream<beast::tcp_stream> ws{ioc};
ws_connect(ioc, ws, m_host, "9999");
json j2 = {
{ "method", "set" },
{
"params", {
{ "opGcodeFile", "printprt:/usr/data/printer_data/gcodes/" + filename }
}
}
};
json boxs_query = {{"method", "get"}, {"params", {{"boxsInfo", 1}}}};
std::string result = ws_send_and_read(ws, boxs_query, "boxsInfo");
ws.close(websocket::close_code::normal);
return result;
} catch (std::exception const& e) {
BOOST_LOG_TRIVIAL(error) << "CrealityPrint: Failed to query boxsInfo: " << e.what();
return {};
}
}
std::string CrealityPrint::get_print_host_webui(DynamicPrintConfig* config)
{
// K-series printers (K2 / K2 Plus / K2 Pro) ship with Mainsail on port 4408.
// Port 80 hosts only the Creality control / upload API, which returns 404
// for unknown paths and therefore renders as a blank/404 page in Orca's
// Device WebView. Default to the Mainsail URL when the user hasn't
// explicitly set print_host_webui.
if (config == nullptr)
return {};
std::string explicit_url = config->opt_string("print_host_webui");
if (!explicit_url.empty())
return explicit_url;
std::string host = config->opt_string("print_host");
if (host.empty())
return {};
if (boost::algorithm::istarts_with(host, "http://"))
host = host.substr(7);
else if (boost::algorithm::istarts_with(host, "https://"))
host = host.substr(8);
if (auto slash = host.find('/'); slash != std::string::npos)
host = host.substr(0, slash);
if (auto colon = host.find(':'); colon != std::string::npos)
host = host.substr(0, colon);
return "http://" + host + ":4408/";
}
bool CrealityPrint::start_print(wxString &msg, const std::string &filename, const std::map<std::string, std::string>& extended_info) const
{
try {
const std::string gcode_path = "/mnt/UDISK/printer_data/gcodes/" + filename;
net::io_context ioc;
websocket::stream<beast::tcp_stream> ws{ioc};
ws_connect(ioc, ws, m_host, "9999");
tcp::resolver resolver{ioc};
websocket::stream<tcp::socket> ws{ioc};
if (supports_multi_color_print()) {
// Build colorMatch list from the mapping provided by the dialog
bool use_spool_holder = false;
json color_list = json::array();
for (int i = 0; ; i++) {
auto it = extended_info.find("colorMatch_" + std::to_string(i));
if (it == extended_info.end())
break;
// Value format: "toolId\ttype\tcolor\tboxId\tmaterialId"
auto val = it->second;
std::vector<std::string> parts;
std::istringstream iss(val);
std::string part;
while (std::getline(iss, part, '\t'))
parts.push_back(part);
if (parts.size() >= 5) {
int box_id = std::stoi(parts[3]);
if (box_id == 0)
use_spool_holder = true;
color_list.push_back({
{"id", parts[0]},
{"type", parts[1]},
{"color", parts[2]},
{"boxId", box_id},
{"materialId", std::stoi(parts[4])}
});
}
}
auto const results = resolver.resolve(host, port);
auto ep = net::connect(ws.next_layer(), results);
host += ':' + std::to_string(ep.port());
ws.set_option(websocket::stream_base::decorator(
[](websocket::request_type& req)
int enable_self_test = 0;
{
req.set(http::field::user_agent,
std::string(BOOST_BEAST_VERSION_STRING) +
" websocket-client-coro");
}));
auto it = extended_info.find("enableSelfTest");
if (it != extended_info.end())
enable_self_test = std::stoi(it->second);
}
ws.handshake(host, "/");
ws.write(net::buffer(to_string(j2)));
if (use_spool_holder) {
json cmd = {
{"method", "set"},
{"params", {
{"opGcodeFile", "printprt:" + gcode_path},
{"enableSelfTest", enable_self_test}
}}
};
ws.write(net::buffer(to_string(cmd)));
} else {
json color_match = {
{"method", "set"},
{"params", {
{"colorMatch", {
{"path", gcode_path},
{"list", color_list}
}}
}}
};
ws.write(net::buffer(to_string(color_match)));
beast::flat_buffer buffer;
json multi_color_print = {
{"method", "set"},
{"params", {
{"multiColorPrint", {
{"gcode", gcode_path},
{"enableSelfTest", enable_self_test}
}}
}}
};
ws.write(net::buffer(to_string(multi_color_print)));
}
} else {
json cmd = {
{"method", "set"},
{"params", {
{"opGcodeFile", "printprt:/usr/data/printer_data/gcodes/" + filename}
}}
};
ws.write(net::buffer(to_string(cmd)));
ws.read(buffer);
beast::flat_buffer buffer;
ws.read(buffer);
}
ws.close(websocket::close_code::normal);
return true;
} catch(std::exception const& e) {
std::cerr << "Error: " << e.what() << std::endl;
BOOST_LOG_TRIVIAL(error) << "CrealityPrint: Error starting print: " << e.what();
msg = wxString::FromUTF8(e.what());
return false;
}
}
}

View File

@@ -1,6 +1,7 @@
#ifndef slic3r_CrealityPrint_hpp_
#define slic3r_CrealityPrint_hpp_
#include <map>
#include <string>
#include <wx/string.h>
#include <boost/optional.hpp>
@@ -29,6 +30,13 @@ public:
virtual bool test(wxString& curl_msg) const override;
PrintHostPostUploadActions get_post_upload_actions() const;
bool upload(PrintHostUpload upload_data, ProgressFn prorgess_fn, ErrorFn error_fn, InfoFn info_fn) const override;
bool supports_multi_color_print() const;
std::string query_boxes_info() const;
std::string model_name() const;
// Mainsail on K-series printers listens on port 4408. Use that as the
// default Device-tab WebView URL when the user has not set print_host_webui.
static std::string get_print_host_webui(DynamicPrintConfig *config);
protected:
virtual void set_auth(Http& http) const;
@@ -39,10 +47,12 @@ private:
std::string m_cafile;
std::string m_web_ui;
bool m_ssl_revoke_best_effort;
mutable std::string m_model;
std::string make_url(const std::string& path) const;
void start_print(const std::string& path) const;
bool start_print(wxString& msg, const std::string& filename, const std::map<std::string, std::string>& extended_info) const;
std::string safe_filename(const std::string& filename) const;
void query_model() const;
};
} // namespace Slic3r

View File

@@ -0,0 +1,334 @@
#include "CrealityPrintAgent.hpp"
#include "CrealityPrint.hpp"
#include "libslic3r/PresetBundle.hpp"
#include "libslic3r/PrintConfig.hpp"
#include "slic3r/GUI/GUI_App.hpp"
#include <boost/log/trivial.hpp>
#include <nlohmann/json.hpp>
#include <algorithm>
#include <map>
namespace Slic3r {
namespace {
constexpr const char* CrealityPrintAgent_VERSION = "0.1.0";
bool has_visible_base_preset(const PresetCollection& filaments, const std::string& filament_id)
{
for (const auto& p : filaments.get_presets()) {
if (p.is_visible && p.is_compatible
&& filaments.get_preset_base(p) == &p
&& p.filament_id == filament_id)
return true;
}
return false;
}
} // namespace
// Score visible compatible filament presets against the CFS spool metadata and
// return the best-matching filament_id. Scoring:
// +20 preset name contains brand_name as a substring
// (e.g. "Hyper PLA" in "Hyper PLA @Creality K2 0.4 nozzle")
// +10 preset name contains the vendor substring (e.g. "Creality")
// Tiebreak: prefer the SYSTEM (shipped) preset over user copies. Brand-
// specific system presets carry their own filament_id; user copies of
// generic presets inherit a generic filament_id from their parent, so
// preferring the user copy can collapse a brand-specific match back to
// "Generic PLA" via the inherited id. Plus: this code targets upstream
// OrcaSlicer where shipping the user's local tuning would be wrong.
// Requires the preset's declared filament_type to equal the spool's base type
// (PLA/PETG/ABS/...) so we never auto-pick a PETG preset for a PLA spool.
// Falls back to filaments.filament_id_by_type(base_type) when nothing scores.
std::string CrealityPrintAgent::match_filament_preset(const PresetCollection& filaments,
const std::string& vendor,
const std::string& brand_name,
const std::string& base_type)
{
auto to_lower = [](std::string s) {
for (auto& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
return s;
};
const std::string vendor_lower = to_lower(vendor);
const std::string brand_lower = to_lower(brand_name);
const std::string type_lower = to_lower(base_type);
struct Match {
const Preset* preset;
int score;
bool is_user;
};
std::vector<Match> matches;
int considered = 0;
for (const auto& p : filaments.get_presets()) {
if (!p.is_visible || !p.is_compatible) continue;
// Note: we deliberately do NOT filter on get_preset_base(p) == &p.
// K2 owners frequently keep tweaked copies of system presets
// (e.g. "Creality Hyper PLA @K2 (Harky)" with their per-spool PA),
// which are derived presets — filtering to bases-only would skip
// exactly the presets users care about most.
++considered;
std::string preset_type;
if (const auto* ft = p.config.option<ConfigOptionStrings>("filament_type"))
if (!ft->values.empty()) preset_type = ft->values.front();
if (to_lower(preset_type) != type_lower) continue;
const std::string name_lower = to_lower(p.name);
int score = 0;
if (!brand_lower.empty() && name_lower.find(brand_lower) != std::string::npos)
score += 20;
if (!vendor_lower.empty() && name_lower.find(vendor_lower) != std::string::npos)
score += 10;
if (score > 0)
matches.push_back({&p, score, !p.is_system && !p.is_default});
}
if (matches.empty()) {
const std::string fallback = filaments.filament_id_by_type(base_type);
const bool fallback_ok = has_visible_base_preset(filaments, fallback);
BOOST_LOG_TRIVIAL(info)
<< "CrealityPrintAgent: no preset scored for spool {" << vendor << " "
<< brand_name << " (" << base_type << ")} after considering " << considered
<< " presets; falling back to generic preset id \"" << fallback << "\""
<< (fallback_ok ? "" : " (NOT visible — returning empty)");
return fallback_ok ? fallback : std::string();
}
std::sort(matches.begin(), matches.end(),
[](const Match& a, const Match& b) {
if (a.score != b.score) return a.score > b.score;
if (a.is_user != b.is_user) return !a.is_user; // prefer system over user
return false;
});
BOOST_LOG_TRIVIAL(info)
<< "CrealityPrintAgent: matched spool {" << vendor << " " << brand_name
<< " (" << base_type << ")} -> preset \"" << matches.front().preset->name
<< "\" (score=" << matches.front().score
<< ", " << matches.size() << " candidate(s) of " << considered << " considered)";
return matches.front().preset->filament_id;
}
CrealityPrintAgent::CrealityPrintAgent(std::string log_dir)
: MoonrakerPrinterAgent(std::move(log_dir))
{
}
AgentInfo CrealityPrintAgent::get_agent_info_static()
{
return AgentInfo{
"crealityprint",
"CrealityPrint",
CrealityPrintAgent_VERSION,
"Creality K-series printer agent (CFS-aware filament sync)"
};
}
std::string CrealityPrintAgent::normalize_filament_type(const std::string& filament_type)
{
static const std::vector<std::string> bases = {
"PETG", "PET", "PLA", "ABS", "ASA", "TPU", "PC", "PA", "PVA", "HIPS"
};
for (const auto& base : bases) {
if (filament_type.rfind(base, 0) == 0) return base;
}
return filament_type;
}
// Parse the boxsInfo JSON returned by CrealityPrint::query_boxes_info().
// Schema (verified 2026-05-06 against K2 Combo F021 firmware v1.1.260206):
// { "boxsInfo": { "materialBoxs": [
// { "id": int, "state": int, "type": int, // type 0 = CFS, 1 = single-spool external
// "materials": [
// { "id": int, "state": int, // state 1 = loaded
// "vendor": str, "type": str, "name": str,
// "color": "#0RRGGBB" }, ...
// ]}, ...
// ]}}
bool CrealityPrintAgent::parse_cfs_response(const std::string& response,
std::vector<CFSSlot>& slots,
int& box_count,
std::string& error)
{
using nlohmann::json;
slots.clear();
box_count = 0;
if (response.empty()) {
error = "empty response";
return false;
}
json resp;
try {
resp = json::parse(response);
} catch (const std::exception& e) {
error = std::string("JSON parse error: ") + e.what();
return false;
}
if (!resp.contains("boxsInfo") || !resp["boxsInfo"].contains("materialBoxs")) {
error = "invalid schema (missing boxsInfo.materialBoxs)";
return false;
}
// Sequential AMS-style index for accepted CFS boxes. The K2's raw box.id has
// gaps (id 0 is the external spool holder, type=1, skipped) — using the raw id
// would publish phantom slots for the gap. Renumber accepted boxes 0,1,2,...
int cfs_count = 0;
for (const auto& box : resp["boxsInfo"]["materialBoxs"]) {
const int box_st = box.value("state", 0);
const int box_type = box.value("type", 0);
if (box_st != 1) continue; // inactive boxes
if (box_type != 0) continue; // non-CFS (external spool holder, handled separately by upload dialog)
const int cfs_index = cfs_count++;
if (!box.contains("materials") || !box["materials"].is_array())
continue;
for (const auto& mat : box["materials"]) {
// CFS slot state encoding observed across K2 family firmwares:
// * K2 (base) / K2 Pro : 0 = empty, 1 = loaded.
// * K2 Plus (1.1.5.5/CFS 1.4.2 onwards): 0 = empty,
// 1 = loaded AND currently
// selected as the active
// spool for printing,
// 2 = loaded but not selected.
// We treat anything non-zero as loaded. Belt-and-braces: also skip
// entries that look blank (no vendor and no type) regardless of state.
const int s_state = mat.value("state", 0);
const std::string s_vendor = mat.value("vendor", std::string());
const std::string s_type = mat.value("type", std::string());
if (s_state == 0) continue; // explicitly empty
if (s_vendor.empty() && s_type.empty()) continue; // blank entry — likely empty under a different state encoding
CFSSlot s;
s.box_id = cfs_index;
s.slot_id = mat.value("id", 0);
s.vendor = s_vendor;
s.brand_name = mat.value("name", "");
s.filament_type = s_type;
s.color_hex = mat.value("color", "#FFFFFF");
// Creality reports colour as "#0RRGGBB" (8 chars with a leading zero
// after '#'). Normalise to standard "#RRGGBB".
if (s.color_hex.size() == 8 && s.color_hex[0] == '#')
s.color_hex = "#" + s.color_hex.substr(2);
slots.push_back(std::move(s));
}
}
box_count = cfs_count;
return true;
}
bool CrealityPrintAgent::fetch_filament_info(std::string dev_id)
{
if (device_info.dev_ip.empty()) {
BOOST_LOG_TRIVIAL(warning)
<< "CrealityPrintAgent::fetch_filament_info: no device IP, falling back to base agent";
return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id));
}
// Build a CrealityPrint helper so we can use its model detection + WS helpers
// (added in upstream PR #13291).
DynamicPrintConfig cfg;
cfg.set_key_value("print_host", new ConfigOptionString("http://" + device_info.dev_ip));
cfg.set_key_value("print_host_webui", new ConfigOptionString(""));
cfg.set_key_value("printhost_cafile", new ConfigOptionString(""));
cfg.set_key_value("printhost_port", new ConfigOptionString(""));
cfg.set_key_value("printhost_apikey", new ConfigOptionString(device_info.api_key));
cfg.set_key_value("printhost_ssl_ignore_revoke", new ConfigOptionBool(false));
CrealityPrint host(&cfg);
// Defer to base if this isn't a K-series board with CFS firmware support.
if (!host.supports_multi_color_print()) {
BOOST_LOG_TRIVIAL(info)
<< "CrealityPrintAgent: " << host.model_name()
<< " is not CFS-capable, deferring to base Moonraker agent";
return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id));
}
BOOST_LOG_TRIVIAL(info)
<< "CrealityPrintAgent: querying CFS slots on " << host.model_name();
const std::string response = host.query_boxes_info();
std::vector<CFSSlot> slots;
int box_count = 0;
std::string parse_err;
if (!parse_cfs_response(response, slots, box_count, parse_err)) {
BOOST_LOG_TRIVIAL(warning)
<< "CrealityPrintAgent: CFS query failed (" << parse_err << "), "
<< "falling back to base agent";
return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id));
}
if (box_count == 0) {
// No active CFS boxes attached — printer is in direct-spool mode. Let the
// base agent take over so the user still gets whatever filament info
// Moonraker exposes.
BOOST_LOG_TRIVIAL(info)
<< "CrealityPrintAgent: no active CFS boxes, deferring to base agent";
return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id));
}
BOOST_LOG_TRIVIAL(info)
<< "CrealityPrintAgent: " << box_count << " CFS box(es), "
<< slots.size() << " loaded slot(s)";
// Index loaded slots by (box, slot) for O(1) lookup as we walk the full
// box_count * 4 grid, emitting an AmsTrayData entry for each physical slot.
std::map<std::pair<int, int>, const CFSSlot*> by_position;
for (const auto& s : slots)
by_position[{s.box_id, s.slot_id}] = &s;
auto* bundle = GUI::wxGetApp().preset_bundle;
const int max_slots = box_count * 4;
std::vector<AmsTrayData> trays;
trays.reserve(max_slots);
for (int box = 0; box < box_count; ++box) {
for (int idx = 0; idx < 4; ++idx) {
AmsTrayData tray;
tray.slot_index = box * 4 + idx;
auto it = by_position.find({box, idx});
if (it == by_position.end()) {
tray.has_filament = false;
trays.push_back(std::move(tray));
continue;
}
const CFSSlot& s = *it->second;
tray.has_filament = true;
tray.tray_type = normalize_filament_type(s.filament_type);
tray.tray_color = s.color_hex;
if (bundle) {
tray.tray_info_idx = match_filament_preset(
bundle->filaments, s.vendor, s.brand_name, tray.tray_type);
}
trays.push_back(std::move(tray));
}
}
build_ams_payload(box_count, max_slots - 1, trays);
return true;
}
} // namespace Slic3r

View File

@@ -0,0 +1,67 @@
#ifndef __CREALITY_PRINT_AGENT_HPP__
#define __CREALITY_PRINT_AGENT_HPP__
#include "MoonrakerPrinterAgent.hpp"
#include <string>
#include <vector>
namespace Slic3r {
class PresetCollection;
// Filament sync for Creality K-series printers with CFS.
//
// Inherits MoonrakerPrinterAgent for all communication / certificates / discovery /
// binding / print-job operations. Overrides fetch_filament_info() to query the
// K-series CFS over its port-9999 WebSocket, convert each loaded slot to an
// AmsTrayData entry, and publish via the base-class build_ams_payload() — the
// same shape used by QidiPrinterAgent and SnapmakerPrinterAgent.
//
// Model detection delegated to CrealityPrint::supports_multi_color_print() (PR #13291).
// For non-CFS K-series boards or when the WS query fails, falls back to the base
// MoonrakerPrinterAgent behaviour.
class CrealityPrintAgent final : public MoonrakerPrinterAgent
{
public:
struct CFSSlot
{
int box_id = 0; // CFS unit index (0 for first box, 1 for chained second box)
int slot_id = 0; // Slot index within the box (0..3)
std::string color_hex; // "#RRGGBB"
std::string filament_type; // "PLA", "ABS", "PETG", ...
std::string brand_name; // "Hyper PLA", ...
std::string vendor; // "Creality", "eSUN", or "" if unknown
};
explicit CrealityPrintAgent(std::string log_dir);
~CrealityPrintAgent() override = default;
static AgentInfo get_agent_info_static();
AgentInfo get_agent_info() override { return get_agent_info_static(); }
bool fetch_filament_info(std::string dev_id) override;
// Parse the boxsInfo JSON returned by CrealityPrint::query_boxes_info() into
// a flat list of loaded slots, plus the count of CFS boxes the printer reports.
static bool parse_cfs_response(const std::string& response,
std::vector<CFSSlot>& slots,
int& box_count,
std::string& error);
// Strip PLA/PETG/... subtype suffixes ("PLA Silk", "PLA+", "ABS Pro") to base
// type so the preset_bundle->filaments.filament_id_by_type() lookup succeeds.
static std::string normalize_filament_type(const std::string& filament_type);
// Score visible compatible filament presets against the CFS spool metadata and
// return the best-matching filament_id. See implementation for scoring details.
static std::string match_filament_preset(const PresetCollection& filaments,
const std::string& vendor,
const std::string& brand_name,
const std::string& base_type);
};
} // namespace Slic3r
#endif

View File

@@ -85,7 +85,7 @@ bool Duet::upload(PrintHostUpload upload_data, ProgressFn prorgess_fn, ErrorFn e
int err_code = dsf ? (status == 201 ? 0 : 1) : get_err_code_from_body(body);
if (err_code != 0) {
BOOST_LOG_TRIVIAL(error) << boost::format("Duet: Request completed but error code was received: %1%") % err_code;
error_fn(format_error(body, L("Unknown error occurred"), 0));
error_fn(format_error(body, _u8L("Unknown error occurred"), 0));
res = false;
} else if (upload_data.post_action == PrintHostPostUploadAction::StartPrint) {
wxString errormsg;
@@ -148,13 +148,13 @@ Duet::ConnectionType Duet::connect(wxString &msg) const
res = ConnectionType::rrf;
break;
case 1:
msg = format_error(body, L("Wrong password"), 0);
msg = format_error(body, _u8L("Wrong password"), 0);
break;
case 2:
msg = format_error(body, L("Could not get resources to create a new connection"), 0);
msg = format_error(body, _u8L("Could not get resources to create a new connection"), 0);
break;
default:
msg = format_error(body, L("Unknown error occurred"), 0);
msg = format_error(body, _u8L("Unknown error occurred"), 0);
break;
}

View File

@@ -1,6 +1,8 @@
#include "ElegooLink.hpp"
#include <algorithm>
#include <map>
#include <mutex>
#include <sstream>
#include <exception>
#include <boost/format.hpp>
@@ -60,6 +62,52 @@ namespace Slic3r {
namespace {
constexpr const char* ELEGOO_CC2_DEFAULT_TOKEN = "123456";
// AppConfig section for CC2 serial numbers, keyed by normalized print_host (host/IP).
constexpr const char* ELEGOO_DEV_SN_SECTION = "dev_sn";
static std::mutex s_sn_cache_mutex;
static std::map<std::string, std::string> s_sn_cache;
std::string sn_cache_key(const std::string& host_ip, const std::string& token)
{
return host_ip + ":" + token;
}
void cache_sn(const std::string& host_ip, const std::string& token, const std::string& sn)
{
if (host_ip.empty() || token.empty() || sn.empty())
return;
std::lock_guard<std::mutex> lock(s_sn_cache_mutex);
s_sn_cache[sn_cache_key(host_ip, token)] = sn;
}
std::string lookup_sn(const std::string& host_ip, const std::string& token)
{
std::lock_guard<std::mutex> lock(s_sn_cache_mutex);
auto it = s_sn_cache.find(sn_cache_key(host_ip, token));
return it != s_sn_cache.end() ? it->second : std::string{};
}
std::string load_sn_from_config(const std::string& host_ip)
{
if (host_ip.empty())
return {};
AppConfig* app_cfg = GUI::get_app_config();
if (app_cfg == nullptr)
return {};
return app_cfg->get(ELEGOO_DEV_SN_SECTION, host_ip);
}
void persist_sn(const std::string& host_ip, const std::string& token, const std::string& sn)
{
if (host_ip.empty() || sn.empty())
return;
cache_sn(host_ip, token, sn);
AppConfig* app_cfg = GUI::get_app_config();
if (app_cfg == nullptr)
return;
app_cfg->set_str(ELEGOO_DEV_SN_SECTION, host_ip, sn);
}
enum class ElegooPrinterType {
Other,
@@ -93,9 +141,9 @@ namespace Slic3r {
const int error_code = root.get<int>("error_code", -1);
if (error_code != 0) {
error_message = root.get<std::string>("message", "Printer returned an error");
error_message = root.get<std::string>("message", _u8L("Printer returned an error"));
if (error_message.empty())
error_message = "Printer returned an error";
error_message = _u8L("Printer returned an error");
error_message += " (" + std::to_string(error_code) + ")";
return false;
}
@@ -103,13 +151,13 @@ namespace Slic3r {
if (serial_number != nullptr) {
const auto system_info = root.get_child_optional("system_info");
if (!system_info) {
error_message = "Missing system_info in response";
error_message = _u8L("Missing system_info in response");
return false;
}
const auto sn = system_info->get_optional<std::string>("sn");
if (!sn || sn->empty()) {
error_message = "Missing printer serial number in response";
error_message = _u8L("Missing printer serial number in response");
return false;
}
*serial_number = *sn;
@@ -117,80 +165,39 @@ namespace Slic3r {
return true;
} catch (const std::exception&) {
error_message = "Error parsing response";
error_message = _u8L("Error parsing response");
return false;
}
}
std::string get_host_from_url(const std::string& url_in)
// NOTE (merge): host parsing was moved into Http::get_host_from_url /
// Http::get_host_header_value by the K2 discovery refactor on this branch, so the
// former ElegooLink-local get_host_from_url/get_host_from_url_no_port helpers are gone.
// main only added the CC2 serial-number lookup below; it is kept here and routed through
// Http::get_host_header_value, which has the same host:port semantics the SN cache key
// relies on.
std::string lookup_cc2_serial_impl(const std::string& printer_model,
const std::string& print_host,
const std::string& apikey)
{
std::string url = url_in;
// add http:// if there is no scheme
size_t double_slash = url.find("//");
if (double_slash == std::string::npos)
url = "http://" + url;
std::string out = url;
CURLU* hurl = curl_url();
if (hurl) {
// Parse the input URL.
CURLUcode rc = curl_url_set(hurl, CURLUPART_URL, url.c_str(), 0);
if (rc == CURLUE_OK) {
// Replace the address.
char* host;
rc = curl_url_get(hurl, CURLUPART_HOST, &host, 0);
if (rc == CURLUE_OK) {
char* port;
rc = curl_url_get(hurl, CURLUPART_PORT, &port, 0);
if (rc == CURLUE_OK && port != nullptr) {
out = std::string(host) + ":" + port;
curl_free(port);
} else {
out = host;
curl_free(host);
}
}
else
BOOST_LOG_TRIVIAL(error) << "ElegooLink get_host_from_url: failed to get host form URL " << url;
}
else
BOOST_LOG_TRIVIAL(error) << "ElegooLink get_host_from_url: failed to parse URL " << url;
curl_url_cleanup(hurl);
}
else
BOOST_LOG_TRIVIAL(error) << "ElegooLink get_host_from_url: failed to allocate curl_url";
return out;
if (classify_printer_model(printer_model) != ElegooPrinterType::CC2)
return {};
const std::string host_ip = Http::get_host_header_value(print_host);
const std::string token = get_cc2_token(apikey);
std::string sn = lookup_sn(host_ip, token);
if (sn.empty())
sn = load_sn_from_config(host_ip);
return sn;
}
std::string get_host_from_url_no_port(const std::string& url_in)
std::string lookup_cc2_serial(DynamicPrintConfig* config)
{
std::string url = url_in;
// add http:// if there is no scheme
size_t double_slash = url.find("//");
if (double_slash == std::string::npos)
url = "http://" + url;
std::string out = url;
CURLU* hurl = curl_url();
if (hurl) {
// Parse the input URL.
CURLUcode rc = curl_url_set(hurl, CURLUPART_URL, url.c_str(), 0);
if (rc == CURLUE_OK) {
// Replace the address.
char* host;
rc = curl_url_get(hurl, CURLUPART_HOST, &host, 0);
if (rc == CURLUE_OK) {
out = host;
curl_free(host);
}
else
BOOST_LOG_TRIVIAL(error) << "ElegooLink get_host_from_url: failed to get host form URL " << url;
}
else
BOOST_LOG_TRIVIAL(error) << "ElegooLink get_host_from_url: failed to parse URL " << url;
curl_url_cleanup(hurl);
}
else
BOOST_LOG_TRIVIAL(error) << "ElegooLink get_host_from_url: failed to allocate curl_url";
return out;
if (config == nullptr)
return {};
return lookup_cc2_serial_impl(config->opt_string("printer_model"),
config->opt_string("print_host"),
config->opt_string("printhost_apikey"));
}
#ifdef WIN32
@@ -333,11 +340,32 @@ namespace Slic3r {
if (classify_printer_model(config->opt_string("printer_model")) != ElegooPrinterType::CC2)
return fallback_webui;
std::string web_path = resources_dir() + "/plugins/elegoolink/web/lan_service_web/index.html";
std::string web_path = resources_dir() + "/web/elegoolink/lan_service_web/index.html";
std::replace(web_path.begin(), web_path.end(), '\\', '/');
web_path = "file://" + web_path;
web_path += "?access_code=" + get_cc2_token(config->opt_string("printhost_apikey"));
web_path += "&ip=" + get_host_from_url(host) + "&id=elegoo_123456";
const std::string token = get_cc2_token(config->opt_string("printhost_apikey"));
const std::string host_ip = Http::get_host_header_value(host);
// Pass sn= so the panel can subscribe to the correct MQTT topics.
std::string sn = lookup_cc2_serial(config);
if (sn.empty()) {
std::string error_msg;
auto http = Http::get("http://" + host_ip + "/system/info?X-Token=" + escape_string(token));
http.timeout_connect(3).timeout_max(5);
http.header("X-Token", token);
http.header("Accept", "application/json");
http.on_complete([&](std::string body, unsigned /*status*/) {
parse_cc2_response(body, error_msg, &sn);
}).perform_sync();
if (!sn.empty())
persist_sn(host_ip, token, sn);
}
web_path += "?access_code=" + token;
web_path += "&ip=" + host_ip;
if (!sn.empty())
web_path += "&sn=" + sn;
web_path += "&id=elegoo_123456";
const std::string lang = GUI::wxGetApp().current_language_code_safe().utf8_string();
if (!lang.empty())
@@ -376,33 +404,9 @@ namespace Slic3r {
std::string ElegooLink::get_sn() const
{
if (classify_printer_model(m_printerModel) != ElegooPrinterType::CC2)
return "";
const char* name = get_name();
std::string sn;
const auto token = cc2_token();
auto http = Http::get(make_cc2_info_url());
http.timeout_connect(10)
.timeout_max(15);
http.header("X-Token", token);
http.header("Accept", "application/json");
http.on_error([&](std::string body, std::string error, unsigned status) {
BOOST_LOG_TRIVIAL(error) << boost::format("%1%: Error getting CC2 device info for SN: %2%, HTTP %3%, body: `%4%`") % name % error % status % body;
})
.on_complete([&](std::string body, unsigned status) {
std::string error_message;
if (!parse_cc2_response(body, error_message, &sn)) {
BOOST_LOG_TRIVIAL(warning) << boost::format("%1%: Failed to parse CC2 SN response, HTTP %2%, reason: %3%") % name % status % error_message;
sn.clear();
}
})
#ifdef WIN32
.ssl_revoke_best_effort(m_ssl_revoke_best_effort)
#endif // WIN32
.perform_sync();
return sn;
// Panel IPC calls this on every load with a 10s timeout. Never block on HTTP
// here — URL sn= and dev_sn must be enough; HTTP is only for get_print_host_webui.
return lookup_cc2_serial_impl(m_printerModel, m_host, m_apikey);
}
bool ElegooLink::elegoo_test(wxString& msg) const{
@@ -427,7 +431,7 @@ namespace Slic3r {
if (std::regex_search(body, match, re)) {
res = true;
} else {
msg = format_error(body, "ElegooLink not detected", 0);
msg = format_error(body, _u8L("ElegooLink not detected"), 0);
res = false;
}
})
@@ -468,9 +472,9 @@ namespace Slic3r {
BOOST_LOG_TRIVIAL(error) << boost::format("%1%: Error getting CC2 device info: %2%, HTTP %3%, body: `%4%`") % name % error % status % body;
res = false;
if (status == 401 || status == 403)
msg = format_error(body, "Invalid access code", status);
msg = format_error(body, _u8L("Invalid access code"), status);
else
msg = format_error(body, error.empty() ? "CC2 device not detected" : error, status);
msg = format_error(body, error.empty() ? _u8L("CC2 device not detected") : error, status);
})
.on_complete([&](std::string body, unsigned status) {
BOOST_LOG_TRIVIAL(debug) << boost::format("%1%: Got CC2 device info: %2%") % name % body;
@@ -478,9 +482,10 @@ namespace Slic3r {
std::string serial_number;
if (!parse_cc2_response(body, error_message, &serial_number)) {
res = false;
msg = format_error(body, error_message.empty() ? "CC2 device not detected" : error_message, status);
msg = format_error(body, error_message.empty() ? _u8L("CC2 device not detected") : error_message, status);
return;
}
persist_sn(Http::get_host_header_value(m_host), token, serial_number);
res = true;
})
#ifdef WIN32
@@ -503,7 +508,6 @@ namespace Slic3r {
// Msg contains ip string.
auto url = substitute_host(make_url(""), GUI::into_u8(msg));
msg.Clear();
std::string host = get_host_from_url(m_host);
auto http = Http::get(url); // std::move(url));
// "Host" header is necessary here. We have resolved IP address and subsituted it into "url" variable.
// And when creating Http object above, libcurl automatically includes "Host" header from address it got.
@@ -511,7 +515,7 @@ namespace Slic3r {
// Not changing the host would work on the most cases (where there is 1 service on 1 hostname) but would break when f.e. reverse
// proxy is used (issue #9734). Also when allow_ip_resolve = 0, this is not needed, but it should not break anything if it stays.
// https://www.rfc-editor.org/rfc/rfc7230#section-5.4
http.header("Host", host);
http.header("Host", Http::get_host_header_value(m_host));
set_auth(http);
http.on_error([&](std::string body, std::string error, unsigned status) {
BOOST_LOG_TRIVIAL(error) << boost::format("%1%: Error getting version at %2% : %3%, HTTP %4%, body: `%5%`") % name % url %
@@ -527,7 +531,7 @@ namespace Slic3r {
if (std::regex_search(body, match, re)) {
res = true;
} else {
msg = format_error(body, "ElegooLink not detected", 0);
msg = format_error(body, _u8L("ElegooLink not detected"), 0);
res = false;
}
})
@@ -555,11 +559,10 @@ namespace Slic3r {
bool res = true;
const auto token = cc2_token();
auto url = substitute_host(make_cc2_info_url(), GUI::into_u8(msg));
std::string host_header = get_host_from_url(m_host);
auto http = Http::get(url);
msg.Clear();
http.header("Host", host_header);
http.header("Host", Http::get_host_header_value(m_host));
http.header("X-Token", token);
http.header("Accept", "application/json");
http.on_error([&](std::string body, std::string error, unsigned status) {
@@ -567,16 +570,16 @@ namespace Slic3r {
error % status % body;
res = false;
if (status == 401 || status == 403)
msg = format_error(body, "Invalid access code", status);
msg = format_error(body, _u8L("Invalid access code"), status);
else
msg = format_error(body, error.empty() ? "CC2 device not detected" : error, status);
msg = format_error(body, error.empty() ? _u8L("CC2 device not detected") : error, status);
})
.on_complete([&](std::string body, unsigned status) {
std::string error_message;
std::string serial_number;
if (!parse_cc2_response(body, error_message, &serial_number)) {
res = false;
msg = format_error(body, error_message.empty() ? "CC2 device not detected" : error_message, status);
msg = format_error(body, error_message.empty() ? _u8L("CC2 device not detected") : error_message, status);
return;
}
res = true;
@@ -618,7 +621,7 @@ namespace Slic3r {
std::string url = substitute_host(make_cc2_upload_url(), resolved_addr.to_string());
info_fn(L"resolve", boost::nowide::widen(url));
return loopUploadCC2(url, get_host_from_url(m_host), std::move(upload_data), prorgess_fn, error_fn, info_fn);
return loopUploadCC2(url, Http::get_host_header_value(m_host), std::move(upload_data), prorgess_fn, error_fn, info_fn);
}
wxString legacy_msg = GUI::from_u8(resolved_addr.to_string());
@@ -664,7 +667,7 @@ namespace Slic3r {
}
#endif // _WIN32
return loopUploadCC2(url, get_host_from_url(m_host), std::move(upload_data), prorgess_fn, error_fn, info_fn);
return loopUploadCC2(url, Http::get_host_header_value(m_host), std::move(upload_data), prorgess_fn, error_fn, info_fn);
}
wxString legacy_msg;
@@ -732,7 +735,7 @@ namespace Slic3r {
} else {
// get error messages
pt::ptree messages = root.get_child("messages");
std::string error_message = "ErrorCode : " + code + "\n";
std::string error_message = (boost::format(_u8L("Error code: %1%")) % code).str() + "\n";
for (pt::ptree::value_type& message : messages) {
std::string field = message.second.get<std::string>("field");
std::string msg = message.second.get<std::string>("message");
@@ -742,10 +745,10 @@ namespace Slic3r {
}
} catch (...) {
BOOST_LOG_TRIVIAL(error) << boost::format("%1%: Error parsing response: %2%") % name % body;
error_fn(wxString::FromUTF8("Error parsing response"));
error_fn(_L("Error parsing response"));
}
} else {
error_fn(format_error(body, "upload failed", status));
error_fn(format_error(body, _u8L("Upload failed"), status));
}
})
.on_error([&](std::string body, std::string error, unsigned status) {
@@ -803,8 +806,7 @@ namespace Slic3r {
// on the most cases (where there is 1 service on 1 hostname) but would break when f.e. reverse proxy is used (issue #9734). Also
// when allow_ip_resolve = 0, this is not needed, but it should not break anything if it stays.
// https://www.rfc-editor.org/rfc/rfc7230#section-5.4
std::string host = get_host_from_url(m_host);
http.header("Host", host);
http.header("Host", Http::get_host_header_value(m_host));
http.header("Accept", "application/json, text/plain, */*");
#endif // _WIN32
set_auth(http);
@@ -835,7 +837,7 @@ namespace Slic3r {
if (res) {
if (upload_data.post_action == PrintHostPostUploadAction::StartPrint) {
// connect to websocket, since the upload is successful, the file will be printed
std::string wsUrl = get_host_from_url_no_port(m_host);
std::string wsUrl = Http::get_host_from_url(m_host);
WebSocketClient client;
try {
client.connect(wsUrl, "3030", "/websocket");
@@ -919,7 +921,7 @@ namespace Slic3r {
BOOST_LOG_TRIVIAL(debug) << boost::format("%1%: CC2 chunk uploaded: HTTP %2%: %3%") % name % status % body;
std::string error_message;
if (!parse_cc2_response(body, error_message)) {
error_fn(format_error(body, error_message.empty() ? "CC2 upload failed" : error_message, status));
error_fn(format_error(body, error_message.empty() ? _u8L("CC2 upload failed") : error_message, status));
return;
}
result = true;
@@ -927,9 +929,9 @@ namespace Slic3r {
.on_error([&](std::string body, std::string error, unsigned status) {
BOOST_LOG_TRIVIAL(error) << boost::format("%1%: Error uploading CC2 chunk: %2%, HTTP %3%, body: `%4%`") % name % error % status % body;
if (status == 401 || status == 403)
error_fn(format_error(body, "Invalid access code", status));
error_fn(format_error(body, _u8L("Invalid access code"), status));
else
error_fn(format_error(body, error.empty() ? "CC2 upload failed" : error, status));
error_fn(format_error(body, error.empty() ? _u8L("CC2 upload failed") : error, status));
})
.on_progress([&](Http::Progress progress, bool& cancel) {
if (progress.ultotal == progress.ulnow)
@@ -1016,7 +1018,7 @@ namespace Slic3r {
#ifndef WIN32
return upload_inner_with_host(std::move(upload_data), prorgess_fn, error_fn, info_fn);
#else
std::string host = get_host_from_url(m_host);
std::string host = Http::get_host_from_url(m_host);
// decide what to do based on m_host - resolve hostname or upload to ip
std::vector<boost::asio::ip::address> resolved_addr;

View File

@@ -85,7 +85,7 @@ bool fix_model_with_cgal_gui(ModelObject &model_object, int volume_idx, GUI::Pro
size_t ivolume = 0;
// Orca: Lambda for updating progress from worker thread.
auto on_progress = [&mtx, &condition, &ivolume, &model_object, &progress](const char *msg, unsigned prcnt) {
auto on_progress = [&mtx, &condition, &ivolume, &model_object, &progress](const std::string &msg, unsigned prcnt) {
std::unique_lock<std::mutex> lock(mtx);
progress.message = msg;
const size_t total = std::max<size_t>(1, model_object.volumes.size());
@@ -108,7 +108,7 @@ bool fix_model_with_cgal_gui(ModelObject &model_object, int volume_idx, GUI::Pro
if (canceled)
throw RepairCanceledException();
on_progress(L("Repairing model object"), 10);
on_progress(_u8L("Repairing model object"), 10);
ModelVolume *volume = model_object.volumes[ivolume];
@@ -118,7 +118,7 @@ bool fix_model_with_cgal_gui(ModelObject &model_object, int volume_idx, GUI::Pro
parts_count = volume->split(1, keep_painting);
if (parts_count > 1) {
const std::string msg = Slic3r::format(L("Split into %1% parts"), parts_count);
on_progress(msg.c_str(), 10);
on_progress(msg, 10);
}
}
@@ -145,7 +145,7 @@ bool fix_model_with_cgal_gui(ModelObject &model_object, int volume_idx, GUI::Pro
if (removed_parts >= parts_count) {
ivolume = part_end;
on_progress(L("Repair finished"), 100);
on_progress(_u8L("Repair finished"), 100);
continue;
}
@@ -161,7 +161,7 @@ bool fix_model_with_cgal_gui(ModelObject &model_object, int volume_idx, GUI::Pro
std::string error;
if (!MeshBoolean::cgal::repair(mesh, nullptr, &error))
throw Slic3r::RuntimeError(error.empty() ? L("Repair failed") : error.c_str());
throw Slic3r::RuntimeError(error.empty() ? _u8L("Repair failed") : error);
part_volume->set_mesh(std::move(mesh));
part_volume->calculate_convex_hull();
@@ -175,20 +175,20 @@ bool fix_model_with_cgal_gui(ModelObject &model_object, int volume_idx, GUI::Pro
ivolume = part_end;
on_progress(L("Repair finished"), 100);
on_progress(_u8L("Repair finished"), 100);
}
model_object.invalidate_bounding_box();
if (ivolume > 0)
--ivolume;
on_progress(L("Repair finished"), 100);
on_progress(_u8L("Repair finished"), 100);
success = true;
finished = true;
} catch (RepairCanceledException &) {
canceled = true;
finished = true;
on_progress(L("Repair canceled"), 100);
on_progress(_u8L("Repair canceled"), 100);
} catch (std::exception &ex) {
success = false;
finished = true;

View File

@@ -119,7 +119,7 @@ bool FlashAir::upload(PrintHostUpload upload_data, ProgressFn prorgess_fn, Error
res = boost::icontains(body, "SUCCESS");
if (! res) {
BOOST_LOG_TRIVIAL(error) << boost::format("%1%: Request completed but no SUCCESS message was received.") % name;
error_fn(format_error(body, L("Unknown error occurred"), 0));
error_fn(format_error(body, _u8L("Unknown error occurred"), 0));
}
})
.perform_sync();
@@ -140,7 +140,7 @@ bool FlashAir::upload(PrintHostUpload upload_data, ProgressFn prorgess_fn, Error
res = boost::icontains(body, "SUCCESS");
if (! res) {
BOOST_LOG_TRIVIAL(error) << boost::format("%1%: Request completed but no SUCCESS message was received.") % name;
error_fn(format_error(body, L("Unknown error occurred"), 0));
error_fn(format_error(body, _u8L("Unknown error occurred"), 0));
}
})
.perform_sync();
@@ -156,7 +156,7 @@ bool FlashAir::upload(PrintHostUpload upload_data, ProgressFn prorgess_fn, Error
res = boost::icontains(body, "SUCCESS");
if (! res) {
BOOST_LOG_TRIVIAL(error) << boost::format("%1%: Request completed but no SUCCESS message was received.") % name;
error_fn(format_error(body, L("Unknown error occurred"), 0));
error_fn(format_error(body, _u8L("Unknown error occurred"), 0));
}
})
.on_error([&](std::string body, std::string error, unsigned status) {

View File

@@ -1,8 +1,13 @@
#include "Flashforge.hpp"
#include <algorithm>
#include <array>
#include <ctime>
#include <chrono>
#include <thread>
#include <sstream>
#include <fstream>
#include <set>
#include <map>
#include <boost/filesystem/path.hpp>
#include <boost/format.hpp>
#include <boost/log/trivial.hpp>
@@ -19,6 +24,10 @@
#include <wx/textctrl.h>
#include <wx/checkbox.h>
#include <boost/beast/core/detail/base64.hpp>
#include <curl/curl.h>
#include <nlohmann/json.hpp>
#include "libslic3r/PrintConfig.hpp"
#include "slic3r/GUI/GUI.hpp"
#include "slic3r/GUI/I18N.hpp"
@@ -30,20 +39,298 @@
namespace fs = boost::filesystem;
namespace pt = boost::property_tree;
using json = nlohmann::json;
namespace Slic3r {
namespace {
constexpr unsigned short FLASHFORGE_DISCOVERY_PORT = 48899;
constexpr unsigned short FLASHFORGE_DISCOVERY_LISTEN_PORT = 18007;
const std::array<unsigned char, 20> FLASHFORGE_DISCOVERY_MESSAGE = {
0x77, 0x77, 0x77, 0x2e, 0x75, 0x73, 0x72, 0x22,
0x65, 0x36, 0xc0, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00
};
std::string trim_null_terminated_ascii(const char* data, size_t len)
{
std::string out(data, data + len);
const auto pos = out.find('\0');
if (pos != std::string::npos)
out.resize(pos);
boost::trim(out);
return out;
}
bool parse_discovery_response(const std::vector<unsigned char>& response, const std::string& ip_address, FlashforgeDiscoveredPrinter& printer)
{
if (response.size() < 0xC4)
return false;
printer.name = trim_null_terminated_ascii(reinterpret_cast<const char*>(response.data()), 32);
printer.serial_number = trim_null_terminated_ascii(reinterpret_cast<const char*>(response.data() + 0x92), 32);
printer.ip_address = ip_address;
return !(printer.name.empty() && printer.serial_number.empty());
}
std::vector<std::string> get_discovery_broadcast_addresses()
{
std::set<std::string> addresses = {"255.255.255.255", "192.168.0.255", "192.168.1.255"};
try {
boost::asio::io_context io_context;
boost::asio::ip::tcp::resolver resolver(io_context);
boost::system::error_code ec;
const auto host_name = boost::asio::ip::host_name(ec);
if (!ec) {
const auto results = resolver.resolve(boost::asio::ip::tcp::v4(), host_name, "", ec);
if (!ec) {
for (const auto& entry : results) {
const auto addr = entry.endpoint().address();
if (!addr.is_v4())
continue;
const auto bytes = addr.to_v4().to_bytes();
if (bytes[0] == 127)
continue;
addresses.insert((boost::format("%1%.%2%.%3%.255") % static_cast<int>(bytes[0]) % static_cast<int>(bytes[1]) % static_cast<int>(bytes[2])).str());
}
}
}
} catch (...) {
}
return {addresses.begin(), addresses.end()};
}
std::string safe_config_string(DynamicPrintConfig* config, const char* key)
{
if (config == nullptr)
return {};
if (const auto* opt = config->option<ConfigOptionString>(key); opt != nullptr)
return opt->value;
return {};
}
bool try_parse_json_int(const json& value, int& out)
{
try {
if (value.is_number_integer() || value.is_number_unsigned()) {
out = value.get<int>();
return true;
}
if (value.is_boolean()) {
out = value.get<bool>() ? 1 : 0;
return true;
}
if (value.is_string()) {
std::string text = value.get<std::string>();
boost::trim(text);
if (text.empty())
return false;
size_t pos = 0;
const long parsed = std::stol(text, &pos, 10);
if (pos == text.size()) {
out = static_cast<int>(parsed);
return true;
}
}
} catch (...) {
}
return false;
}
bool validate_local_api_response(const std::string& response_body, wxString& error_msg)
{
const auto parsed = json::parse(response_body, nullptr, false, true);
if (parsed.is_discarded() || !parsed.is_object()) {
error_msg = _(L("Flashforge returned an invalid JSON response."));
return false;
}
int result_code = 0;
bool has_code = false;
if (parsed.contains("code"))
has_code = try_parse_json_int(parsed["code"], result_code);
if (!has_code && parsed.contains("err"))
has_code = try_parse_json_int(parsed["err"], result_code);
if (has_code && result_code != 0) {
std::string message;
if (parsed.contains("message") && parsed["message"].is_string())
message = parsed["message"].get<std::string>();
else if (parsed.contains("msg") && parsed["msg"].is_string())
message = parsed["msg"].get<std::string>();
if (message.empty())
message = "Request failed";
error_msg = GUI::from_u8((boost::format("Flashforge local API error %1%: %2%") % result_code % message).str());
return false;
}
return true;
}
std::string sanitize_flashforge_filename(const std::string& filename, const std::string& fallback_extension = {})
{
std::string basename = fs::path(filename).filename().string();
if (basename.empty()) {
basename = "print";
if (!fallback_extension.empty())
basename += fallback_extension;
}
for (char& ch : basename) {
const bool is_ascii_alnum = (ch >= '0' && ch <= '9') || (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z');
if (!is_ascii_alnum && ch != '.' && ch != '_' && ch != '-') {
ch = '_';
}
}
return basename;
}
} // namespace
Flashforge::Flashforge(DynamicPrintConfig* config)
: m_host(config->opt_string("print_host"))
: m_host()
, m_serial_number()
, m_check_code()
, m_console_port("8899")
, m_gcFlavor(config->option<ConfigOptionEnum<GCodeFlavor>>("gcode_flavor")->value)
, m_gcFlavor(gcfMarlinLegacy)
, m_bufferSize(4096) // 4K buffer size
{}
{
m_host = safe_config_string(config, "print_host");
m_serial_number = safe_config_string(config, "flashforge_serial_number");
m_check_code = safe_config_string(config, "printhost_apikey");
if (config != nullptr) {
if (const auto* gcode_flavor = config->option<ConfigOptionEnum<GCodeFlavor>>("gcode_flavor"); gcode_flavor != nullptr)
m_gcFlavor = gcode_flavor->value;
}
}
const char* Flashforge::get_name() const { return "Flashforge"; }
bool Flashforge::discover_printers(std::vector<FlashforgeDiscoveredPrinter>& printers, wxString& msg, int timeout_ms, int idle_timeout_ms, int max_retries)
{
printers.clear();
try {
const auto broadcast_addresses = get_discovery_broadcast_addresses();
std::map<std::string, FlashforgeDiscoveredPrinter> by_ip;
for (int attempt = 0; attempt < std::max(1, max_retries); ++attempt) {
boost::asio::io_context io_context;
boost::asio::ip::udp::socket socket(io_context);
boost::system::error_code ec;
socket.open(boost::asio::ip::udp::v4(), ec);
if (ec) {
msg = wxString::FromUTF8(ec.message().c_str());
return false;
}
socket.set_option(boost::asio::socket_base::broadcast(true), ec);
if (ec) {
msg = wxString::FromUTF8(ec.message().c_str());
return false;
}
socket.set_option(boost::asio::socket_base::reuse_address(true), ec);
if (ec) {
msg = wxString::FromUTF8(ec.message().c_str());
return false;
}
socket.bind({boost::asio::ip::udp::v4(), FLASHFORGE_DISCOVERY_LISTEN_PORT}, ec);
if (ec) {
msg = wxString::FromUTF8(ec.message().c_str());
return false;
}
for (const auto& addr : broadcast_addresses) {
socket.send_to(boost::asio::buffer(FLASHFORGE_DISCOVERY_MESSAGE),
{boost::asio::ip::make_address_v4(addr, ec), FLASHFORGE_DISCOVERY_PORT}, 0, ec);
ec.clear();
}
socket.non_blocking(true, ec);
if (ec) {
msg = wxString::FromUTF8(ec.message().c_str());
return false;
}
const auto start = std::chrono::steady_clock::now();
auto last_reply = start;
while (true) {
const auto now = std::chrono::steady_clock::now();
if (std::chrono::duration_cast<std::chrono::milliseconds>(now - start).count() >= timeout_ms)
break;
if (!by_ip.empty() && std::chrono::duration_cast<std::chrono::milliseconds>(now - last_reply).count() >= idle_timeout_ms)
break;
std::vector<unsigned char> buffer(512);
boost::asio::ip::udp::endpoint remote_endpoint;
const auto received = socket.receive_from(boost::asio::buffer(buffer), remote_endpoint, 0, ec);
if (!ec) {
buffer.resize(received);
FlashforgeDiscoveredPrinter printer;
if (parse_discovery_response(buffer, remote_endpoint.address().to_string(), printer)) {
by_ip[printer.ip_address] = std::move(printer);
last_reply = std::chrono::steady_clock::now();
}
} else if (ec == boost::asio::error::would_block || ec == boost::asio::error::try_again) {
ec.clear();
std::this_thread::sleep_for(std::chrono::milliseconds(50));
} else {
msg = wxString::FromUTF8(ec.message().c_str());
return false;
}
}
if (!by_ip.empty())
break;
}
for (auto& [_, printer] : by_ip)
printers.emplace_back(std::move(printer));
std::sort(printers.begin(), printers.end(), [](const FlashforgeDiscoveredPrinter& lhs, const FlashforgeDiscoveredPrinter& rhs) {
if (lhs.name != rhs.name)
return lhs.name < rhs.name;
return lhs.ip_address < rhs.ip_address;
});
if (printers.empty()) {
msg = _(L("No Flashforge printers were discovered on the local network."));
return false;
}
return true;
} catch (const std::exception& ex) {
msg = wxString::FromUTF8(ex.what());
return false;
}
}
bool Flashforge::test(wxString& msg) const
{
if (!m_serial_number.empty() && !m_check_code.empty())
return test_local_api(msg);
BOOST_LOG_TRIVIAL(debug) << boost::format("[Flashforge Serial] testing connection");
// Utils::TCPConsole console(m_host, m_console_port);
Utils::TCPConsole client(m_host, m_console_port);
@@ -58,11 +345,19 @@ bool Flashforge::test(wxString& msg) const
return res;
}
wxString Flashforge::get_test_ok_msg() const { return _(L("Serial connection to Flashforge is working correctly.")); }
wxString Flashforge::get_test_ok_msg() const
{
if (!m_serial_number.empty() && !m_check_code.empty())
return _(L("Connected to Flashforge local API successfully."));
return _(L("Serial connection to Flashforge is working correctly."));
}
wxString Flashforge::get_test_failed_msg(wxString& msg) const
{
return GUI::from_u8((boost::format("%s: %s") % _utf8(L("Could not connect to Flashforge via serial")) % std::string(msg.ToUTF8())).str());
const std::string prefix = (!m_serial_number.empty() && !m_check_code.empty()) ?
_utf8(L("Could not connect to Flashforge local API")) :
_utf8(L("Could not connect to Flashforge via serial"));
return GUI::from_u8((boost::format("%s: %s") % prefix % std::string(msg.ToUTF8())).str());
}
@@ -98,21 +393,25 @@ bool Flashforge::connect(wxString& msg) const
bool Flashforge::start_print(wxString& msg, const std::string& filename) const
{
Utils::TCPConsole client(m_host, m_console_port);
Slic3r::Utils::SerialMessage startPrintCommand = {(boost::format("~M23 0:/user/%1%") % filename).str(), Slic3r::Utils::Command};
const std::string safe_filename = sanitize_flashforge_filename(filename);
Slic3r::Utils::SerialMessage startPrintCommand = {(boost::format("~M23 0:/user/%1%") % safe_filename).str(), Slic3r::Utils::Command};
client.enqueue_cmd(startPrintCommand);
bool res = client.run_queue();
if (!res) {
msg = wxString::FromUTF8(client.error_message().c_str());
BOOST_LOG_TRIVIAL(info) << boost::format("[Flashforge Serial] Failed to start print %1%") % filename;
BOOST_LOG_TRIVIAL(info) << boost::format("[Flashforge Serial] Failed to start print %1%") % safe_filename;
} else
BOOST_LOG_TRIVIAL(info) << boost::format("[Flashforge Serial] Started print %1%") % filename;
BOOST_LOG_TRIVIAL(info) << boost::format("[Flashforge Serial] Started print %1%") % safe_filename;
return res;
}
bool Flashforge::upload(PrintHostUpload upload_data, ProgressFn progress_fn, ErrorFn error_fn, InfoFn info_fn) const
{
if (!m_serial_number.empty() && !m_check_code.empty())
return upload_local_api(std::move(upload_data), std::move(progress_fn), std::move(error_fn));
bool res = true;
wxString errormsg;
@@ -121,6 +420,8 @@ bool Flashforge::upload(PrintHostUpload upload_data, ProgressFn progress_fn, Err
try {
res = connect(errormsg);
const std::string fallback_extension = upload_data.source_path.extension().string().empty() ? ".gcode" : upload_data.source_path.extension().string();
const std::string upload_filename = sanitize_flashforge_filename(upload_data.upload_path.string(), fallback_extension);
std::ifstream newfile;
newfile.open(upload_data.source_path.c_str(), std::ios::binary); // open a file to perform read operation using file object
@@ -142,7 +443,7 @@ bool Flashforge::upload(PrintHostUpload upload_data, ProgressFn progress_fn, Err
newfile.close(); // close the file object.
}
Slic3r::Utils::SerialMessage fileuploadCommand =
{(boost::format("~M28 %1% 0:/user/%2%") % gcodeFile.size() % upload_data.upload_path.generic_string()).str(),
{(boost::format("~M28 %1% 0:/user/%2%") % gcodeFile.size() % upload_filename).str(),
Slic3r::Utils::Command};
client.enqueue_cmd(fileuploadCommand);
@@ -178,7 +479,7 @@ bool Flashforge::upload(PrintHostUpload upload_data, ProgressFn progress_fn, Err
res = client.run_queue();
if (upload_data.post_action == PrintHostPostUploadAction::StartPrint)
res = start_print(errormsg, upload_data.upload_path.string());
res = start_print(errormsg, upload_filename);
}
} catch (const std::exception& e) {
@@ -190,6 +491,185 @@ bool Flashforge::upload(PrintHostUpload upload_data, ProgressFn progress_fn, Err
return res;
}
bool Flashforge::test_local_api(wxString& msg) const
{
std::string body;
return request_local_api_json("detail", json{{"serialNumber", m_serial_number}, {"checkCode", m_check_code}}.dump(), body, msg);
}
bool Flashforge::fetch_material_slots(std::vector<FlashforgeMaterialSlot>& slots, bool* supports_material_station, wxString& msg) const
{
slots.clear();
if (m_serial_number.empty() || m_check_code.empty()) {
msg = _(L("Flashforge local API requires both serial number and access code."));
return false;
}
std::string body;
if (!request_local_api_json("detail", json{{"serialNumber", m_serial_number}, {"checkCode", m_check_code}}.dump(), body, msg))
return false;
const auto parsed = json::parse(body, nullptr, false, true);
if (parsed.is_discarded()) {
msg = _(L("Flashforge returned an invalid JSON response."));
return false;
}
const auto& detail = parsed.contains("detail") ? parsed["detail"] : parsed;
const auto& station = detail.contains("matlStationInfo") ? detail["matlStationInfo"] :
detail.contains("MatlStationInfo") ? detail["MatlStationInfo"] : json();
const auto& slot_infos = station.contains("slotInfos") ? station["slotInfos"] :
station.contains("SlotInfos") ? station["SlotInfos"] : json::array();
bool reports_material_station = false;
int has_material_station_flag = 0;
if (detail.contains("hasMatlStation") && try_parse_json_int(detail["hasMatlStation"], has_material_station_flag))
reports_material_station = has_material_station_flag != 0;
else if (detail.contains("HasMatlStation") && try_parse_json_int(detail["HasMatlStation"], has_material_station_flag))
reports_material_station = has_material_station_flag != 0;
int slot_count = 0;
if (station.contains("slotCnt") && try_parse_json_int(station["slotCnt"], slot_count))
reports_material_station = reports_material_station || slot_count > 0;
else if (station.contains("SlotCnt") && try_parse_json_int(station["SlotCnt"], slot_count))
reports_material_station = reports_material_station || slot_count > 0;
if (slot_infos.is_array() && !slot_infos.empty())
reports_material_station = true;
if (supports_material_station != nullptr)
*supports_material_station = reports_material_station;
for (const auto& slot : slot_infos) {
FlashforgeMaterialSlot info;
info.slot_id = slot.value("slotId", static_cast<int>(slots.size()) + 1);
info.has_filament = slot.value("hasFilament", false);
info.material_name = slot.value("materialName", std::string());
info.material_color = slot.value("materialColor", std::string());
slots.emplace_back(std::move(info));
}
return true;
}
bool Flashforge::upload_local_api(PrintHostUpload upload_data, ProgressFn progress_fn, ErrorFn error_fn) const
{
bool res = true;
std::string material_map_b64;
std::string material_map_json = "[]";
auto leveling_before_print = upload_data.extended_info["levelingBeforePrint"] == "1";
auto time_lapse_video = upload_data.extended_info["timeLapseVideo"] == "1";
auto use_material_station = upload_data.extended_info["useMatlStation"] == "1";
if (auto it = upload_data.extended_info.find("materialMappings"); it != upload_data.extended_info.end())
material_map_json = it->second;
material_map_b64.resize(boost::beast::detail::base64::encoded_size(material_map_json.size()));
material_map_b64.resize(boost::beast::detail::base64::encode(material_map_b64.data(), material_map_json.data(), material_map_json.size()));
auto url = make_http_url("uploadGcode");
const std::string fallback_extension = upload_data.source_path.extension().string().empty() ? (upload_data.use_3mf ? ".3mf" : ".gcode") : upload_data.source_path.extension().string();
auto filename = sanitize_flashforge_filename(upload_data.upload_path.string(), fallback_extension);
std::string file_size;
try {
file_size = std::to_string(fs::file_size(upload_data.source_path));
} catch (...) {
file_size = "0";
}
auto http = Http::post(url);
http.header("serialNumber", m_serial_number)
.header("checkCode", m_check_code)
.header("fileSize", file_size)
.header("printNow", upload_data.post_action == PrintHostPostUploadAction::StartPrint ? "true" : "false")
.header("levelingBeforePrint", leveling_before_print ? "true" : "false")
.header("flowCalibration", "false")
.header("firstLayerInspection", "false")
.header("timeLapseVideo", time_lapse_video ? "true" : "false")
.header("useMatlStation", use_material_station ? "true" : "false")
.header("gcodeToolCnt", upload_data.extended_info["gcodeToolCnt"])
.header("materialMappings", material_map_b64)
.form_add_file("gcodeFile", upload_data.source_path.string(), filename)
.on_complete([&](std::string body, unsigned status) {
wxString msg;
if (!validate_local_api_response(body, msg)) {
BOOST_LOG_TRIVIAL(error) << boost::format("[Flashforge HTTP] upload rejected by printer: HTTP %1% body: `%2%`") % status % body;
error_fn(msg);
res = false;
} else {
BOOST_LOG_TRIVIAL(info) << boost::format("[Flashforge HTTP] upload complete: HTTP %1% body: %2%") % status % body;
}
})
.on_error([&](std::string body, std::string error, unsigned status) {
BOOST_LOG_TRIVIAL(error) << boost::format("[Flashforge HTTP] upload failed: %1%, HTTP %2%, body: `%3%`") % error % status % body;
error_fn(format_error(body, error, status));
res = false;
})
.on_progress([&](Http::Progress progress, bool& cancel) {
progress_fn(std::move(progress), cancel);
if (cancel)
res = false;
})
.perform_sync();
return res;
}
bool Flashforge::request_local_api_json(const std::string& path, const std::string& body, std::string& response_body, wxString& error_msg) const
{
bool ok = true;
auto http = Http::post(make_http_url(path));
http.header("Content-Type", "application/json")
.set_post_body(body)
.on_complete([&](std::string body_text, unsigned) {
response_body = std::move(body_text);
if (!validate_local_api_response(response_body, error_msg))
ok = false;
})
.on_error([&](std::string body_text, std::string error, unsigned status) {
response_body = std::move(body_text);
error_msg = format_error(response_body, error, status);
ok = false;
})
.perform_sync();
return ok;
}
std::string Flashforge::make_http_url(const std::string& path) const
{
return (boost::format("http://%1%:8898/%2%") % extract_host_name() % path).str();
}
std::string Flashforge::extract_host_name() const
{
std::string host = m_host;
if (host.find("://") == std::string::npos) {
const auto slash_pos = host.find('/');
if (slash_pos != std::string::npos)
host = host.substr(0, slash_pos);
return host;
}
std::string out = host;
CURLU* hurl = curl_url();
if (!hurl)
return host;
const auto rc = curl_url_set(hurl, CURLUPART_URL, host.c_str(), 0);
if (rc == CURLUE_OK) {
char* raw_host = nullptr;
if (curl_url_get(hurl, CURLUPART_HOST, &raw_host, 0) == CURLUE_OK && raw_host != nullptr) {
out = raw_host;
curl_free(raw_host);
}
}
curl_url_cleanup(hurl);
return out;
}
int Flashforge::get_err_code_from_body(const std::string& body) const
{
pt::ptree root;

View File

@@ -1,6 +1,7 @@
#ifndef slic3r_FlashForge_hpp_
#define slic3r_FlashForge_hpp_
#include <vector>
#include <string>
#include <wx/string.h>
#include "PrintHost.hpp"
@@ -12,6 +13,21 @@ namespace Slic3r {
class DynamicPrintConfig;
class Http;
struct FlashforgeMaterialSlot
{
int slot_id {0}; // API is 1-based.
bool has_filament {false};
std::string material_name;
std::string material_color;
};
struct FlashforgeDiscoveredPrinter
{
std::string name;
std::string serial_number;
std::string ip_address;
};
class Flashforge : public PrintHost
{
public:
@@ -24,13 +40,17 @@ public:
wxString get_test_ok_msg() const override;
wxString get_test_failed_msg(wxString &msg) const override;
bool upload(PrintHostUpload upload_data, ProgressFn prorgess_fn, ErrorFn error_fn, InfoFn info_fn) const override;
bool has_auto_discovery() const override { return false; }
bool has_auto_discovery() const override { return true; }
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 fetch_material_slots(std::vector<FlashforgeMaterialSlot>& slots, bool* supports_material_station, wxString& msg) const;
static bool discover_printers(std::vector<FlashforgeDiscoveredPrinter>& printers, wxString& msg, int timeout_ms = 10000, int idle_timeout_ms = 1500, int max_retries = 3);
private:
std::string m_host;
std::string m_serial_number;
std::string m_check_code;
std::string m_console_port;
const int m_bufferSize;
GCodeFlavor m_gcFlavor;
@@ -43,6 +63,11 @@ private:
Slic3r::Utils::SerialMessage tempStatusCommand = {"~M105\r\n", Slic3r::Utils::Command};
Slic3r::Utils::SerialMessage printStatusCommand = {"~M27\r\n", Slic3r::Utils::Command};
Slic3r::Utils::SerialMessage saveFileCommand = {"~M29\r\n",Slic3r::Utils::Command};
bool upload_local_api(PrintHostUpload upload_data, ProgressFn progress_fn, ErrorFn error_fn) const;
bool test_local_api(wxString& msg) const;
bool request_local_api_json(const std::string& path, const std::string& body, std::string& response_body, wxString& error_msg) const;
std::string make_http_url(const std::string& path) const;
std::string extract_host_name() const;
int get_err_code_from_body(const std::string &body) const;
bool connect(wxString& msg) const;
bool start_print(wxString& msg, const std::string& filename) const;

View File

@@ -978,6 +978,51 @@ std::string Http::get_filename_from_url(const std::string &url)
return path_url.substr(start_pos + 1, path_url.length() - start_pos - 1);
}
std::string Http::get_host_from_url(const std::string &url_in, std::string *port)
{
std::string url = url_in;
if (url.find("//") == std::string::npos)
url = "http://" + url;
if (port)
port->clear();
std::string out = url_in;
CURLU *hurl = curl_url();
if (hurl) {
CURLUcode rc = curl_url_set(hurl, CURLUPART_URL, url.c_str(), 0);
if (rc == CURLUE_OK) {
char *host;
rc = curl_url_get(hurl, CURLUPART_HOST, &host, 0);
if (rc == CURLUE_OK) {
out = host;
curl_free(host);
if (port) {
char *pstr;
rc = curl_url_get(hurl, CURLUPART_PORT, &pstr, 0);
if (rc == CURLUE_OK && pstr) {
*port = pstr;
curl_free(pstr);
}
}
} else
BOOST_LOG_TRIVIAL(error) << "Http::get_host_from_url: failed to get host from URL " << url;
} else
BOOST_LOG_TRIVIAL(error) << "Http::get_host_from_url: failed to parse URL " << url;
curl_url_cleanup(hurl);
} else
BOOST_LOG_TRIVIAL(error) << "Http::get_host_from_url: failed to allocate curl_url";
return out;
}
std::string Http::get_host_header_value(const std::string &url)
{
std::string port;
std::string host = get_host_from_url(url, &port);
if (!port.empty())
host += ":" + port;
return host;
}
std::ostream& operator<<(std::ostream &os, const Http::Progress &progress)
{
os << "Http::Progress("

View File

@@ -204,6 +204,8 @@ public:
static std::string url_decode(const std::string &str);
static std::string get_filename_from_url(const std::string &url);
static std::string get_host_from_url(const std::string &url, std::string *port = nullptr);
static std::string get_host_header_value(const std::string &url);
private:
Http(const std::string &url);

View File

@@ -247,7 +247,7 @@ public:
/**
* Update or create a preset with a known setting_id.
*/
virtual int put_setting(std::string setting_id, std::string name, std::map<std::string, std::string>* values_map, unsigned int* http_code) = 0;
virtual int put_setting(std::string setting_id, std::string name, std::map<std::string, std::string>* values_map, unsigned int* http_code, bool force = false) = 0;
/**
* Trigger bulk download of user presets.

View File

@@ -84,7 +84,7 @@ bool MKS::upload(PrintHostUpload upload_data, ProgressFn prorgess_fn, ErrorFn er
int err_code = get_err_code_from_body(body);
if (err_code != 0) {
BOOST_LOG_TRIVIAL(error) << boost::format("MKS: Request completed but error code was received: %1%") % err_code;
error_fn(format_error(body, L("Unknown error occurred"), 0));
error_fn(format_error(body, _u8L("Unknown error occurred"), 0));
res = false;
}
else if (upload_data.post_action == PrintHostPostUploadAction::StartPrint) {

View File

@@ -0,0 +1,311 @@
#include "Moonraker.hpp"
#include <sstream>
#include <boost/format.hpp>
#include <boost/log/trivial.hpp>
#include <boost/property_tree/ptree.hpp>
#include <boost/property_tree/json_parser.hpp>
#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<std::string>("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<std::string>("name", "");
const std::string &perms = child.second.get<std::string>("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": "<name>.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 = <gcode file>
// root = <storage root> (Moonraker default: "gcodes")
// Successful response shape:
// { "result": { "item": { "path": "<name>.gcode", "root": "<root>" }, "print_started": <bool> } }
// 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<std::string>("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;
}
}

View File

@@ -0,0 +1,63 @@
#ifndef slic3r_Moonraker_hpp_
#define slic3r_Moonraker_hpp_
#include <string>
#include <wx/string.h>
#include <wx/arrstr.h>
#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":"<name>.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

View File

@@ -383,11 +383,12 @@ int NetworkAgent::put_setting(std::string setting_id,
std::string name,
std::map<std::string, std::string>* values_map,
unsigned int* http_code,
const std::string& provider)
const std::string& provider,
bool force)
{
const auto cloud_agent = get_cloud_agent(provider);
if (cloud_agent)
return cloud_agent->put_setting(std::move(setting_id), std::move(name), values_map, http_code);
return cloud_agent->put_setting(std::move(setting_id), std::move(name), values_map, http_code, force);
return -1;
}
@@ -582,21 +583,22 @@ int NetworkAgent::get_my_token(std::string ticket, unsigned int* http_code, std:
return -1;
}
int NetworkAgent::track_enable(bool enable, const std::string& provider)
int NetworkAgent::track_enable(bool enable)
{
this->enable_track = enable;
const auto cloud_agent = get_cloud_agent(provider);
// Orca cloud has no telemetry; the only cloud agent that tracks events is BBL.
this->enable_track = enable;
const auto cloud_agent = get_cloud_agent(BBL_CLOUD_PROVIDER);
if (cloud_agent)
return cloud_agent->track_enable(enable);
return -1;
return 0;
}
int NetworkAgent::track_remove_files(const std::string& provider)
int NetworkAgent::track_remove_files()
{
const auto cloud_agent = get_cloud_agent(provider);
const auto cloud_agent = get_cloud_agent(BBL_CLOUD_PROVIDER);
if (cloud_agent)
return cloud_agent->track_remove_files();
return -1;
return 0;
}
int NetworkAgent::track_event(std::string evt_key, std::string content, const std::string& provider)

View File

@@ -93,7 +93,7 @@ public:
// NOTE: this should always call only OrcaCloud
int get_user_presets(std::map<std::string, std::map<std::string, std::string>>* user_presets, const std::string& provider = ORCA_CLOUD_PROVIDER);
std::string request_setting_id(std::string name, std::map<std::string, std::string>* values_map, unsigned int* http_code, const std::string& provider = ORCA_CLOUD_PROVIDER);
int put_setting(std::string setting_id, std::string name, std::map<std::string, std::string>* values_map, unsigned int* http_code, const std::string& provider = ORCA_CLOUD_PROVIDER);
int put_setting(std::string setting_id, std::string name, std::map<std::string, std::string>* values_map, unsigned int* http_code, const std::string& provider = ORCA_CLOUD_PROVIDER, bool force = false);
int get_setting_list(std::string bundle_version, ProgressFn pro_fn = nullptr, WasCancelledFn cancel_fn = nullptr, const std::string& provider = ORCA_CLOUD_PROVIDER);
int get_setting_list2(std::string bundle_version, CheckFn chk_fn, ProgressFn pro_fn = nullptr, WasCancelledFn cancel_fn = nullptr, const std::string& provider = ORCA_CLOUD_PROVIDER);
int delete_setting(std::string setting_id, const std::string& provider = ORCA_CLOUD_PROVIDER);
@@ -118,8 +118,9 @@ public:
int get_model_mall_detail_url(std::string* url, std::string id, const std::string& provider = ORCA_CLOUD_PROVIDER);
int get_my_profile(std::string token, unsigned int* http_code, std::string* http_body, const std::string& provider = ORCA_CLOUD_PROVIDER);
int get_my_token(std::string ticket, unsigned int* http_code, std::string* http_body, const std::string& provider = ORCA_CLOUD_PROVIDER);
int track_enable(bool enable, const std::string& provider = ORCA_CLOUD_PROVIDER);
int track_remove_files(const std::string& provider = ORCA_CLOUD_PROVIDER);
// Orca: telemetry only exists on the BBL cloud agent (Orca cloud has no track events).
int track_enable(bool enable);
int track_remove_files();
int track_event(std::string evt_key, std::string content, const std::string& provider = ORCA_CLOUD_PROVIDER);
int track_header(std::string header, const std::string& provider = ORCA_CLOUD_PROVIDER);
int track_update_property(std::string name, std::string value, std::string type = "string", const std::string& provider = ORCA_CLOUD_PROVIDER);

View File

@@ -6,6 +6,7 @@
#include "QidiPrinterAgent.hpp"
#include "SnapmakerPrinterAgent.hpp"
#include "MoonrakerPrinterAgent.hpp"
#include "CrealityPrintAgent.hpp"
#include <boost/log/trivial.hpp>
#include <map>
#include <mutex>
@@ -133,6 +134,9 @@ void NetworkAgentFactory::register_all_agents()
register_agent<OrcaPrinterAgent>();
register_agent<QidiPrinterAgent>();
register_agent<SnapmakerPrinterAgent>();
register_agent<CrealityPrintAgent>(); // Must come BEFORE MoonrakerPrinterAgent —
// CrealityPrintAgent extends Moonraker behaviour
// for K-series boards with CFS support.
register_agent<MoonrakerPrinterAgent>();
// BBLPrinterAgent takes no constructor args, so register manually

View File

@@ -33,45 +33,6 @@ namespace Slic3r {
namespace {
#ifdef WIN32
std::string get_host_from_url(const std::string& url_in)
{
std::string url = url_in;
// add http:// if there is no scheme
size_t double_slash = url.find("//");
if (double_slash == std::string::npos)
url = "http://" + url;
std::string out = url;
CURLU* hurl = curl_url();
if (hurl) {
// Parse the input URL.
CURLUcode rc = curl_url_set(hurl, CURLUPART_URL, url.c_str(), 0);
if (rc == CURLUE_OK) {
// Replace the address.
char* host;
rc = curl_url_get(hurl, CURLUPART_HOST, &host, 0);
if (rc == CURLUE_OK) {
char* port;
rc = curl_url_get(hurl, CURLUPART_PORT, &port, 0);
if (rc == CURLUE_OK && port != nullptr) {
out = std::string(host) + ":" + port;
curl_free(port);
} else {
out = host;
curl_free(host);
}
}
else
BOOST_LOG_TRIVIAL(error) << "OctoPrint get_host_from_url: failed to get host form URL " << url;
}
else
BOOST_LOG_TRIVIAL(error) << "OctoPrint get_host_from_url: failed to parse URL " << url;
curl_url_cleanup(hurl);
}
else
BOOST_LOG_TRIVIAL(error) << "OctoPrint get_host_from_url: failed to allocate curl_url";
return out;
}
// Workaround for Windows 10/11 mDNS resolve issue, where two mDNS resolves in succession fail.
std::string substitute_host(const std::string& orig_addr, std::string sub_addr)
{
@@ -186,7 +147,6 @@ bool OctoPrint::test_with_resolved_ip(wxString &msg) const
BOOST_LOG_TRIVIAL(info) << boost::format("%1%: Get version at: %2%") % name % url;
std::string host = get_host_from_url(m_host);
auto http = Http::get(url);//std::move(url));
// "Host" header is necessary here. We have resolved IP address and subsituted it into "url" variable.
// And when creating Http object above, libcurl automatically includes "Host" header from address it got.
@@ -194,7 +154,7 @@ bool OctoPrint::test_with_resolved_ip(wxString &msg) const
// Not changing the host would work on the most cases (where there is 1 service on 1 hostname) but would break when f.e. reverse proxy is used (issue #9734).
// Also when allow_ip_resolve = 0, this is not needed, but it should not break anything if it stays.
// https://www.rfc-editor.org/rfc/rfc7230#section-5.4
http.header("Host", host);
http.header("Host", Http::get_host_header_value(m_host));
set_auth(http);
http
.on_error([&](std::string body, std::string error, unsigned status) {
@@ -223,7 +183,7 @@ bool OctoPrint::test_with_resolved_ip(wxString &msg) const
}
catch (const std::exception&) {
res = false;
msg = "Could not parse server response.";
msg = _L("Could not parse server response.");
}
})
.ssl_revoke_best_effort(m_ssl_revoke_best_effort)
@@ -272,7 +232,7 @@ bool OctoPrint::test(wxString& msg) const
}
catch (const std::exception &) {
res = false;
msg = "Could not parse server response";
msg = _L("Could not parse server response.");
}
})
#ifdef WIN32
@@ -306,7 +266,7 @@ bool OctoPrint::upload(PrintHostUpload upload_data, ProgressFn prorgess_fn, Erro
#ifndef WIN32
return upload_inner_with_host(std::move(upload_data), prorgess_fn, error_fn, info_fn);
#else
std::string host = get_host_from_url(m_host);
std::string host = Http::get_host_from_url(m_host);
// decide what to do based on m_host - resolve hostname or upload to ip
std::vector<boost::asio::ip::address> resolved_addr;
@@ -393,14 +353,13 @@ bool OctoPrint::upload_inner_with_resolved_ip(PrintHostUpload upload_data, Progr
% upload_parent_path.string()
% (upload_data.post_action == PrintHostPostUploadAction::StartPrint ? "true" : "false");
std::string host = get_host_from_url(m_host);
auto http = Http::post(url);//std::move(url));
// "Host" header is necessary here. We have resolved IP address and subsituted it into "url" variable.
// And when creating Http object above, libcurl automatically includes "Host" header from address it got.
// Thus "Host" is set to the resolved IP instead of host filled by user. We need to change it back.
// Not changing the host would work on the most cases (where there is 1 service on 1 hostname) but would break when f.e. reverse proxy is used (issue #9734).
// https://www.rfc-editor.org/rfc/rfc7230#section-5.4
http.header("Host", host);
http.header("Host", Http::get_host_header_value(m_host));
set_auth(http);
http.form_add("print", upload_data.post_action == PrintHostPostUploadAction::StartPrint ? "true" : "false")
.form_add("path", upload_parent_path.string()) // XXX: slashes on windows ???
@@ -486,8 +445,7 @@ bool OctoPrint::upload_inner_with_host(PrintHostUpload upload_data, ProgressFn p
// Not changing the host would work on the most cases (where there is 1 service on 1 hostname) but would break when f.e. reverse proxy is used (issue #9734).
// Also when allow_ip_resolve = 0, this is not needed, but it should not break anything if it stays.
// https://www.rfc-editor.org/rfc/rfc7230#section-5.4
std::string host = get_host_from_url(m_host);
http.header("Host", host);
http.header("Host", Http::get_host_header_value(m_host));
#endif // _WIN32
set_auth(http);
http.form_add("print", upload_data.post_action == PrintHostPostUploadAction::StartPrint ? "true" : "false")
@@ -677,7 +635,7 @@ bool PrusaLink::test(wxString& msg) const
}
catch (const std::exception&) {
res = false;
msg = "Could not parse server response";
msg = _L("Could not parse server response.");
}
})
#ifdef WIN32
@@ -853,7 +811,7 @@ bool PrusaLink::test_with_method_check(wxString& msg, bool& use_put) const
}
catch (const std::exception&) {
res = false;
msg = "Could not parse server response";
msg = _L("Could not parse server response.");
}
})
#ifdef WIN32
@@ -884,7 +842,6 @@ bool PrusaLink::test_with_resolved_ip_and_method_check(wxString& msg, bool& use_
BOOST_LOG_TRIVIAL(info) << boost::format("%1%: Get version at: %2%") % name % url;
std::string host = get_host_from_url(m_host);
auto http = Http::get(url);//std::move(url));
// "Host" header is necessary here. We have resolved IP address and subsituted it into "url" variable.
// And when creating Http object above, libcurl automatically includes "Host" header from address it got.
@@ -892,7 +849,7 @@ bool PrusaLink::test_with_resolved_ip_and_method_check(wxString& msg, bool& use_
// Not changing the host would work on the most cases (where there is 1 service on 1 hostname) but would break when f.e. reverse proxy is used (issue #9734).
// Also when allow_ip_resolve = 0, this is not needed, but it should not break anything if it stays.
// https://www.rfc-editor.org/rfc/rfc7230#section-5.4
http.header("Host", host);
http.header("Host", Http::get_host_header_value(m_host));
set_auth(http);
http
.on_error([&](std::string body, std::string error, unsigned status) {
@@ -934,7 +891,7 @@ bool PrusaLink::test_with_resolved_ip_and_method_check(wxString& msg, bool& use_
}
catch (const std::exception&) {
res = false;
msg = "Could not parse server response";
msg = _L("Could not parse server response.");
}
})
@@ -1053,8 +1010,7 @@ bool PrusaLink::put_inner(PrintHostUpload upload_data, std::string url, const st
// Thus "Host" is set to the resolved IP instead of host filled by user. We need to change it back.
// Not changing the host would work on the most cases (where there is 1 service on 1 hostname) but would break when f.e. reverse proxy is used (issue #9734).
// https://www.rfc-editor.org/rfc/rfc7230#section-5.4
std::string host = get_host_from_url(m_host);
http.header("Host", host);
http.header("Host", Http::get_host_header_value(m_host));
#endif // _WIN32
set_auth(http);
// This is ugly, but works. There was an error at PrusaLink side that accepts any string at Print-After-Upload as true, thus False was also triggering print after upload.
@@ -1103,8 +1059,7 @@ bool PrusaLink::post_inner(PrintHostUpload upload_data, std::string url, const s
// Thus "Host" is set to the resolved IP instead of host filled by user. We need to change it back.
// Not changing the host would work on the most cases (where there is 1 service on 1 hostname) but would break when f.e. reverse proxy is used (issue #9734).
// https://www.rfc-editor.org/rfc/rfc7230#section-5.4
std::string host = get_host_from_url(m_host);
http.header("Host", host);
http.header("Host", Http::get_host_header_value(m_host));
#endif // _WIN32
set_auth(http);
set_http_post_header_args(http, upload_data.post_action);

View File

@@ -31,6 +31,7 @@
#include <wx/filefn.h>
#include <wx/secretstore.h>
#include <wx/stdpaths.h>
#include <wx/app.h>
#include <wx/utils.h>
#if defined(_WIN32)
@@ -56,6 +57,7 @@ constexpr const char* ORCA_DEFAULT_PUB_KEY = "sb_publishable_lvVe_whOi80SU9BPSxM
constexpr const char* ORCA_HEALTH_PATH = "/api/v1/health";
constexpr const char* ORCA_SYNC_PULL_PATH = "/api/v1/sync/pull";
constexpr const char* ORCA_SYNC_PUSH_PATH = "/api/v1/sync/push";
constexpr const char* ORCA_SYNC_FORCE_PUSH_PATH = "/api/v1/sync/force-push";
constexpr const char* ORCA_SYNC_DELETE_PATH = "/api/v1/sync/delete";
constexpr const char* ORCA_PROFILES_PATH = "/api/v1/sync/profiles";
constexpr const char* ORCA_SUBSCRIPTIONS_PATH = "/api/v1/subscriptions";
@@ -100,24 +102,6 @@ std::string resolve_display_name(
return username;
}
std::string generate_uuid_for_setting_id(const std::string& name, const std::string& user_id = "")
{
if (name.empty()) {
return "";
}
// Mix user_id into the hashed input so two different users generating a setting_id
// for an identically-named preset get distinct UUIDs. Without this, the cloud's ID
// space collides across accounts and the second user's create gets HTTP 409 with
// server_profile=null on every sync (the foreign owner's record is not exposed).
static const boost::uuids::uuid orca_namespace =
boost::uuids::string_generator()("f47ac10b-58cc-4372-a567-0e02b2c3d479");
boost::uuids::name_generator_sha1 gen(orca_namespace);
boost::uuids::uuid id = user_id.empty() ? gen(name) : gen(user_id + "/" + name);
return boost::uuids::to_string(id);
}
std::string base64url_encode(const std::vector<unsigned char>& data)
{
std::string out;
@@ -411,6 +395,24 @@ OrcaCloudServiceAgent::~OrcaCloudServiceAgent()
}
}
std::string OrcaCloudServiceAgent::generate_uuid_for_setting_id(const std::string& name, const std::string& user_id)
{
if (name.empty()) {
return "";
}
// Mix user_id into the hashed input so two different users generating a setting_id
// for an identically-named preset get distinct UUIDs. Without this, the cloud's ID
// space collides across accounts and the second user's create gets HTTP 409 with
// server_profile=null on every sync (the foreign owner's record is not exposed).
static const boost::uuids::uuid orca_namespace =
boost::uuids::string_generator()("f47ac10b-58cc-4372-a567-0e02b2c3d479");
boost::uuids::name_generator_sha1 gen(orca_namespace);
boost::uuids::uuid id = user_id.empty() ? gen(name) : gen(user_id + "/" + name);
return boost::uuids::to_string(id);
}
void OrcaCloudServiceAgent::configure_urls(AppConfig* app_config)
{
if (!app_config) return;
@@ -478,7 +480,7 @@ int OrcaCloudServiceAgent::set_config_dir(std::string cfg_dir)
config_dir = cfg_dir;
wxFileName fallback(wxString::FromUTF8(cfg_dir.c_str()), "orca_refresh_token.sec");
fallback.Normalize();
refresh_fallback_path = fallback.GetFullPath().ToStdString();
secret_fallback_path = fallback.GetFullPath().ToStdString();
return BAMBU_NETWORK_SUCCESS;
}
@@ -496,14 +498,71 @@ int OrcaCloudServiceAgent::set_country_code(std::string code)
return BAMBU_NETWORK_SUCCESS;
}
/// Decode a saved user session or a refresh token.
///
/// Returns `false` if invalid input, and a re-authentication is required.
///
/// If returns `true`, `out_refresh_token` will contain the user refresh token, and `out_session` can be one of two scenarios:
/// - if `out_session.logged_in` is `true`, then `out_session.refresh_token` and `out_session.user_id` are guaranteed to be present,
/// and a refresh is not necessarily required until you need to make any network call
/// - otherwise if `out_session.logged_in` is `false`, you should do a refresh immediately to get the user information before proceed,
/// otherwise user will be logged out
static bool parse_stored_secret(const std::string& secret, std::string& out_refresh_token, OrcaCloudServiceAgent::SessionInfo& out_session)
{
out_refresh_token.clear();
out_session = OrcaCloudServiceAgent::SessionInfo{};
try {
// Valid secret should be a json object, otherwise it's a plain refresh token
const json secret_json = json::parse(secret, nullptr, false);
if (secret_json.type() != json::value_t::object) {
out_refresh_token = secret;
return true;
}
OrcaCloudServiceAgent::SessionInfo user_session{};
user_session.refresh_token = get_json_string_field(secret_json, "refresh_token");
user_session.user_id = get_json_string_field(secret_json, "user_id");
user_session.user_name = get_json_string_field(secret_json, "username");
user_session.user_nickname = get_json_string_field(secret_json, "nickname");
user_session.logged_in = true;
// User session, must at least contains refresh token and user id
if (user_session.refresh_token.empty() || user_session.user_id.empty()) {
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: secret does not contain valid user session, force re-authentication";
return false;
}
out_refresh_token = user_session.refresh_token;
out_session = std::move(user_session);
return true;
} catch (const std::exception&) {
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: parse_stored_secret exception, force re-authentication";
return false;
}
}
int OrcaCloudServiceAgent::start()
{
regenerate_pkce();
// Attempt silent sign-in from stored refresh token
std::string stored_refresh;
if (load_refresh_token(stored_refresh) && !stored_refresh.empty()) {
refresh_now(stored_refresh, "refresh token", false);
std::string stored_secret;
if (load_user_secret(stored_secret) && !stored_secret.empty()) {
// Backward compatibility: if secret it a json, then read it as use session,
// which allows us to refresh it in a background thread to speed up the app startup;
// otherwise it's a plain refresh token, then we force a sync refresh
std::string refresh_token;
SessionInfo stored_session;
if (parse_stored_secret(stored_secret, refresh_token, stored_session)) {
if (stored_session.logged_in) {
// We have a previously saved user session, use it. Skip re-persisting: the secret was
// just loaded from disk, so writing the identical bytes back is wasted startup I/O.
set_user_session(stored_session.access_token, stored_session.user_id, stored_session.user_name,
stored_session.user_nickname, stored_session.user_avatar, stored_session.refresh_token,
/*persist=*/false);
}
refresh_now(refresh_token, "refresh token", stored_session.logged_in);
}
}
return BAMBU_NETWORK_SUCCESS;
@@ -965,7 +1024,7 @@ std::string OrcaCloudServiceAgent::request_setting_id(std::string name, std::map
return "";
}
int OrcaCloudServiceAgent::put_setting(std::string setting_id, std::string name, std::map<std::string, std::string>* values_map, unsigned int* http_code)
int OrcaCloudServiceAgent::put_setting(std::string setting_id, std::string name, std::map<std::string, std::string>* values_map, unsigned int* http_code, bool force)
{
// Extract original_updated_time for Optimistic Concurrency Control
// If present, server will verify version before update. If absent, treated as insert.
@@ -989,7 +1048,7 @@ int OrcaCloudServiceAgent::put_setting(std::string setting_id, std::string name,
}
}
auto result = sync_push(setting_id, name, content, original_updated_time);
auto result = sync_push(setting_id, name, content, original_updated_time, force);
if (http_code) *http_code = result.http_code;
if (result.success) {
@@ -1208,11 +1267,11 @@ int OrcaCloudServiceAgent::sync_pull(
}
}
SyncPushResult OrcaCloudServiceAgent::sync_push(
const std::string& profile_id,
const std::string& name,
const nlohmann::json& content,
const std::string& original_updated_time)
SyncPushResult OrcaCloudServiceAgent::sync_push(const std::string& profile_id,
const std::string& name,
const nlohmann::json& content,
const std::string& original_updated_time,
bool force)
{
SyncPushResult result;
result.success = false;
@@ -1243,14 +1302,16 @@ SyncPushResult OrcaCloudServiceAgent::sync_push(
std::string response;
unsigned int http_code = 0;
int http_result = http_post(ORCA_SYNC_PUSH_PATH, body_str, &response, &http_code);
int http_result = http_post(force ? ORCA_SYNC_FORCE_PUSH_PATH : ORCA_SYNC_PUSH_PATH, body_str, &response, &http_code);
result.http_code = http_code;
if (http_code == 409) {
// Conflict - parse server version
nlohmann::json err_body;
try {
auto json = nlohmann::json::parse(response);
err_body = json;
if (json.is_null()) {
result.server_deleted = true;
} else {
@@ -1260,6 +1321,13 @@ SyncPushResult OrcaCloudServiceAgent::sync_push(
result.server_version.updated_time = profile_data.value(ORCA_JSON_KEY_UPDATE_TIME, 0);
}
} catch (...) {}
// Surface the conflict via the http-error callback with the local preset name injected.
// The raw server body omits the name for tombstone (-3) conflicts (server_profile is null),
// but the GUI needs it to regenerate the deterministic setting_id for a force push.
if (!err_body.is_object())
err_body = nlohmann::json::object();
err_body["name"] = name;
invoke_http_error_callback(409, err_body.dump());
result.error_message = response;
return result;
}
@@ -1377,10 +1445,10 @@ void OrcaCloudServiceAgent::update_redirect_uri()
// Auth - Token Persistence
// ============================================================================
void OrcaCloudServiceAgent::persist_refresh_token(const std::string& token)
void OrcaCloudServiceAgent::persist_user_secret(const std::string& secret)
{
if (token.empty()) {
clear_refresh_token();
if (secret.empty()) {
clear_user_secret();
return;
}
@@ -1390,13 +1458,13 @@ void OrcaCloudServiceAgent::persist_refresh_token(const std::string& token)
// Use encrypted file only
auto key = sha256_bytes(get_encryption_key());
if (key.empty()) {
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: cannot derive key for refresh-token file storage";
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: cannot derive key for user secret file storage";
return;
}
std::string payload;
if (!aes256gcm_encrypt(token, key, payload)) {
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: failed to encrypt refresh token for file storage";
if (!aes256gcm_encrypt(secret, key, payload)) {
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: failed to encrypt user secret for file storage";
return;
}
@@ -1406,34 +1474,38 @@ void OrcaCloudServiceAgent::persist_refresh_token(const std::string& token)
}
compute_fallback_path();
wxFileName path(wxString::FromUTF8(refresh_fallback_path.c_str()));
if (secret_fallback_path.empty()) {
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: no user secret storage path available; skipping file persistence";
return;
}
wxFileName path(wxString::FromUTF8(secret_fallback_path.c_str()));
path.Normalize();
if (!wxFileName::DirExists(path.GetPath())) {
wxFileName::Mkdir(path.GetPath(), wxS_DIR_DEFAULT, wxPATH_MKDIR_FULL);
}
const std::string tmp_path = refresh_fallback_path + ".tmp";
const std::string tmp_path = secret_fallback_path + ".tmp";
std::ofstream ofs(tmp_path, std::ios::out | std::ios::trunc | std::ios::binary);
if (ofs.good()) {
ofs << signed_payload;
ofs.flush();
ofs.close();
if (wxRenameFile(wxString::FromUTF8(tmp_path.c_str()), wxString::FromUTF8(refresh_fallback_path.c_str()), true)) {
if (wxRenameFile(wxString::FromUTF8(tmp_path.c_str()), wxString::FromUTF8(secret_fallback_path.c_str()), true)) {
stored = true;
} else {
wxRemoveFile(wxString::FromUTF8(tmp_path.c_str()));
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: failed to atomically replace refresh-token file";
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: failed to atomically replace user secret file";
}
} else {
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: cannot open refresh-token file for write - " << refresh_fallback_path;
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: cannot open user secret file for write - " << secret_fallback_path;
}
} else {
// Use wxSecretStore only
wxSecretStore store = wxSecretStore::GetDefault();
if (store.IsOk()) {
wxSecretValue secret(wxString::FromUTF8(token.c_str()));
if (store.Save(SECRET_STORE_SERVICE, SECRET_STORE_USER, secret)) {
wxSecretValue secret_value(wxString::FromUTF8(secret.c_str()));
if (store.Save(SECRET_STORE_SERVICE, SECRET_STORE_USER, secret_value)) {
stored = true;
} else {
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: System Keychain save failed";
@@ -1446,15 +1518,15 @@ void OrcaCloudServiceAgent::persist_refresh_token(const std::string& token)
(void) stored;
}
bool OrcaCloudServiceAgent::load_refresh_token(std::string& out_token)
bool OrcaCloudServiceAgent::load_user_secret(std::string& out_secret)
{
out_token.clear();
out_secret.clear();
if (m_use_encrypted_token_file) {
// Load from encrypted file only
compute_fallback_path();
if (wxFileExists(wxString::FromUTF8(refresh_fallback_path.c_str()))) {
std::ifstream ifs(refresh_fallback_path, std::ios::binary);
if (wxFileExists(wxString::FromUTF8(secret_fallback_path.c_str()))) {
std::ifstream ifs(secret_fallback_path, std::ios::binary);
std::string payload((std::istreambuf_iterator<char>(ifs)), std::istreambuf_iterator<char>());
auto key = sha256_bytes(get_encryption_key());
std::string plain;
@@ -1477,16 +1549,16 @@ bool OrcaCloudServiceAgent::load_refresh_token(std::string& out_token)
std::transform(computed_hmac.begin(), computed_hmac.end(), computed_hmac.begin(), ::tolower);
if (computed_hmac.empty() || computed_hmac != lower_stored) {
integrity_ok = false;
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: refresh token integrity check failed (HMAC mismatch)";
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: user secret integrity check failed (HMAC mismatch)";
}
}
}
if (integrity_ok && aes256gcm_decrypt(encoded_payload, key, plain) && !plain.empty()) {
out_token = plain;
out_secret = plain;
// Upgrade legacy payloads to signed format
if (payload.rfind("v2:", 0) != 0) {
persist_refresh_token(out_token);
persist_user_secret(out_secret);
}
return true;
}
@@ -1498,8 +1570,8 @@ bool OrcaCloudServiceAgent::load_refresh_token(std::string& out_token)
wxString username;
wxSecretValue secret;
if (store.Load(SECRET_STORE_SERVICE, username, secret) && secret.IsOk()) {
out_token.assign(static_cast<const char*>(secret.GetData()), secret.GetSize());
if (!out_token.empty()) {
out_secret.assign(static_cast<const char*>(secret.GetData()), secret.GetSize());
if (!out_secret.empty()) {
return true;
}
}
@@ -1509,7 +1581,7 @@ bool OrcaCloudServiceAgent::load_refresh_token(std::string& out_token)
return false;
}
void OrcaCloudServiceAgent::clear_refresh_token()
void OrcaCloudServiceAgent::clear_user_secret()
{
wxSecretStore store = wxSecretStore::GetDefault();
if (store.IsOk()) {
@@ -1517,8 +1589,8 @@ void OrcaCloudServiceAgent::clear_refresh_token()
}
compute_fallback_path();
if (!refresh_fallback_path.empty() && wxFileExists(wxString::FromUTF8(refresh_fallback_path.c_str()))) {
wxRemoveFile(wxString::FromUTF8(refresh_fallback_path.c_str()));
if (!secret_fallback_path.empty() && wxFileExists(wxString::FromUTF8(secret_fallback_path.c_str()))) {
wxRemoveFile(wxString::FromUTF8(secret_fallback_path.c_str()));
}
}
@@ -1563,21 +1635,24 @@ bool OrcaCloudServiceAgent::decode_jwt_expiry(const std::string& token, std::chr
return false;
}
bool OrcaCloudServiceAgent::refresh_now(const std::string& refresh_token, const std::string& reason, bool async)
RefreshResult OrcaCloudServiceAgent::refresh_now(const std::string& refresh_token, const std::string& reason, bool async)
{
if (refresh_token.empty()) return false;
if (refresh_token.empty()) return RefreshResult::AuthRejected; // nothing to refresh
bool expected = false;
if (!refresh_running.compare_exchange_strong(expected, true)) {
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: refresh already running, skip (reason=" << reason << ")";
return false;
// Another refresh is already in flight. Treat as transient so we keep the session
// instead of logging out: that in-flight refresh surfaces its own Success/AuthRejected,
// so a genuine rejection is only deferred to the next request, never lost.
return RefreshResult::Transient;
}
auto worker = [this, refresh_token, reason]() {
(void) reason;
bool ok = refresh_session_with_token(refresh_token);
RefreshResult r = refresh_session_with_token(refresh_token);
refresh_running.store(false);
return ok;
return r;
};
if (async) {
@@ -1585,21 +1660,27 @@ bool OrcaCloudServiceAgent::refresh_now(const std::string& refresh_token, const
refresh_thread.join();
}
refresh_thread = std::thread([worker]() { worker(); });
return true;
// Fire-and-forget: the outcome isn't known yet and no current caller consumes it.
// Return Transient (indeterminate) rather than implying a completed, successful refresh.
return RefreshResult::Transient;
}
return worker();
}
bool OrcaCloudServiceAgent::refresh_from_storage(const std::string& reason, bool async)
RefreshResult OrcaCloudServiceAgent::refresh_from_storage(const std::string& reason, bool async)
{
std::string refresh_token = get_refresh_token();
if (refresh_token.empty()) {
load_refresh_token(refresh_token);
std::string user_secret;
if (load_user_secret(user_secret) && !user_secret.empty()) {
SessionInfo stored_session;
parse_stored_secret(user_secret, refresh_token, stored_session);
}
}
if (refresh_token.empty()) {
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: no refresh token available for refresh (reason=" << reason << ")";
return false;
return RefreshResult::AuthRejected; // no persisted token: nothing to preserve
}
return refresh_now(refresh_token, reason, async);
@@ -1615,37 +1696,55 @@ bool OrcaCloudServiceAgent::refresh_if_expiring(std::chrono::seconds skew, const
if (!needs_refresh) return true;
if (refresh_from_storage(reason, false)) return true;
if (refresh_from_storage(reason, false) == RefreshResult::Success) return true;
std::this_thread::sleep_for(std::chrono::milliseconds(750));
return refresh_from_storage(reason + "_retry", false);
return refresh_from_storage(reason + "_retry", false) == RefreshResult::Success;
}
bool OrcaCloudServiceAgent::refresh_session_with_token(const std::string& refresh_token)
// Maps a token-refresh HTTP outcome to a RefreshResult. http_code == 0 means the
// server could not be reached; session_established is only meaningful for a 2xx body.
static RefreshResult classify_refresh_result(unsigned http_code, bool session_established)
{
if (http_code == 0)
return RefreshResult::Transient; // no response: network/transport failure
if (http_code == 400 || http_code == 401 || http_code == 403)
return RefreshResult::AuthRejected; // refresh token rejected
if (http_code >= 400)
return RefreshResult::Transient; // rate-limit (429), server error (5xx) or other 4xx: keep the session
return session_established ? RefreshResult::Success // 2xx with a usable session
: RefreshResult::Transient; // 2xx but unusable body
}
RefreshResult OrcaCloudServiceAgent::refresh_session_with_token(const std::string& refresh_token)
{
std::string body = "{\"refresh_token\":\"" + refresh_token + "\"}";
std::string url = auth_base_url + auth_constants::TOKEN_PATH + "?grant_type=refresh_token";
std::string response;
unsigned int http_code = 0;
if (!http_post_token(body, &response, &http_code, url) || http_code >= 400) {
// http_post_token sets http_code to 0 when the server could not be reached.
http_post_token(body, &response, &http_code, url);
bool established = false;
if (http_code >= 200 && http_code < 300) {
if (session_handler) {
established = session_handler(response);
} else {
// No session handler set - parse the token response directly and establish the
// session, so OrcaCloudServiceAgent is self-contained without external setup.
try {
established = set_user_session(json::parse(response));
} catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: token refresh parse exception - " << e.what();
}
}
} else {
std::string truncated_response = response.size() > 200 ? response.substr(0, 200) + "..." : response;
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: token refresh failed - http_code=" << http_code
<< ", response_body=" << truncated_response;
return false;
}
if (session_handler) {
return session_handler(response);
}
// No session handler set - parse the token response directly and establish session
// This makes OrcaCloudServiceAgent self-contained without requiring external setup
try {
return set_user_session(json::parse(response));
} catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: token refresh parse exception - " << e.what();
return false;
}
return classify_refresh_result(http_code, established);
}
// ============================================================================
@@ -1657,7 +1756,8 @@ bool OrcaCloudServiceAgent::set_user_session(const std::string& token,
const std::string& username,
const std::string& nickname,
const std::string& avatar,
const std::string& refresh_token)
const std::string& refresh_token,
bool persist)
{
std::chrono::system_clock::time_point exp_tp{};
decode_jwt_expiry(token, exp_tp);
@@ -1674,8 +1774,17 @@ bool OrcaCloudServiceAgent::set_user_session(const std::string& token,
session.logged_in = true;
}
if (!refresh_token.empty()) {
persist_refresh_token(refresh_token);
if (persist) {
// Store user session on disk to not block use from using
// an already logged in account if internet is not available.
// Don't store access token though, we should always refresh it
// once user is back online.
json sec = json::object();
sec["refresh_token"] = refresh_token;
sec["user_id"] = user_id;
sec["username"] = username;
sec["nickname"] = nickname;
persist_user_secret(sec.dump());
}
// Set per-user sync state path
@@ -1751,22 +1860,27 @@ void OrcaCloudServiceAgent::clear_session()
std::lock_guard<std::mutex> lock(session_mutex);
session = SessionInfo{};
}
clear_refresh_token();
clear_user_secret();
}
// ============================================================================
// HTTP Helpers
// ============================================================================
bool OrcaCloudServiceAgent::attempt_refresh_after_unauthorized(const std::string& reason)
RefreshResult OrcaCloudServiceAgent::attempt_refresh_after_unauthorized(const std::string& reason)
{
if (refresh_from_storage(reason, false)) return true;
RefreshResult r = refresh_from_storage(reason, false);
if (r != RefreshResult::Transient) return r; // Success or AuthRejected: decided, no retry
// Only a transient (network/server) failure is worth retrying.
std::this_thread::sleep_for(std::chrono::milliseconds(500));
if (refresh_from_storage(reason + "_retry", false)) return true;
r = refresh_from_storage(reason + "_retry", false);
BOOST_LOG_TRIVIAL(warning) << "[auth] event=refresh result=failure source=" << reason << " action=logout";
return false;
if (r == RefreshResult::Transient)
BOOST_LOG_TRIVIAL(warning) << "[auth] event=refresh result=transient source=" << reason << " action=keep_session";
else if (r == RefreshResult::AuthRejected)
BOOST_LOG_TRIVIAL(warning) << "[auth] event=refresh result=rejected source=" << reason << " action=logout";
return r;
}
std::map<std::string, std::string> OrcaCloudServiceAgent::data_headers()
@@ -1779,6 +1893,24 @@ std::map<std::string, std::string> OrcaCloudServiceAgent::data_headers()
return headers;
}
bool OrcaCloudServiceAgent::resolve_unauthorized(HttpResult& res,
const std::function<HttpResult()>& perform, const std::string& reason)
{
if (res.status != 401)
return false;
RefreshResult rr = attempt_refresh_after_unauthorized(reason);
if (rr == RefreshResult::Success) {
res = perform(); // refreshed: retry the original request with the new token
return false;
}
// Transient (no connection / 5xx / 429 / ambiguous): keep the session and token,
// suppress the auth error so the GUI does not log the user out.
// AuthRejected (refresh token genuinely rejected): let the 401 surface -> logout.
return rr == RefreshResult::Transient;
}
int OrcaCloudServiceAgent::http_get(const std::string& path, std::string* response_body, unsigned int* http_code)
{
std::string url = api_base_url + path;
@@ -1787,12 +1919,6 @@ int OrcaCloudServiceAgent::http_get(const std::string& path, std::string* respon
if (!ensure_token_fresh("http_get_" + path))
BOOST_LOG_TRIVIAL(warning) << "ensure_token_fresh returned false";
struct HttpResult {
bool success{false};
unsigned int status{0};
std::string body;
};
auto perform = [&]() {
HttpResult result;
try {
@@ -1831,20 +1957,16 @@ int OrcaCloudServiceAgent::http_get(const std::string& path, std::string* respon
};
HttpResult res = perform();
// Single retry on 401 - no recursion
if (res.status == 401 && attempt_refresh_after_unauthorized("http_get_" + path)) {
res = perform();
}
bool suppress = resolve_unauthorized(res, perform, "http_get_" + path);
if (response_body) *response_body = res.body;
if (http_code) *http_code = res.status;
if (!res.success || res.status >= 400) {
if (!suppress && (!res.success || res.status >= 400)) {
invoke_http_error_callback(res.status, res.body);
}
return res.success ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECT_FAILED;
return (res.success && !suppress) ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECT_FAILED;
}
int OrcaCloudServiceAgent::http_post(const std::string& path, const std::string& body, std::string* response_body, unsigned int* http_code)
@@ -1854,12 +1976,6 @@ int OrcaCloudServiceAgent::http_post(const std::string& path, const std::string&
ensure_token_fresh("http_post_" + path);
struct HttpResult {
bool success{false};
unsigned int status{0};
std::string body;
};
auto perform = [&]() {
HttpResult result;
try {
@@ -1888,7 +2004,7 @@ int OrcaCloudServiceAgent::http_post(const std::string& path, const std::string&
.on_error([&](std::string resp_body, std::string error, unsigned resp_status) {
result.success = false;
result.status = resp_status == 0 ? 404 : resp_status;
result.body = body;
result.body = resp_body;
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: HTTP error - " << error;
})
.timeout_max(30)
@@ -1901,20 +2017,19 @@ int OrcaCloudServiceAgent::http_post(const std::string& path, const std::string&
};
HttpResult res = perform();
// Single retry on 401 - no recursion
if (res.status == 401 && attempt_refresh_after_unauthorized("http_post_" + path)) {
res = perform();
}
bool suppress = resolve_unauthorized(res, perform, "http_post_" + path);
if (response_body) *response_body = res.body;
if (http_code) *http_code = res.status;
if (!res.success || res.status >= 400) {
// 409 is a push-only domain conflict; sync_push re-fires the error callback with the
// local preset name injected (the raw server body omits it for tombstone conflicts),
// so skip the generic nameless auto-fire here to avoid a duplicate, nameless event.
if (!suppress && (!res.success || res.status >= 400) && res.status != 409) {
invoke_http_error_callback(res.status, res.body);
}
return res.success ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECT_FAILED;
return (res.success && !suppress) ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECT_FAILED;
}
int OrcaCloudServiceAgent::http_put(const std::string& path, const std::string& body, std::string* response_body, unsigned int* http_code)
@@ -1924,12 +2039,6 @@ int OrcaCloudServiceAgent::http_put(const std::string& path, const std::string&
ensure_token_fresh("http_put_" + path);
struct HttpResult {
bool success{false};
unsigned int status{0};
std::string body;
};
auto perform = [&]() {
HttpResult result;
try {
@@ -1958,7 +2067,7 @@ int OrcaCloudServiceAgent::http_put(const std::string& path, const std::string&
.on_error([&](std::string resp_body, std::string error, unsigned resp_status) {
result.success = false;
result.status = resp_status == 0 ? 404 : resp_status;
result.body = body;
result.body = resp_body;
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: HTTP error - " << error;
})
.timeout_max(30)
@@ -1971,20 +2080,16 @@ int OrcaCloudServiceAgent::http_put(const std::string& path, const std::string&
};
HttpResult res = perform();
// Single retry on 401 - no recursion
if (res.status == 401 && attempt_refresh_after_unauthorized("http_put_" + path)) {
res = perform();
}
bool suppress = resolve_unauthorized(res, perform, "http_put_" + path);
if (response_body) *response_body = res.body;
if (http_code) *http_code = res.status;
if (!res.success || res.status >= 400) {
if (!suppress && (!res.success || res.status >= 400)) {
invoke_http_error_callback(res.status, res.body);
}
return res.success ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECT_FAILED;
return (res.success && !suppress) ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECT_FAILED;
}
int OrcaCloudServiceAgent::http_delete(const std::string& path, std::string* response_body, unsigned int* http_code)
@@ -1994,12 +2099,6 @@ int OrcaCloudServiceAgent::http_delete(const std::string& path, std::string* res
ensure_token_fresh("http_delete_" + path);
struct HttpResult {
bool success{false};
unsigned int status{0};
std::string body;
};
auto perform = [&]() {
HttpResult result;
try {
@@ -2038,20 +2137,16 @@ int OrcaCloudServiceAgent::http_delete(const std::string& path, std::string* res
};
HttpResult res = perform();
// Single retry on 401 - no recursion
if (res.status == 401 && attempt_refresh_after_unauthorized("http_delete_" + path)) {
res = perform();
}
bool suppress = resolve_unauthorized(res, perform, "http_delete_" + path);
if (response_body) *response_body = res.body;
if (http_code) *http_code = res.status;
if (!res.success || res.status >= 400) {
if (!suppress && (!res.success || res.status >= 400)) {
invoke_http_error_callback(res.status, res.body);
}
return res.success ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECT_FAILED;
return (res.success && !suppress) ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECT_FAILED;
}
bool OrcaCloudServiceAgent::http_post_token(const std::string& body, std::string* response_body, unsigned int* http_code, const std::string& custom_url)
@@ -2107,7 +2202,7 @@ bool OrcaCloudServiceAgent::http_post_token(const std::string& body, std::string
})
.on_error([&](std::string body, std::string error, unsigned resp_status) {
success = false;
status = resp_status == 0 ? 404 : resp_status;
status = resp_status; // keep 0 for "no response" so the refresh classifier sees a transport failure
resp_body = body;
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: HTTP error - " << error;
})
@@ -2200,10 +2295,18 @@ bool OrcaCloudServiceAgent::http_post_auth(const std::string& path, const std::s
void OrcaCloudServiceAgent::compute_fallback_path()
{
if (!refresh_fallback_path.empty()) return;
if (!secret_fallback_path.empty())
return;
// wxStandardPaths::GetUserDataDir() resolves the app data directory via
// wxAppConsoleBase::GetAppName(), which dereferences wxTheApp. In headless
// contexts (CLI, unit tests) there is no wxApp, so guard the call to avoid a
// null dereference. The path can still be provided explicitly through
// set_config_dir(); when it is left empty, file persistence is skipped.
if (wxTheApp == nullptr)
return;
wxFileName fallback(wxStandardPaths::Get().GetUserDataDir(), "orca_refresh_token.sec");
fallback.Normalize();
refresh_fallback_path = fallback.GetFullPath().ToStdString();
secret_fallback_path = fallback.GetFullPath().ToStdString();
}
// ============================================================================

View File

@@ -20,6 +20,14 @@ namespace Slic3r {
class AppConfig;
struct BundleMetadata;
// Outcome of a token-refresh attempt: decides whether a 401 should log the user
// out (AuthRejected) or be treated as a recoverable condition (Transient).
enum class RefreshResult {
Success, // new tokens obtained
AuthRejected, // server definitively rejected the refresh token -> logout is correct
Transient // network/server problem -> keep the session and retry later
};
// Constants for OAuth loopback server
namespace auth_constants {
constexpr int LOOPBACK_PORT = 41172;
@@ -176,7 +184,12 @@ public:
// ========================================================================
int get_user_presets(std::map<std::string, std::map<std::string, std::string>>* user_presets) override;
std::string request_setting_id(std::string name, std::map<std::string, std::string>* values_map, unsigned int* http_code) override;
int put_setting(std::string setting_id, std::string name, std::map<std::string, std::string>* values_map, unsigned int* http_code) override;
int put_setting(std::string setting_id, std::string name, std::map<std::string, std::string>* values_map, unsigned int* http_code, bool force = false) override;
SyncPushResult sync_push(const std::string& profile_id,
const std::string& name,
const nlohmann::json& content,
const std::string& original_updated_time = "",
bool force = false);
int get_setting_list(std::string bundle_version, ProgressFn pro_fn = nullptr, WasCancelledFn cancel_fn = nullptr) override;
int get_setting_list2(std::string bundle_version, CheckFn chk_fn, ProgressFn pro_fn = nullptr, WasCancelledFn cancel_fn = nullptr) override;
int delete_setting(std::string setting_id) override;
@@ -266,15 +279,15 @@ public:
const PkceBundle& pkce();
void regenerate_pkce();
void persist_refresh_token(const std::string& token);
bool load_refresh_token(std::string& out_token);
void clear_refresh_token();
void persist_user_secret(const std::string& secret);
bool load_user_secret(std::string& out_secret);
void clear_user_secret();
// Token refresh helpers
bool refresh_if_expiring(std::chrono::seconds skew, const std::string& reason);
bool refresh_from_storage(const std::string& reason, bool async = false);
bool refresh_now(const std::string& refresh_token, const std::string& reason, bool async = false);
bool refresh_session_with_token(const std::string& refresh_token);
bool refresh_if_expiring(std::chrono::seconds skew, const std::string& reason);
RefreshResult refresh_from_storage(const std::string& reason, bool async = false);
RefreshResult refresh_now(const std::string& refresh_token, const std::string& reason, bool async = false);
RefreshResult refresh_session_with_token(const std::string& refresh_token);
// Session state helpers. nickname is the human-facing UI label after provider fallback resolution.
bool set_user_session(const std::string& token,
@@ -282,11 +295,14 @@ public:
const std::string& username,
const std::string& nickname,
const std::string& avatar,
const std::string& refresh_token = "");
const std::string& refresh_token = "",
bool persist = true);
// Accepts either nested Orca cloud / GoTrue session JSON or flat WebView token JSON.
bool set_user_session(const nlohmann::json& session_json, bool notify_login = true);
void clear_session();
static std::string generate_uuid_for_setting_id(const std::string& name, const std::string& user_id = "");
private:
// Sync protocol helpers
int sync_pull(
@@ -294,12 +310,20 @@ private:
std::function<void(int http_code, const std::string& error)> on_error
);
SyncPushResult sync_push(
const std::string& profile_id,
const std::string& name,
const nlohmann::json& content,
const std::string& original_updated_time = ""
);
// Shared result of one HTTP attempt by the data methods (get/post/put/delete).
struct HttpResult {
bool success{false};
unsigned int status{0};
std::string body;
};
// Applies the "retry once on 401" policy for the data HTTP methods.
// `res` holds the first response; `perform` re-issues the request after a
// successful refresh. Returns true if the auth error should be SUPPRESSED
// (i.e. the session must be kept rather than logged out).
bool resolve_unauthorized(HttpResult& res,
const std::function<HttpResult()>& perform,
const std::string& reason);
// HTTP request helpers
int http_get(const std::string& path, std::string* response_body, unsigned int* http_code);
@@ -307,7 +331,7 @@ private:
int http_put(const std::string& path, const std::string& body, std::string* response_body, unsigned int* http_code);
int http_delete(const std::string& path, std::string* response_body, unsigned int* http_code);
std::map<std::string, std::string> data_headers();
bool attempt_refresh_after_unauthorized(const std::string& reason);
RefreshResult attempt_refresh_after_unauthorized(const std::string& reason);
// Auth HTTP helpers
bool http_post_token(const std::string& body, std::string* response_body, unsigned int* http_code, const std::string& url = "");
@@ -340,7 +364,7 @@ private:
// Member variables - auth state
PkceBundle pkce_bundle;
std::string refresh_fallback_path;
std::string secret_fallback_path;
SessionHandler session_handler;
OnLoginCompleteHandler on_login_complete_handler;
SessionInfo session;

View File

@@ -1412,7 +1412,7 @@ void PresetUpdater::slic3r_update_notify()
static bool reload_configs_update_gui()
{
wxString header = _L("Need to check the unsaved changes before configuration updates.");
wxString header = _L("Please check any unsaved changes before updating the configuration.");
if (!GUI::wxGetApp().check_and_save_current_preset_changes(_L("Configuration updates"), header, false ))
return false;

View File

@@ -27,6 +27,8 @@
#include "Flashforge.hpp"
#include "SimplyPrint.hpp"
#include "ElegooLink.hpp"
#include "3DPrinterOS.hpp"
#include "Moonraker.hpp"
namespace fs = boost::filesystem;
using boost::optional;
@@ -67,6 +69,8 @@ PrintHost* PrintHost::get_print_host(DynamicPrintConfig *config)
case htFlashforge: return new Flashforge(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 {
@@ -88,6 +92,10 @@ std::string PrintHost::get_print_host_webui(DynamicPrintConfig* config)
webui_url = ElegooLink::get_print_host_webui(config);
break;
}
case htCrealityPrint: {
webui_url = CrealityPrint::get_print_host_webui(config);
break;
}
default: break;
}

View File

@@ -90,7 +90,7 @@ bool Repetier::test(wxString &msg) const
}
catch (const std::exception &) {
res = false;
msg = "Could not parse server response";
msg = _L("Could not parse server response.");
}
})
.perform_sync();