From f118b6b33785eb569056efba0ce0f0666a5a4c99 Mon Sep 17 00:00:00 2001 From: Mariano Dupont Date: Wed, 27 May 2026 12:03:44 -0300 Subject: [PATCH] Add Flashforge Adventurer 5 series local send workflow with IFS mapping (#12991) * Add Flashforge AD5X local send dialog, IFS mapping, and LAN discovery * Refine Flashforge AD5X IFS dialog behavior * Refine Flashforge IFS slot selection dialog * Fix Flashforge printer selection and print mapping * Use 3MF for Flashforge local uploads * Generalize Flashforge local API handling * Handle Flashforge local API IFS support more robustly * Use selected plate filament info for Flashforge IFS mapping * Fix Flashforge current-plate mapping and widget sizing * Improve Flashforge IFS contrast and color matching * Fix Flashforge legacy plate export and upload naming Resolve PLATE_CURRENT_IDX before the legacy send-to-printhost path calls send_gcode so single-plate Flashforge 3MF exports target the selected plate instead of leaking the sentinel into export_3mf. Sanitize Flashforge upload names in one shared utility reused by both the dialog and the backend client. This keeps the UI-visible filename and the actual uploaded filename consistent and replaces printer-problematic characters such as '=' without scattering Flashforge-specific logic through the generic Plater flow. * Keep Flashforge upload filename sanitization in the backend only Drop the PrintHostSendDialog API changes and keep filename sanitization inside the Flashforge backend paths that actually talk to the printer. This keeps the generic send dialog flow untouched while still normalizing problematic upload names for both serial and local API uploads. * Only use the Flashforge IFS dialog for local API uploads * Use reported Flashforge IFS support without model fallback * Remove unused Flashforge slot uniqueness tracking * Include for Flashforge discovery message --- src/libslic3r/Preset.cpp | 3 +- src/libslic3r/PrintConfig.cpp | 7 + src/slic3r/GUI/PartPlate.hpp | 1 + src/slic3r/GUI/PhysicalPrinterDialog.cpp | 53 +- src/slic3r/GUI/Plater.cpp | 88 ++- src/slic3r/GUI/PrintHostDialogs.cpp | 902 +++++++++++++++++++++++ src/slic3r/GUI/PrintHostDialogs.hpp | 70 +- src/slic3r/Utils/Flashforge.cpp | 500 ++++++++++++- src/slic3r/Utils/Flashforge.hpp | 27 +- 9 files changed, 1626 insertions(+), 25 deletions(-) diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp index cd58e90e97..4e7234d1bc 100644 --- a/src/libslic3r/Preset.cpp +++ b/src/libslic3r/Preset.cpp @@ -1329,7 +1329,7 @@ static std::vector s_Preset_printer_options { "scan_first_layer", "enable_power_loss_recovery", "wrapping_detection_layers", "wrapping_exclude_area", "machine_load_filament_time", "machine_unload_filament_time", "machine_tool_change_time", "time_cost", "machine_pause_gcode", "template_custom_gcode", "nozzle_type", "nozzle_hrc","auxiliary_fan", "nozzle_volume","upward_compatible_machine", "z_hop_types", "travel_slope", "retract_lift_enforce","support_chamber_temp_control","support_air_filtration","printer_structure", "best_object_pos", "head_wrap_detect_zone", - "host_type", "print_host", "printhost_apikey", "bbl_use_printhost", "printer_agent", + "host_type", "print_host", "printhost_apikey", "flashforge_serial_number", "bbl_use_printhost", "printer_agent", "print_host_webui", "printhost_cafile","printhost_port","printhost_authorization_type", "printhost_user", "printhost_password", "printhost_ssl_ignore_revoke", "thumbnails", "thumbnails_format", @@ -3839,6 +3839,7 @@ static std::vector s_PhysicalPrinter_opts { "print_host", "print_host_webui", "printhost_apikey", + "flashforge_serial_number", "printhost_cafile", "printhost_port", "printhost_authorization_type", diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index b706283d07..9617c9866f 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -828,6 +828,13 @@ void PrintConfigDef::init_common_params() def->cli = ConfigOptionDef::nocli; def->set_default_value(new ConfigOptionString()); + def = this->add("flashforge_serial_number", coString); + def->label = L("Serial Number"); + def->tooltip = L("Flashforge local API requires the printer serial number."); + def->mode = comAdvanced; + def->cli = ConfigOptionDef::nocli; + def->set_default_value(new ConfigOptionString()); + def = this->add("printhost_port", coString); def->label = L("Printer"); def->tooltip = L("Name of the printer."); diff --git a/src/slic3r/GUI/PartPlate.hpp b/src/slic3r/GUI/PartPlate.hpp index 4b41cc6722..d4c5399142 100644 --- a/src/slic3r/GUI/PartPlate.hpp +++ b/src/slic3r/GUI/PartPlate.hpp @@ -338,6 +338,7 @@ public: std::vector get_extruders_without_support(bool conside_custom_gcode = false) const; // get used filaments from gcode result, 1 based idx std::vector get_used_filaments(); + const std::vector& get_slice_filaments_info() const { return slice_filaments_info; } int get_physical_extruder_by_filament_id(const DynamicConfig& g_config, int idx) const; bool check_filament_printable(const DynamicPrintConfig & config, wxString& error_message); bool check_tpu_printable_status(const DynamicPrintConfig & config, const std::vector &tpu_filaments); diff --git a/src/slic3r/GUI/PhysicalPrinterDialog.cpp b/src/slic3r/GUI/PhysicalPrinterDialog.cpp index 667919aca3..7a85a4c1f2 100644 --- a/src/slic3r/GUI/PhysicalPrinterDialog.cpp +++ b/src/slic3r/GUI/PhysicalPrinterDialog.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -31,6 +32,7 @@ #include "PrintHostDialogs.hpp" #include "../Utils/ASCIIFolding.hpp" #include "../Utils/PrintHost.hpp" +#include "../Utils/Flashforge.hpp" #include "../Utils/UndoRedo.hpp" #include "RemovableDriveManager.hpp" #include "BitmapCache.hpp" @@ -204,10 +206,37 @@ void PhysicalPrinterDialog::build_printhost_settings(ConfigOptionsGroup* m_optgr { auto sizer = create_sizer_with_btn(parent, &m_printhost_browse_btn, "printer_host_browser", _L("Browse") + " " + dots); m_printhost_browse_btn->Bind(wxEVT_BUTTON, [=](wxCommandEvent& e) { - BonjourDialog dialog(this, Preset::printer_technology(*m_config)); - if (dialog.show_and_lookup()) { - m_optgroup->set_value("print_host", dialog.get_selected(), true); - m_optgroup->get_field("print_host")->field_changed(); + const auto host_type = m_config->opt_enum("host_type"); + if (host_type == htFlashforge) { + wxBusyCursor wait; + std::vector printers; + wxString error_msg; + if (!Flashforge::discover_printers(printers, error_msg)) { + show_error(this, error_msg); + return; + } + + wxArrayString choices; + for (const auto& printer : printers) + choices.Add(from_u8((boost::format("%1% (%2%) [%3%]") % printer.name % printer.ip_address % printer.serial_number).str())); + + wxSingleChoiceDialog dialog(this, _L("Select a Flashforge printer"), _L("Discovered Printers"), choices); + if (dialog.ShowModal() == wxID_OK) { + const int idx = dialog.GetSelection(); + if (idx >= 0 && idx < static_cast(printers.size())) { + m_optgroup->set_value("print_host", from_u8(printers[idx].ip_address), true); + m_optgroup->set_value("flashforge_serial_number", from_u8(printers[idx].serial_number), true); + m_config->opt_string("print_host") = printers[idx].ip_address; + m_config->opt_string("flashforge_serial_number") = printers[idx].serial_number; + update_printhost_buttons(); + } + } + } else { + BonjourDialog dialog(this, Preset::printer_technology(*m_config)); + if (dialog.show_and_lookup()) { + m_optgroup->set_value("print_host", dialog.get_selected(), true); + m_optgroup->get_field("print_host")->field_changed(); + } } }); @@ -329,6 +358,10 @@ void PhysicalPrinterDialog::build_printhost_settings(ConfigOptionsGroup* m_optgr option.opt.width = Field::def_width_wider(); m_optgroup->append_single_option_line(option); + option = m_optgroup->get_option("flashforge_serial_number"); + option.opt.width = Field::def_width_wider(); + m_optgroup->append_single_option_line(option); + option = m_optgroup->get_option("printhost_port"); option.opt.width = Field::def_width_wider(); Line port_line = m_optgroup->create_single_option_line(option); @@ -685,13 +718,17 @@ void PhysicalPrinterDialog::update(bool printer_change) } if (opt->value == htFlashforge) { - m_optgroup->hide_field("printhost_apikey"); - m_optgroup->hide_field("printhost_authorization_type"); - } + m_optgroup->show_field("printhost_apikey"); + m_optgroup->show_field("flashforge_serial_number"); + m_optgroup->hide_field("printhost_authorization_type"); + } else { + m_optgroup->hide_field("flashforge_serial_number"); + } } else { m_optgroup->set_value("host_type", int(PrintHostType::htOctoPrint), false); m_optgroup->hide_field("host_type"); + m_optgroup->hide_field("flashforge_serial_number"); m_optgroup->show_field("printhost_authorization_type"); @@ -809,7 +846,7 @@ void PhysicalPrinterDialog::on_dpi_changed(const wxRect& suggested_rect) void PhysicalPrinterDialog::check_host_key_valid() { - std::vector keys = {"print_host", "print_host_webui", "printhost_apikey", "printhost_cafile", "printhost_user", "printhost_password", "printhost_port"}; + std::vector keys = {"print_host", "print_host_webui", "printhost_apikey", "flashforge_serial_number", "printhost_cafile", "printhost_user", "printhost_password", "printhost_port"}; for (auto &key : keys) { auto it = m_config->option(key); if (!it) m_config->set_key_value(key, new ConfigOptionString("")); diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 00c2ed65ff..8d46b6dc3b 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -16081,6 +16081,18 @@ void Plater::send_gcode_legacy(int plate_idx, Export3mfProgressFn proFn, bool us if (upload_job.empty()) return; + const auto host_type_opt = physical_printer_config->option>("host_type"); + const auto host_type = host_type_opt != nullptr ? host_type_opt->value : htElegooLink; + const auto* ff_serial_opt = physical_printer_config->option("flashforge_serial_number"); + const auto* ff_code_opt = physical_printer_config->option("printhost_apikey"); + const bool flashforge_local_api = + host_type == htFlashforge && + ff_serial_opt != nullptr && !ff_serial_opt->value.empty() && + ff_code_opt != nullptr && !ff_code_opt->value.empty(); + + if (flashforge_local_api) + use_3mf = true; + upload_job.upload_data.use_3mf = use_3mf; // Obtain default output path @@ -16128,8 +16140,6 @@ void Plater::send_gcode_legacy(int plate_idx, Export3mfProgressFn proFn, bool us { auto preset_bundle = wxGetApp().preset_bundle; - const auto opt = physical_printer_config->option>("host_type"); - const auto host_type = opt != nullptr ? opt->value : htElegooLink; auto config = get_app_config(); std::unique_ptr pDlg; @@ -16137,6 +16147,77 @@ void Plater::send_gcode_legacy(int plate_idx, Export3mfProgressFn proFn, bool us pDlg = std::make_unique(default_output_file, upload_job.printhost->get_post_upload_actions(), groups, storage_paths, storage_names, config->get_bool("open_device_tab_post_upload")); + } else if (flashforge_local_api) { + auto* flashforge_host = dynamic_cast(upload_job.printhost.get()); + if (flashforge_host == nullptr) { + show_error(this, _L("Flashforge host is not available."), false); + return; + } + + std::vector slots; + bool supports_material_station = false; + { + wxBusyCursor wait; + wxString msg; + if (!flashforge_host->fetch_material_slots(slots, &supports_material_station, msg)) { + show_error(this, msg.empty() ? _L("Unable to log in to the Flashforge printer.") : msg, false); + return; + } + } + + std::vector project_filaments; + PlateDataPtrs plate_data_list; + DynamicPrintConfig cfg = wxGetApp().preset_bundle->full_config(); + const auto* filament_color = dynamic_cast(cfg.option("filament_colour")); + const auto* filament_id_opt = dynamic_cast(cfg.option("filament_ids")); + const int resolved_plate_idx = plate_idx == PLATE_CURRENT_IDX ? get_partplate_list().get_curr_plate_index() : plate_idx; + auto enrich_project_filaments = [&](std::vector& filaments) { + for (auto& filament : filaments) { + if (filament.id < 0) + continue; + + std::string display_filament_type; + try { + filament.type = cfg.get_filament_type(display_filament_type, filament.id); + } catch (...) { + } + + if (filament.type.empty()) + filament.type = display_filament_type; + if (filament.type.empty()) + filament.type = "Unknown"; + + filament.filament_id = filament_id_opt ? filament_id_opt->get_at(static_cast(filament.id)) : ""; + filament.color = filament_color ? filament_color->get_at(static_cast(filament.id)) : "#FFFFFF"; + if (filament.color.empty()) + filament.color = "#FFFFFF"; + } + }; + + p->partplate_list.store_to_3mf_structure(plate_data_list, true, plate_idx); + PlateData* selected_plate_data = (resolved_plate_idx >= 0 && resolved_plate_idx < static_cast(plate_data_list.size())) ? plate_data_list[resolved_plate_idx] : nullptr; + if (selected_plate_data == nullptr && !plate_data_list.empty()) + selected_plate_data = plate_data_list.front(); + + if (selected_plate_data != nullptr) + project_filaments = selected_plate_data->slice_filaments_info; + + if (project_filaments.empty()) { + if (PartPlate* plate = get_partplate_list().get_plate(resolved_plate_idx); plate != nullptr) + project_filaments = plate->get_slice_filaments_info(); + } + + if (!project_filaments.empty()) + enrich_project_filaments(project_filaments); + release_PlateData_list(plate_data_list); + + pDlg = std::make_unique(default_output_file, upload_job.printhost->get_post_upload_actions(), groups, + storage_paths, storage_names, + config->get_bool("open_device_tab_post_upload"), + flashforge_host, + supports_material_station, + std::move(slots), + project_filaments); } else { pDlg = std::make_unique(default_output_file, upload_job.printhost->get_post_upload_actions(), groups, storage_paths, storage_names, config->get_bool("open_device_tab_post_upload")); @@ -16166,7 +16247,8 @@ void Plater::send_gcode_legacy(int plate_idx, Export3mfProgressFn proFn, bool us if (use_3mf) { // Process gcode - const int result = send_gcode(plate_idx, nullptr); + const int export_plate_idx = plate_idx == PLATE_CURRENT_IDX ? get_partplate_list().get_curr_plate_index() : plate_idx; + const int result = send_gcode(export_plate_idx, nullptr); if (result < 0) { wxString msg = _L("Abnormal print file data. Please slice again"); diff --git a/src/slic3r/GUI/PrintHostDialogs.cpp b/src/slic3r/GUI/PrintHostDialogs.cpp index f59ff23e9d..1d9b30fd1b 100644 --- a/src/slic3r/GUI/PrintHostDialogs.cpp +++ b/src/slic3r/GUI/PrintHostDialogs.cpp @@ -1,7 +1,9 @@ #include "PrintHostDialogs.hpp" #include +#include #include +#include #include #include @@ -11,6 +13,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -18,6 +23,8 @@ #include #include #include +#include +#include #include "GUI.hpp" #include "GUI_App.hpp" @@ -30,10 +37,383 @@ #include "format.hpp" namespace fs = boost::filesystem; +using json = nlohmann::json; namespace Slic3r { namespace GUI { +namespace { + +wxColour contrasting_text_color(const wxColour& background) +{ + return background.GetLuminance() < 0.60 ? *wxWHITE : wxColour("#303030"); +} + +long long color_distance_sq(const wxColour& lhs, const wxColour& rhs) +{ + const long long dr = static_cast(lhs.Red()) - static_cast(rhs.Red()); + const long long dg = static_cast(lhs.Green()) - static_cast(rhs.Green()); + const long long db = static_cast(lhs.Blue()) - static_cast(rhs.Blue()); + return dr * dr + dg * dg + db * db; +} + +class FlashforgeSlotCard : public wxPanel +{ +public: + FlashforgeSlotCard(wxWindow* parent) + : wxPanel(parent, wxID_ANY) + { + SetDoubleBuffered(true); + SetMinSize(wxSize(FromDIP(68), FromDIP(92))); + SetMaxSize(GetMinSize()); + Bind(wxEVT_PAINT, &FlashforgeSlotCard::on_paint, this); + Bind(wxEVT_ENTER_WINDOW, [this](wxMouseEvent& e) { + m_hover = true; + SetCursor(wxCursor(m_enabled ? wxCURSOR_HAND : wxCURSOR_NO_ENTRY)); + Refresh(); + e.Skip(); + }); + Bind(wxEVT_LEAVE_WINDOW, [this](wxMouseEvent& e) { + m_hover = false; + SetCursor(wxCursor(wxCURSOR_ARROW)); + Refresh(); + e.Skip(); + }); + Bind(wxEVT_LEFT_DOWN, &FlashforgeSlotCard::on_left_down, this); + } + + void set_slot(const Slic3r::FlashforgeMaterialSlot& slot, bool enabled) + { + m_slot_id = slot.slot_id; + m_color = parse_color(slot.material_color); + m_name = slot.material_name.empty() ? _L("Unknown") : from_u8(slot.material_name); + m_empty = !slot.has_filament; + m_enabled = enabled && !m_empty; + Refresh(); + } + +private: + static wxColour parse_color(const std::string& raw) + { + wxColour color(from_u8(raw)); + if (color.IsOk()) + return color; + + std::string value = raw; + boost::trim(value); + if (!value.empty() && value.front() != '#') + value.insert(value.begin(), '#'); + color = wxColour(from_u8(value)); + return color.IsOk() ? color : wxColour("#D0D0D0"); + } + + void on_left_down(wxMouseEvent& e) + { + if (!m_enabled) + return; + + wxCommandEvent evt(wxEVT_BUTTON, GetId()); + evt.SetInt(m_slot_id); + evt.SetString(m_color.GetAsString(wxC2S_HTML_SYNTAX)); + wxPostEvent(this, evt); + e.Skip(); + } + + void on_paint(wxPaintEvent&) + { + wxPaintDC dc(this); + std::unique_ptr gc(wxGraphicsContext::Create(dc)); + if (gc == nullptr) + return; + + const wxSize size = GetSize(); + const int circle_size = FromDIP(24); + const int outline_width = (m_hover && m_enabled) ? FromDIP(2) : FromDIP(1); + const wxRect body_rect(FromDIP(7), FromDIP(26), size.x - FromDIP(14), size.y - FromDIP(32)); + const wxColour badge_color = m_enabled ? wxSystemSettings::GetColour(wxSYS_COLOUR_HIGHLIGHT) + : wxSystemSettings::GetColour(wxSYS_COLOUR_GRAYTEXT); + const wxColour body_border = m_enabled ? wxSystemSettings::GetColour(wxSYS_COLOUR_BTNSHADOW) + : wxSystemSettings::GetColour(wxSYS_COLOUR_GRAYTEXT); + + gc->SetPen(*wxTRANSPARENT_PEN); + gc->SetBrush(wxBrush((m_hover && m_enabled) ? badge_color.ChangeLightness(130) : badge_color)); + gc->DrawEllipse((size.x - circle_size) / 2.0, 0, circle_size, circle_size); + + dc.SetFont(::Label::Body_13); + dc.SetTextForeground(*wxWHITE); + const wxString slot_txt = wxString::Format("%d", m_slot_id); + const wxSize slot_size = dc.GetTextExtent(slot_txt); + dc.DrawText(slot_txt, (size.x - slot_size.x) / 2, (circle_size - slot_size.y) / 2); + + const wxColour bg_color = m_empty ? wxColour("#F6F6F6") : m_color; + gc->SetPen(wxPen(body_border, outline_width)); + gc->SetBrush(wxBrush(bg_color)); + gc->DrawRoundedRectangle(body_rect.x, body_rect.y, body_rect.width, body_rect.height, FromDIP(8)); + + dc.SetFont(::Label::Body_12); + dc.SetTextForeground(contrasting_text_color(bg_color)); + + wxString label = m_empty ? _L("Empty") : m_name; + if (dc.GetTextExtent(label).x > body_rect.width - FromDIP(8)) + dc.SetFont(::Label::Body_10); + if (dc.GetTextExtent(label).x > body_rect.width - FromDIP(8)) { + while (!label.empty() && dc.GetTextExtent(label + "...").x > body_rect.width - FromDIP(8)) + label.RemoveLast(); + label += "..."; + } + const wxSize label_size = dc.GetTextExtent(label); + dc.DrawText(label, body_rect.x + (body_rect.width - label_size.x) / 2, body_rect.y + (body_rect.height - label_size.y) / 2); + } + +private: + int m_slot_id {0}; + wxColour m_color {*wxWHITE}; + wxString m_name; + bool m_empty {true}; + bool m_enabled {false}; + bool m_hover {false}; +}; + +class FlashforgeSlotDialog : public DPIDialog +{ +public: + FlashforgeSlotDialog(wxWindow* parent, const wxString& material_name) + : DPIDialog(parent, wxID_ANY, _L("Choose a slot for the selected color"), wxDefaultPosition, wxDefaultSize, wxCAPTION | wxCLOSE_BOX) + , m_material_name(material_name) + { + SetFont(wxGetApp().normal_font()); + SetBackgroundColour(*wxWHITE); + + auto* root = new wxBoxSizer(wxVERTICAL); + auto* title = new wxStaticText(this, wxID_ANY, _L("Material in the material station")); + title->SetFont(::Label::Head_13); + root->Add(title, 0, wxALL | wxALIGN_CENTER_HORIZONTAL, FromDIP(12)); + + m_grid = new wxGridSizer(1, 4, FromDIP(10), FromDIP(12)); + auto* grid_row = new wxBoxSizer(wxHORIZONTAL); + grid_row->AddStretchSpacer(); + grid_row->Add(m_grid, 0); + grid_row->AddStretchSpacer(); + root->Add(grid_row, 0, wxLEFT | wxRIGHT | wxEXPAND, FromDIP(18)); + + auto* tip = new wxStaticText(this, wxID_ANY, _L("Only materials of the same type can be selected.")); + tip->SetForegroundColour(wxSystemSettings::GetColour(wxSYS_COLOUR_GRAYTEXT)); + root->Add(tip, 0, wxALL | wxALIGN_CENTER_HORIZONTAL, FromDIP(12)); + + SetSizer(root); + SetMinSize(wxSize(FromDIP(460), FromDIP(240))); + + for (int i = 0; i < 4; ++i) { + auto* card = new FlashforgeSlotCard(this); + card->Bind(wxEVT_BUTTON, [this](wxCommandEvent& e) { + m_selected_slot_id = e.GetInt(); + m_selected_color = wxColour(e.GetString()); + EndModal(wxID_OK); + }); + m_cards.push_back(card); + m_grid->Add(card, 0); + } + + wxGetApp().UpdateDlgDarkUI(this); + Layout(); + Fit(); + CenterOnParent(); + Refresh(); + } + + void update_slots(const std::vector& slots, const std::function& matcher) + { + for (size_t i = 0; i < m_cards.size(); ++i) { + Slic3r::FlashforgeMaterialSlot slot; + slot.slot_id = static_cast(i) + 1; + if (const auto it = std::find_if(slots.begin(), slots.end(), [&](const Slic3r::FlashforgeMaterialSlot& item) { return item.slot_id == slot.slot_id; }); it != slots.end()) + slot = *it; + const bool enabled = slot.has_filament && matcher(slot); + m_cards[i]->set_slot(slot, enabled); + } + Layout(); + Fit(); + } + + bool has_selection() const { return m_selected_slot_id > 0; } + int selected_slot_id() const { return m_selected_slot_id; } + wxColour selected_color() const { return m_selected_color; } + +protected: + void on_dpi_changed(const wxRect& suggested_rect) override + { + Fit(); + Refresh(); + if (suggested_rect.IsEmpty()) + return; + SetSize(suggested_rect.GetSize()); + } + +private: + wxString m_material_name; + wxGridSizer* m_grid {nullptr}; + std::vector m_cards; + int m_selected_slot_id {0}; + wxColour m_selected_color; +}; + +class FlashforgeMaterialMapWidget : public wxPanel +{ +public: + using SelectFn = std::function; + + FlashforgeMaterialMapWidget(wxWindow* parent, int tool_id, const wxColour& color, const wxString& material_name, SelectFn on_select) + : wxPanel(parent, wxID_ANY) + , m_tool_id(tool_id) + , m_color(color) + , m_name(material_name.Strip()) + , m_select_fn(std::move(on_select)) + { + SetDoubleBuffered(true); + const wxSize size(FromDIP(72), FromDIP(58)); + SetSize(size); + SetMinSize(size); + SetMaxSize(size); + Bind(wxEVT_PAINT, &FlashforgeMaterialMapWidget::on_paint, this); + Bind(wxEVT_LEFT_DOWN, &FlashforgeMaterialMapWidget::on_left_down, this); + + } + + int tool_id() const { return m_tool_id; } + int selected_slot_id() const { return m_slot_id; } + bool is_slot_selected() const { return m_slot_id > 0; } + wxString material_name() const { return m_name; } + + void set_enable_mapping(bool enable) + { + if (m_mapping_enabled == enable) + return; + m_mapping_enabled = enable; + if (!enable) + reset_slot(); + Enable(enable); + Refresh(); + } + + void set_slot_selection(int slot_id, const wxColour& slot_color) + { + m_slot_id = slot_id; + m_slot_color = slot_color; + Refresh(); + } + + void reset_slot() + { + m_slot_id = 0; + m_slot_color = wxColour("#DDDDDD"); + Refresh(); + } + + void update_popup_slots(const std::vector& slots, const std::function& matcher) + { + m_slots_snapshot = slots; + m_matcher = matcher; + } + + wxSize DoGetBestSize() const override + { + return wxSize(FromDIP(72), FromDIP(58)); + } + +private: + void on_left_down(wxMouseEvent& e) + { + if (!m_mapping_enabled) + return; + + FlashforgeSlotDialog dlg(this, m_name); + dlg.update_slots(m_slots_snapshot, m_matcher); + m_selected = true; + Refresh(); + if (dlg.ShowModal() == wxID_OK && dlg.has_selection()) { + m_slot_id = dlg.selected_slot_id(); + m_slot_color = dlg.selected_color(); + if (m_select_fn) + m_select_fn(this); + } + m_selected = false; + Refresh(); + e.Skip(); + } + + void on_paint(wxPaintEvent&) + { + wxPaintDC dc(this); + std::unique_ptr gc(wxGraphicsContext::Create(dc)); + if (gc == nullptr) + return; + + const wxSize size = GetSize(); + const int half_h = size.y / 2; + gc->SetPen(*wxTRANSPARENT_PEN); + gc->SetBrush(wxBrush(m_color)); + gc->DrawRoundedRectangle(0, 0, size.x, half_h, FromDIP(3)); + gc->DrawRectangle(0, half_h - FromDIP(3), size.x, FromDIP(3)); + + gc->SetBrush(wxBrush(m_mapping_enabled ? m_slot_color : wxColour("#DDDDDD"))); + gc->DrawRoundedRectangle(0, half_h, size.x, half_h, FromDIP(3)); + gc->DrawRectangle(0, half_h, size.x, FromDIP(3)); + + if (m_selected) { + gc->SetPen(wxPen(wxColour("#00AE42"), FromDIP(2))); + gc->SetBrush(*wxTRANSPARENT_BRUSH); + gc->DrawRoundedRectangle(0, 0, size.x - FromDIP(1), size.y - FromDIP(1), FromDIP(3)); + } else if (m_color.GetLuminance() > 0.95 || m_slot_color.GetLuminance() > 0.95) { + gc->SetPen(wxPen(wxColour("#ACACAC"), FromDIP(1))); + gc->SetBrush(*wxTRANSPARENT_BRUSH); + gc->DrawRoundedRectangle(0, 0, size.x - FromDIP(1), size.y - FromDIP(1), FromDIP(3)); + } + + dc.SetFont(::Label::Body_13); + dc.SetTextForeground(contrasting_text_color(m_color)); + wxString top_text = m_name; + if (dc.GetTextExtent(top_text).x > size.x - FromDIP(10)) { + dc.SetFont(::Label::Body_10); + } + wxSize top_size = dc.GetTextExtent(top_text); + dc.DrawText(top_text, (size.x - top_size.x) / 2, (half_h - top_size.y) / 2); + + dc.SetFont(::Label::Body_13); + dc.SetTextForeground(contrasting_text_color(m_slot_color)); + const wxString bottom_text = m_slot_id > 0 ? wxString::Format("%d", m_slot_id) : "-"; + const wxSize bottom_size = dc.GetTextExtent(bottom_text); + dc.DrawText(bottom_text, (size.x - bottom_size.x - FromDIP(10)) / 2, half_h + (half_h - bottom_size.y) / 2); + + wxPoint pts[3] = { + wxPoint(size.x - FromDIP(18), half_h + half_h / 2 - FromDIP(2)), + wxPoint(size.x - FromDIP(10), half_h + half_h / 2 - FromDIP(2)), + wxPoint(size.x - FromDIP(14), half_h + half_h / 2 + FromDIP(3)) + }; + dc.SetBrush(wxBrush(contrasting_text_color(m_slot_color))); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawPolygon(3, pts); + } + +private: + int m_tool_id {-1}; + wxColour m_color; + wxString m_name; + wxColour m_slot_color {wxColour("#DDDDDD")}; + int m_slot_id {0}; + bool m_selected {false}; + bool m_mapping_enabled {true}; + SelectFn m_select_fn; + std::vector m_slots_snapshot; + std::function m_matcher; +}; + +static FlashforgeMaterialMapWidget* as_ff_map_widget(wxWindow* window) +{ + return dynamic_cast(window); +} + +} // namespace + static const char *CONFIG_KEY_PATH = "printhost_path"; static const char *CONFIG_KEY_GROUP = "printhost_group"; static const char* CONFIG_KEY_STORAGE = "printhost_storage"; @@ -257,6 +637,528 @@ void PrintHostSendDialog::EndModal(int ret) MsgDialog::EndModal(ret); } +FlashforgePrintHostSendDialog::FlashforgePrintHostSendDialog(const fs::path& path, + PrintHostPostUploadActions post_actions, + const wxArrayString& groups, + const wxArrayString& storage_paths, + const wxArrayString& storage_names, + bool switch_to_device_tab, + const Slic3r::Flashforge* host, + bool supports_material_station, + std::vector slots, + const std::vector& project_filaments) + : PrintHostSendDialog(path, post_actions, groups, storage_paths, storage_names, switch_to_device_tab) + , m_host(host) + , m_slots(std::move(slots)) + , m_project_filaments(project_filaments) +{ + m_supports_material_station = supports_material_station; + m_slots_loaded = !m_slots.empty(); +} + +void FlashforgePrintHostSendDialog::init() +{ + const AppConfig* app_config = wxGetApp().app_config; + const auto& path = m_path; + + std::string leveling = app_config->get("recent", CONFIG_KEY_LEVELING); + if (!leveling.empty()) + m_leveling_before_print = leveling == "1"; + + std::string timelapse = app_config->get("recent", CONFIG_KEY_TIMELAPSE); + if (!timelapse.empty()) + m_time_lapse_video = timelapse == "1"; + + // Flashforge local printing should default to IFS enabled when supported. + // We don't revive an old stale "0" here. + m_use_material_station = m_supports_material_station; + if (m_supports_material_station && !app_config->has("recent", CONFIG_KEY_IFS)) + const_cast(app_config)->set("recent", CONFIG_KEY_IFS, "1"); + + this->SetMinSize(wxSize(560, 420)); + + auto* label_dir_hint = new wxStaticText(this, wxID_ANY, _L("Use forward slashes ( / ) as a directory separator if needed.")); + label_dir_hint->Wrap(CONTENT_WIDTH * wxGetApp().em_unit()); + content_sizer->Add(txt_filename, 0, wxEXPAND); + content_sizer->Add(label_dir_hint); + content_sizer->AddSpacer(VERT_SPACING); + + wxString recent_path = from_u8(app_config->get("recent", CONFIG_KEY_PATH)); + if (recent_path.Length() > 0 && recent_path[recent_path.Length() - 1] != '/') + recent_path += '/'; + const auto recent_path_len = recent_path.Length(); + recent_path += path.filename().wstring(); + wxString stem(path.stem().wstring()); + const auto stem_len = stem.Length(); + txt_filename->SetValue(recent_path); + + { + auto checkbox_sizer = new wxBoxSizer(wxHORIZONTAL); + auto checkbox = new ::CheckBox(this, wxID_APPLY); + checkbox->SetValue(m_switch_to_device_tab); + checkbox->Bind(wxEVT_TOGGLEBUTTON, [this](wxCommandEvent& e) { + auto* source = dynamic_cast<::CheckBox*>(e.GetEventObject()); + if (source != nullptr) + source->SetValue(e.IsChecked()); + m_switch_to_device_tab = e.IsChecked(); + e.Skip(); + }); + checkbox_sizer->Add(checkbox, 0, wxALL | wxALIGN_CENTER, FromDIP(2)); + + auto checkbox_text = new wxStaticText(this, wxID_ANY, _L("Switch to Device tab after upload.")); + checkbox_text->SetFont(::Label::Body_13); + checkbox_text->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#323A3D"))); + checkbox_sizer->Add(checkbox_text, 0, wxALL | wxALIGN_CENTER, FromDIP(2)); + content_sizer->Add(checkbox_sizer); + content_sizer->AddSpacer(VERT_SPACING); + } + + m_flashforge_options_sizer = new wxBoxSizer(wxVERTICAL); + + auto add_option_checkbox = [this](wxBoxSizer* parent, const wxString& label, bool value, std::function setter, ::CheckBox** out = nullptr) { + auto row = new wxBoxSizer(wxHORIZONTAL); + auto checkbox = new ::CheckBox(this); + checkbox->SetValue(value); + checkbox->Bind(wxEVT_TOGGLEBUTTON, [setter](wxCommandEvent& e) { + auto* source = dynamic_cast<::CheckBox*>(e.GetEventObject()); + if (source != nullptr) + source->SetValue(e.IsChecked()); + setter(e.IsChecked()); + e.Skip(); + }); + row->Add(checkbox, 0, wxALL | wxALIGN_CENTER, FromDIP(2)); + + auto text = new wxStaticText(this, wxID_ANY, label); + text->SetFont(::Label::Body_13); + text->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#323A3D"))); + row->Add(text, 0, wxALL | wxALIGN_CENTER, FromDIP(2)); + parent->Add(row); + parent->AddSpacer(FromDIP(6)); + + if (out != nullptr) + *out = checkbox; + }; + + add_option_checkbox(m_flashforge_options_sizer, _L("Leveling before print"), m_leveling_before_print, + [this](bool checked) { m_leveling_before_print = checked; }, &m_checkbox_leveling); + add_option_checkbox(m_flashforge_options_sizer, _L("Time-lapse"), m_time_lapse_video, + [this](bool checked) { m_time_lapse_video = checked; }, &m_checkbox_timelapse); + add_option_checkbox(m_flashforge_options_sizer, _L("Enable IFS"), m_use_material_station, + [this](bool checked) { + m_use_material_station = checked; + if (checked) { + ensure_slots_loaded(); + rebuild_mapping_rows(); + } + sync_mapping_section_visibility(); + }, &m_checkbox_ifs); + + if (m_checkbox_ifs != nullptr && !m_supports_material_station) + m_checkbox_ifs->Enable(false); + + m_status_text = new wxStaticText(this, wxID_ANY, wxEmptyString); + m_status_text->SetFont(::Label::Body_12); + m_flashforge_options_sizer->Add(m_status_text, 0, wxTOP | wxBOTTOM, FromDIP(4)); + + m_mapping_section_sizer = new wxBoxSizer(wxVERTICAL); + m_mapping_wrap_sizer = new wxWrapSizer(wxHORIZONTAL, wxWRAPSIZER_DEFAULT_FLAGS); + m_mapping_section_sizer->Add(m_mapping_wrap_sizer, 0, wxTOP | wxALIGN_LEFT, FromDIP(10)); + m_flashforge_options_sizer->Add(m_mapping_section_sizer, 0, wxEXPAND); + + content_sizer->Add(m_flashforge_options_sizer, 0, wxEXPAND); + + if (m_supports_material_station) + m_status_text->SetLabel(wxString::Format(_L("Detected %d IFS slots on printer."), static_cast(m_slots.size()))); + else + m_status_text->SetLabel(_L("This printer does not report a material station.")); + + rebuild_mapping_rows(); + sync_mapping_section_visibility(); + + if (size_t extension_start = recent_path.find_last_of('.'); extension_start != std::string::npos) + m_valid_suffix = recent_path.substr(extension_start); + + auto validate_path = [this](const wxString& filename) -> bool { + if (!filename.Lower().EndsWith(m_valid_suffix.Lower())) { + MessageDialog msg_wingow(this, wxString::Format(_L("Upload filename doesn't end with \"%s\". Do you wish to continue?"), m_valid_suffix), + wxString(SLIC3R_APP_NAME), wxYES | wxNO); + if (msg_wingow.ShowModal() == wxID_NO) + return false; + } + return validate_before_close(); + }; + + auto* btn_ok = add_button(wxID_OK, true, _L("Upload")); + btn_ok->Bind(wxEVT_BUTTON, [this, validate_path](wxCommandEvent&) { + if (validate_path(txt_filename->GetValue())) { + post_upload_action = PrintHostPostUploadAction::None; + EndDialog(wxID_OK); + } + }); + + if (m_post_actions.has(PrintHostPostUploadAction::StartPrint)) { + auto* btn_print = add_button(wxID_YES, false, _L("Upload and Print")); + btn_print->Bind(wxEVT_BUTTON, [this, validate_path](wxCommandEvent&) { + if (validate_path(txt_filename->GetValue())) { + post_upload_action = PrintHostPostUploadAction::StartPrint; + EndDialog(wxID_OK); + } + }); + } + + add_button(wxID_CANCEL, false, _L("Cancel")); + finalize(); + txt_filename->SetFocus(); + +#ifdef __linux__ + txt_filename->Bind(wxEVT_KILL_FOCUS, [this](wxEvent& e) { + e.Skip(); + txt_filename->SetInsertionPoint(txt_filename->GetLastPosition()); + }, txt_filename->GetId()); +#endif /* __linux__ */ + + Bind(wxEVT_SHOW, [=](const wxShowEvent&) { + CallAfter([=]() { + txt_filename->SetInsertionPoint(0); + txt_filename->SetSelection(recent_path_len, recent_path_len + stem_len); + }); + }); +} + +void FlashforgePrintHostSendDialog::EndModal(int ret) +{ + if (ret == wxID_OK) { + AppConfig* app_config = wxGetApp().app_config; + app_config->set("recent", CONFIG_KEY_LEVELING, m_leveling_before_print ? "1" : "0"); + app_config->set("recent", CONFIG_KEY_TIMELAPSE, m_time_lapse_video ? "1" : "0"); + app_config->set("recent", CONFIG_KEY_IFS, m_use_material_station ? "1" : "0"); + } + + PrintHostSendDialog::EndModal(ret); +} + +std::map FlashforgePrintHostSendDialog::extendedInfo() const +{ + json mappings = json::array(); + int mapped_count = 0; + + if (m_use_material_station) { + for (const auto& row : m_mapping_rows) { + auto* card = as_ff_map_widget(row.card); + if (card == nullptr || row.tool_id < 0) + continue; + + const int slot_id = card->selected_slot_id(); + if (slot_id <= 0) + continue; + + const auto filament_it = std::find_if(m_project_filaments.begin(), m_project_filaments.end(), [&](const FilamentInfo& item) { return item.id == row.tool_id; }); + const auto slot_it = std::find_if(m_slots.begin(), m_slots.end(), [&](const FlashforgeMaterialSlot& slot) { return slot.slot_id == slot_id; }); + if (filament_it == m_project_filaments.end() || slot_it == m_slots.end()) + continue; + + mappings.push_back({ + {"toolId", filament_it->id}, + {"slotId", slot_it->slot_id}, + {"materialName", slot_it->material_name}, + {"toolMaterialColor", filament_it->color}, + {"slotMaterialColor", slot_it->material_color} + }); + ++mapped_count; + } + } + + return { + {"levelingBeforePrint", m_leveling_before_print ? "1" : "0"}, + {"timeLapseVideo", m_time_lapse_video ? "1" : "0"}, + {"useMatlStation", m_use_material_station ? "1" : "0"}, + {"gcodeToolCnt", std::to_string(mapped_count)}, + {"materialMappings", mappings.dump()} + }; +} + +void FlashforgePrintHostSendDialog::load_slots() +{ + m_slots.clear(); + m_slots_loaded = false; + m_supports_material_station = false; + + if (m_host == nullptr) { + m_status_text->SetLabel(_L("Flashforge host is not available.")); + return; + } + + wxString msg; + bool supports_material_station = false; + if (!m_host->fetch_material_slots(m_slots, &supports_material_station, msg)) { + m_status_text->SetLabel(msg.empty() ? _L("Unable to read IFS slots from printer.") : msg); + return; + } + + m_supports_material_station = supports_material_station; + m_slots_loaded = !m_slots.empty(); + m_use_material_station = m_supports_material_station; + + if (m_supports_material_station) + m_status_text->SetLabel(wxString::Format(_L("Detected %d IFS slots on printer."), static_cast(m_slots.size()))); + else + m_status_text->SetLabel(_L("This printer does not report a material station.")); +} + +bool FlashforgePrintHostSendDialog::ensure_slots_loaded(bool force_reload) +{ + if (!force_reload && (m_slots_loaded || !m_supports_material_station)) + return m_slots_loaded; + + if (m_status_text != nullptr) + m_status_text->SetLabel(_L("Loading IFS slots from printer...")); + + wxBusyCursor wait; + load_slots(); + return m_slots_loaded; +} + +void FlashforgePrintHostSendDialog::rebuild_mapping_rows() +{ + if (m_mapping_wrap_sizer == nullptr) + return; + + m_mapping_wrap_sizer->Clear(true); + m_mapping_rows.clear(); + + if (m_project_filaments.empty()) { + m_mapping_wrap_sizer->Add(new wxStaticText(this, wxID_ANY, _L("Slice the plate first to get project material information.")), 0, wxALL, FromDIP(2)); + return; + } + + for (const auto& filament : m_project_filaments) { + auto* card = new FlashforgeMaterialMapWidget(this, filament.id, to_wx_colour(filament.color), from_u8(filament.get_display_filament_type()), + [this](FlashforgeMaterialMapWidget* changed_card) { + if (changed_card == nullptr) + return; + for (auto& row : m_mapping_rows) { + if (row.card == changed_card) { + refresh_mapping_card(row); + break; + } + } + }); + m_mapping_wrap_sizer->Add(card, 0, wxRIGHT | wxBOTTOM | wxFIXED_MINSIZE, FromDIP(10)); + + MappingRow row; + row.tool_id = filament.id; + row.card = card; + m_mapping_rows.push_back(row); + } + + auto_assign_mappings(); +} + +void FlashforgePrintHostSendDialog::auto_assign_mappings() +{ + for (size_t idx = 0; idx < m_project_filaments.size() && idx < m_mapping_rows.size(); ++idx) { + auto& filament = m_project_filaments[idx]; + auto* card = as_ff_map_widget(m_mapping_rows[idx].card); + if (card == nullptr) + continue; + + const wxColour filament_color = to_wx_colour(filament.color); + const Slic3r::FlashforgeMaterialSlot* best_slot = nullptr; + long long best_distance = std::numeric_limits::max(); + + for (const auto& slot : m_slots) { + if (!slot.has_filament || !slot_matches_filament(slot, filament)) + continue; + + const long long distance = color_distance_sq(filament_color, to_wx_colour(slot.material_color)); + if (best_slot == nullptr || distance < best_distance) { + best_slot = &slot; + best_distance = distance; + } + } + + if (best_slot != nullptr) + card->set_slot_selection(best_slot->slot_id, to_wx_colour(best_slot->material_color)); + else + card->reset_slot(); + + refresh_mapping_card(m_mapping_rows[idx]); + } +} + +void FlashforgePrintHostSendDialog::refresh_mapping_card(MappingRow& row) +{ + auto* card = as_ff_map_widget(row.card); + if (card == nullptr) + return; + + const auto* filament = find_filament_by_tool_id(row.tool_id); + card->set_enable_mapping(m_use_material_station); + card->update_popup_slots(m_slots, [this, filament](const FlashforgeMaterialSlot& slot) { + return filament != nullptr && slot_matches_filament(slot, *filament); + }); + + if (card->selected_slot_id() <= 0) { + card->reset_slot(); + return; + } + + const auto* slot = find_slot_by_id(std::to_string(card->selected_slot_id())); + if (slot == nullptr) { + card->reset_slot(); + return; + } + + card->set_slot_selection(slot->slot_id, to_wx_colour(slot->material_color)); +} + +void FlashforgePrintHostSendDialog::sync_mapping_section_visibility() +{ + if (m_mapping_section_sizer == nullptr) + return; + + m_mapping_section_sizer->ShowItems(m_use_material_station && m_supports_material_station); + if (wxSizer* sizer = GetSizer(); sizer != nullptr) { + sizer->Layout(); + sizer->Fit(this); + SetMinSize(GetBestSize()); + } + Layout(); + Fit(); +} + +const Slic3r::FlashforgeMaterialSlot* FlashforgePrintHostSendDialog::find_slot_by_id(const std::string& slot_id_text) const +{ + const auto slot_it = std::find_if(m_slots.begin(), m_slots.end(), [&](const FlashforgeMaterialSlot& slot) { return std::to_string(slot.slot_id) == slot_id_text; }); + return slot_it == m_slots.end() ? nullptr : &(*slot_it); +} + +const FilamentInfo* FlashforgePrintHostSendDialog::find_filament_by_tool_id(int tool_id) const +{ + const auto filament_it = std::find_if(m_project_filaments.begin(), m_project_filaments.end(), [&](const FilamentInfo& filament) { return filament.id == tool_id; }); + return filament_it == m_project_filaments.end() ? nullptr : &(*filament_it); +} + +bool FlashforgePrintHostSendDialog::slot_matches_filament(const Slic3r::FlashforgeMaterialSlot& slot, const FilamentInfo& filament) const +{ + if (!slot.has_filament) + return false; + + const std::string project_material = normalize_material(!filament.type.empty() ? filament.type : filament.get_display_filament_type()); + const std::string slot_material = normalize_material(slot.material_name); + return !project_material.empty() && !slot_material.empty() && project_material == slot_material; +} + +bool FlashforgePrintHostSendDialog::validate_before_close() +{ + if (!m_use_material_station && m_project_filaments.size() > 1) { + show_error(this, _L("This plate uses multiple materials. Enable IFS and assign each tool to a printer slot.")); + return false; + } + + if (!m_use_material_station) + return true; + + for (const auto& row : m_mapping_rows) { + auto* card = as_ff_map_widget(row.card); + if (card == nullptr || !card->is_slot_selected()) { + show_error(this, _L("Each project material must be assigned to an IFS slot before printing.")); + return false; + } + + const auto* slot = find_slot_by_id(std::to_string(card->selected_slot_id())); + const auto* filament = find_filament_by_tool_id(row.tool_id); + if (slot == nullptr || filament == nullptr || !slot->has_filament) { + show_error(this, _L("Each project material must be assigned to a loaded IFS slot before printing.")); + return false; + } + + if (!slot_matches_filament(*slot, *filament)) { + show_error(this, _L("Each project material must match the material loaded in the selected IFS slot.")); + return false; + } + } + + return true; +} + +std::string FlashforgePrintHostSendDialog::normalize_material(const std::string& material) const +{ + std::string normalized = boost::to_upper_copy(material); + normalized.erase(std::remove_if(normalized.begin(), normalized.end(), [](unsigned char ch) { return !std::isalnum(ch); }), normalized.end()); + + if (normalized.empty()) + return {}; + + if (normalized.find("SILK") != std::string::npos) + return "SILK"; + + if (normalized.find("PLA") != std::string::npos && normalized.find("CF") != std::string::npos) + return "PLACF"; + if (normalized.find("PETG") != std::string::npos && normalized.find("CF") != std::string::npos) + return "PETGCF"; + + if (normalized == "PLA" || normalized == "PLA+" || normalized == "PLAPLUS") + return "PLA"; + if (normalized.find("PLA") != std::string::npos) + return "PLA"; + + if (normalized == "ABS" || normalized.find("ABS") != std::string::npos) + return "ABS"; + if (normalized == "ASA" || normalized.find("ASA") != std::string::npos) + return "ABS"; + + if (normalized.find("PETG") != std::string::npos) + return "PETG"; + + if (normalized.find("TPU") != std::string::npos || normalized.find("TPE") != std::string::npos || normalized.find("FLEX") != std::string::npos) + return "TPU"; + + return normalized; +} + +wxColour FlashforgePrintHostSendDialog::to_wx_colour(const std::string& color) const +{ + wxColour wx_color(from_u8(color)); + if (wx_color.IsOk()) + return wx_color; + + std::string normalized = boost::trim_copy(color); + if (boost::istarts_with(normalized, "0x")) + normalized = normalized.substr(2); + if (!normalized.empty() && normalized.front() == '#') + normalized.erase(normalized.begin()); + + if (normalized.size() == 8) { + auto hex_to_byte = [](char hi, char lo) -> int { + auto hex_val = [](char c) -> int { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; + }; + const int h = hex_val(hi); + const int l = hex_val(lo); + return (h < 0 || l < 0) ? -1 : h * 16 + l; + }; + + const int r = hex_to_byte(normalized[0], normalized[1]); + const int g = hex_to_byte(normalized[2], normalized[3]); + const int b = hex_to_byte(normalized[4], normalized[5]); + const int a = hex_to_byte(normalized[6], normalized[7]); + if (r >= 0 && g >= 0 && b >= 0 && a >= 0) + return wxColour(r, g, b, a); + } + + if (normalized.size() == 6) { + wx_color = wxColour("#" + from_u8(normalized)); + if (wx_color.IsOk()) + return wx_color; + } + + return wxColour("#999999"); +} + wxDEFINE_EVENT(EVT_PRINTHOST_PROGRESS, PrintHostQueueDialog::Event); wxDEFINE_EVENT(EVT_PRINTHOST_ERROR, PrintHostQueueDialog::Event); wxDEFINE_EVENT(EVT_PRINTHOST_CANCEL, PrintHostQueueDialog::Event); diff --git a/src/slic3r/GUI/PrintHostDialogs.hpp b/src/slic3r/GUI/PrintHostDialogs.hpp index e648537ccb..cc4670f514 100644 --- a/src/slic3r/GUI/PrintHostDialogs.hpp +++ b/src/slic3r/GUI/PrintHostDialogs.hpp @@ -1,8 +1,8 @@ #ifndef slic3r_PrintHostSendDialog_hpp_ #define slic3r_PrintHostSendDialog_hpp_ -#include #include +#include #include #include @@ -12,12 +12,18 @@ #include "GUI_Utils.hpp" #include "MsgDialog.hpp" #include "../Utils/PrintHost.hpp" +#include "../Utils/Flashforge.hpp" #include "libslic3r/PrintConfig.hpp" +#include "libslic3r/ProjectTask.hpp" class wxButton; class wxTextCtrl; -class wxChoice; class wxComboBox; +class ComboBox; class wxDataViewListCtrl; +class wxFlexGridSizer; +class wxStaticText; +class wxWrapSizer; +class CheckBox; namespace Slic3r { @@ -180,6 +186,66 @@ private: BedType m_BedType; }; +class FlashforgePrintHostSendDialog : public PrintHostSendDialog +{ +public: + FlashforgePrintHostSendDialog(const boost::filesystem::path& path, + PrintHostPostUploadActions post_actions, + const wxArrayString& groups, + const wxArrayString& storage_paths, + const wxArrayString& storage_names, + bool switch_to_device_tab, + const Slic3r::Flashforge* host, + bool supports_material_station, + std::vector slots, + const std::vector& project_filaments); + + virtual void init() override; + virtual void EndModal(int ret) override; + virtual std::map extendedInfo() const override; + +private: + struct MappingRow { + int tool_id {-1}; + wxWindow* card {nullptr}; + }; + + void load_slots(); + bool ensure_slots_loaded(bool force_reload = false); + void rebuild_mapping_rows(); + void auto_assign_mappings(); + void refresh_mapping_card(MappingRow& row); + void sync_mapping_section_visibility(); + const Slic3r::FlashforgeMaterialSlot* find_slot_by_id(const std::string& slot_id_text) const; + const FilamentInfo* find_filament_by_tool_id(int tool_id) const; + bool slot_matches_filament(const Slic3r::FlashforgeMaterialSlot& slot, const FilamentInfo& filament) const; + bool validate_before_close(); + std::string normalize_material(const std::string& material) const; + wxColour to_wx_colour(const std::string& color) const; + +private: + const Slic3r::Flashforge* m_host {nullptr}; + std::vector m_project_filaments; + std::vector m_slots; + std::vector m_mapping_rows; + wxBoxSizer* m_flashforge_options_sizer {nullptr}; + wxBoxSizer* m_mapping_section_sizer {nullptr}; + wxWrapSizer* m_mapping_wrap_sizer {nullptr}; + wxStaticText* m_status_text {nullptr}; + ::CheckBox* m_checkbox_leveling {nullptr}; + ::CheckBox* m_checkbox_timelapse {nullptr}; + ::CheckBox* m_checkbox_ifs {nullptr}; + bool m_leveling_before_print {true}; + bool m_time_lapse_video {false}; + bool m_use_material_station {false}; + bool m_supports_material_station {false}; + bool m_slots_loaded {false}; + + const char* CONFIG_KEY_LEVELING = "flashforge_leveling_before_print"; + const char* CONFIG_KEY_TIMELAPSE = "flashforge_timelapse_video"; + const char* CONFIG_KEY_IFS = "flashforge_use_material_station"; +}; + wxDECLARE_EVENT(EVT_PRINTHOST_PROGRESS, PrintHostQueueDialog::Event); wxDECLARE_EVENT(EVT_PRINTHOST_ERROR, PrintHostQueueDialog::Event); wxDECLARE_EVENT(EVT_PRINTHOST_CANCEL, PrintHostQueueDialog::Event); diff --git a/src/slic3r/Utils/Flashforge.cpp b/src/slic3r/Utils/Flashforge.cpp index 6fe3a83083..88b3eb3f69 100644 --- a/src/slic3r/Utils/Flashforge.cpp +++ b/src/slic3r/Utils/Flashforge.cpp @@ -1,8 +1,13 @@ #include "Flashforge.hpp" #include +#include #include #include #include +#include +#include +#include +#include #include #include #include @@ -19,6 +24,10 @@ #include #include +#include +#include +#include + #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 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& response, const std::string& ip_address, FlashforgeDiscoveredPrinter& printer) +{ + if (response.size() < 0xC4) + return false; + + printer.name = trim_null_terminated_ascii(reinterpret_cast(response.data()), 32); + printer.serial_number = trim_null_terminated_ascii(reinterpret_cast(response.data() + 0x92), 32); + printer.ip_address = ip_address; + return !(printer.name.empty() && printer.serial_number.empty()); +} + +std::vector get_discovery_broadcast_addresses() +{ + std::set 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(bytes[0]) % static_cast(bytes[1]) % static_cast(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(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(); + return true; + } + + if (value.is_boolean()) { + out = value.get() ? 1 : 0; + return true; + } + + if (value.is_string()) { + std::string text = value.get(); + 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(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(); + else if (parsed.contains("msg") && parsed["msg"].is_string()) + message = parsed["msg"].get(); + + 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>("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>("gcode_flavor"); gcode_flavor != nullptr) + m_gcFlavor = gcode_flavor->value; + } +} const char* Flashforge::get_name() const { return "Flashforge"; } +bool Flashforge::discover_printers(std::vector& 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 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(now - start).count() >= timeout_ms) + break; + if (!by_ip.empty() && std::chrono::duration_cast(now - last_reply).count() >= idle_timeout_ms) + break; + + std::vector 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& 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(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; diff --git a/src/slic3r/Utils/Flashforge.hpp b/src/slic3r/Utils/Flashforge.hpp index 82f9092753..ea7acdcb11 100644 --- a/src/slic3r/Utils/Flashforge.hpp +++ b/src/slic3r/Utils/Flashforge.hpp @@ -1,6 +1,7 @@ #ifndef slic3r_FlashForge_hpp_ #define slic3r_FlashForge_hpp_ +#include #include #include #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& slots, bool* supports_material_station, wxString& msg) const; + static bool discover_printers(std::vector& 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;