mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-08-04 16:52:29 +00:00
Merge branch 'main' into pr/Noisyfox/13712
This commit is contained in:
667
src/slic3r/Utils/3DPrinterOS.cpp
Executable file
667
src/slic3r/Utils/3DPrinterOS.cpp
Executable 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 >ype) 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
|
||||
80
src/slic3r/Utils/3DPrinterOS.hpp
Executable file
80
src/slic3r/Utils/3DPrinterOS.hpp
Executable 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 >ype
|
||||
) const;
|
||||
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
128
src/slic3r/Utils/CrealityHostDiscovery.cpp
Normal file
128
src/slic3r/Utils/CrealityHostDiscovery.cpp
Normal 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
|
||||
45
src/slic3r/Utils/CrealityHostDiscovery.hpp
Normal file
45
src/slic3r/Utils/CrealityHostDiscovery.hpp
Normal 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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
334
src/slic3r/Utils/CrealityPrintAgent.cpp
Normal file
334
src/slic3r/Utils/CrealityPrintAgent.cpp
Normal 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
|
||||
67
src/slic3r/Utils/CrealityPrintAgent.hpp
Normal file
67
src/slic3r/Utils/CrealityPrintAgent.hpp
Normal 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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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("
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
311
src/slic3r/Utils/Moonraker.cpp
Normal file
311
src/slic3r/Utils/Moonraker.cpp
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
63
src/slic3r/Utils/Moonraker.hpp
Normal file
63
src/slic3r/Utils/Moonraker.hpp
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user