#include "QidiPrinterAgent.hpp" #include "Http.hpp" #include "IPrinterAgent.hpp" #include "libslic3r/PresetBundle.hpp" #include "slic3r/GUI/GUI_App.hpp" #include "nlohmann/json.hpp" #include #include #include #include #include using json = nlohmann::json; namespace Slic3r { namespace { // Check whether any visible, compatible base preset in the collection has the given filament_id. 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; } // RAII decrement for MoonrakerPrinterAgent::filament_fetch_in_flight — guarantees the // counter drops back down on every exit path (early return or fall-through) inside the // detached fetch thread below, so ~MoonrakerPrinterAgent()'s wait loop can't stall forever. struct InFlightGuard { std::atomic& counter; ~InFlightGuard() { counter.fetch_sub(1, std::memory_order_relaxed); } }; } // anonymous namespace const std::string QidiPrinterAgent_VERSION = "0.0.1"; QidiPrinterAgent::QidiPrinterAgent(std::string log_dir) : MoonrakerPrinterAgent(std::move(log_dir)) { } AgentInfo QidiPrinterAgent::get_agent_info_static() { return AgentInfo{"qidi", "Qidi", QidiPrinterAgent_VERSION, "Qidi printer agent"}; } FilamentSyncMode QidiPrinterAgent::get_filament_sync_mode() const { if (GUI::wxGetApp().app_config->get_bool("use_printer_agents")) return FilamentSyncMode::subscription; return FilamentSyncMode::pull; } bool QidiPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode) { if (sync_mode != get_filament_sync_mode()) return false; // Snapshot only what the fetch needs, rather than reading device_info live from the // background thread below — device_info can be concurrently rewritten by a reconnect // on another thread while this fetch is still in flight. std::string base_url = device_info.base_url; std::string api_key = device_info.api_key; std::string model_id = device_info.model_id; std::string model_name = device_info.model_name; filament_fetch_in_flight.fetch_add(1, std::memory_order_relaxed); std::thread([this, base_url, api_key, model_id, model_name]() { InFlightGuard guard{filament_fetch_in_flight}; std::string error; // 1. Fetch device info and infer series_id std::string series_id; { MoonrakerDeviceInfo info; if (fetch_device_info(base_url, api_key, info, error)) { series_id = infer_series_id(info.model_id, info.dev_name); } } if (series_id.empty()) { // Fall back to the configured Orca model if Moonraker doesn't expose a usable identifier. series_id = infer_series_id(model_id, model_name); } // 2. Fetch filament dictionary QidiFilamentDict dict; if (!fetch_filament_dict(base_url, api_key, dict, error)) { BOOST_LOG_TRIVIAL(warning) << "QidiPrinterAgent::fetch_filament_info: Failed to fetch filament dict: " << error; } // 3. Fetch slot info and build AmsTrayData directly std::vector trays; int box_count = 0; if (!fetch_slot_info(base_url, api_key, dict, series_id, trays, box_count, error)) { BOOST_LOG_TRIVIAL(warning) << "QidiPrinterAgent::fetch_filament_info: Failed to fetch slot info: " << error; return; } // 4. Build the AMS payload build_ams_payload(box_count, box_count * 4 - 1, trays); }).detach(); return true; } bool QidiPrinterAgent::apply_box_mapping(const PrintParams& params) const { // enable_box mirrors task_use_ams: engage the multi-color box only when this // job actually routes filament through it. (See qidi-ams-findings.md §2/§8.3 — // if firmware treats enable_box as "a box exists" rather than "use it this job", // switch this gate to HasAms()/box_count instead.) const int enable = params.task_use_ams ? 1 : 0; if (!send_gcode(device_info.dev_id, "SAVE_VARIABLE VARIABLE=enable_box VALUE=" + std::to_string(enable))) { BOOST_LOG_TRIVIAL(error) << "QidiPrinterAgent::apply_box_mapping: failed to set enable_box"; return false; } // When the box isn't used this job, leave the existing value_t slot // assignments untouched (enable_box=0 is enough to disengage it). if (!enable) return true; if (params.ams_mapping.empty()) { BOOST_LOG_TRIVIAL(warning) << "QidiPrinterAgent::apply_box_mapping: enable_box set but ams_mapping is empty"; return true; } // ams_mapping (v0) is a JSON array indexed by filament/tool; each value is the // physical box slot (-1 = unmapped). Mirror it onto the printer's value_t // variables: SAVE_VARIABLE VARIABLE=value_t VALUE='slot'. auto mapping = nlohmann::json::parse(params.ams_mapping, nullptr, /*allow_exceptions*/ false); if (mapping.is_discarded() || !mapping.is_array()) { BOOST_LOG_TRIVIAL(error) << "QidiPrinterAgent::apply_box_mapping: invalid ams_mapping: " << params.ams_mapping; return false; } for (size_t tool = 0; tool < mapping.size(); ++tool) { if (!mapping[tool].is_number_integer()) continue; const int slot = mapping[tool].get(); if (slot < 0) continue; // unmapped filament — skip const std::string gcode = "SAVE_VARIABLE VARIABLE=value_t" + std::to_string(tool) + " VALUE=\"'slot" + std::to_string(slot) + "'\""; if (!send_gcode(device_info.dev_id, gcode)) { BOOST_LOG_TRIVIAL(error) << "QidiPrinterAgent::apply_box_mapping: failed to set value_t" << tool; return false; } } return true; } int QidiPrinterAgent::start_local_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn) { if (!apply_box_mapping(params)) return BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED; return MoonrakerPrinterAgent::start_local_print(std::move(params), update_fn, cancel_fn); } int QidiPrinterAgent::start_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) { if (!apply_box_mapping(params)) return BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED; return MoonrakerPrinterAgent::start_print(std::move(params), update_fn, cancel_fn, wait_fn); } int QidiPrinterAgent::start_local_print_with_record(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) { if (!apply_box_mapping(params)) return BAMBU_NETWORK_ERR_PRINT_WR_UPLOAD_FTP_FAILED; return MoonrakerPrinterAgent::start_local_print_with_record(std::move(params), update_fn, cancel_fn, wait_fn); } int QidiPrinterAgent::start_sdcard_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn) { if (!apply_box_mapping(params)) return BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED; return MoonrakerPrinterAgent::start_sdcard_print(std::move(params), update_fn, cancel_fn); } bool QidiPrinterAgent::fetch_slot_info(const std::string& base_url, const std::string& api_key, const QidiFilamentDict& dict, const std::string& series_id, std::vector& trays, int& box_count, std::string& error) { std::string url = join_url(base_url, "/printer/objects/query?save_variables=variables"); for (int i = 0; i < 16; ++i) { url += "&box_stepper%20slot" + std::to_string(i) + "=runout_button"; } std::string response_body; bool success = false; std::string http_error; auto http = Http::get(url); if (!api_key.empty()) { http.header("X-Api-Key", api_key); } http.timeout_connect(5) .timeout_max(10) .on_complete([&](std::string body, unsigned status) { if (status == 200) { response_body = body; success = true; } else { http_error = "HTTP error: " + std::to_string(status); } }) .on_error([&](std::string body, std::string err, unsigned status) { http_error = err; if (status > 0) { http_error += " (HTTP " + std::to_string(status) + ")"; } }) .perform_sync(); if (!success) { error = http_error.empty() ? "Connection failed" : http_error; return false; } nlohmann::json status; nlohmann::json variables; if (!parse_slot_response(response_body, status, variables, error)) return false; box_count = variables.value("box_count", 1); if (box_count < 0) { box_count = 0; } const int max_slots = box_count * 4; trays.clear(); trays.reserve(max_slots); // Lambda to build setting_id from slot data auto build_setting_id = [&](int filament_type_idx, int vendor_type, const std::string& tray_type) { const int vendor = (vendor_type == 1) ? 1 : 0; if (is_numeric(series_id) && filament_type_idx > 0) { return "QD_" + series_id + "_" + std::to_string(vendor) + "_" + std::to_string(filament_type_idx); } return map_filament_type_to_setting_id(tray_type); }; for (int i = 0; i < max_slots; ++i) { AmsTrayData tray; tray.slot_index = i; // Read slot variables const int color_index = variables.value("color_slot" + std::to_string(i), 1); const int filament_type = variables.value("filament_slot" + std::to_string(i), 1); const int vendor_type = variables.value("vendor_slot" + std::to_string(i), 0); // Check filament presence via runout sensor std::string box_stepper_key = "box_stepper slot" + std::to_string(i); tray.has_filament = false; if (status.contains(box_stepper_key)) { auto& box_stepper = status[box_stepper_key]; if (box_stepper.contains("runout_button") && !box_stepper["runout_button"].is_null()) { int runout_button = box_stepper["runout_button"].template get(); tray.has_filament = (runout_button == 0); } } if (tray.has_filament) { // Look up filament type name from dictionary std::string filament_name = "PLA"; auto filament_it = dict.filaments.find(filament_type); if (filament_it != dict.filaments.end()) { filament_name = filament_it->second; } tray.tray_type = normalize_filament_type(filament_name); // Try Qidi-specific setting ID first; fall back to visible preset by type std::string setting_id = build_setting_id(filament_type, vendor_type, tray.tray_type); auto* bundle = GUI::wxGetApp().preset_bundle; if (!bundle) { tray.tray_info_idx = setting_id; } else if (!setting_id.empty() && has_visible_base_preset(bundle->filaments, setting_id)) { tray.tray_info_idx = setting_id; } else { tray.tray_info_idx = bundle->filaments.filament_id_by_type(tray.tray_type); } // Look up color from dictionary auto color_it = dict.colors.find(color_index); if (color_it != dict.colors.end()) { tray.tray_color = color_it->second; } else { tray.tray_color = "FFFFFFFF"; } } trays.push_back(tray); } return true; } bool QidiPrinterAgent::parse_slot_response(const std::string& response_body, nlohmann::json& status, nlohmann::json& variables, std::string& error) { auto json = nlohmann::json::parse(response_body, nullptr, false, true); if (json.is_discarded()) { error = "Invalid JSON response"; return false; } if (!json.is_object() || !json.contains("result") || !json["result"].is_object() || !json["result"].contains("status") || !json["result"]["status"].is_object() || !json["result"]["status"].contains("save_variables") || !json["result"]["status"]["save_variables"].is_object() || !json["result"]["status"]["save_variables"].contains("variables") || !json["result"]["status"]["save_variables"]["variables"].is_object()) { // why: Qidi firmware may send null here, but json::value() throws for it. error = "Unexpected JSON structure: save_variables.variables must be an object"; return false; } status = json["result"]["status"]; variables = status["save_variables"]["variables"]; return true; } bool QidiPrinterAgent::fetch_filament_dict(const std::string& base_url, const std::string& api_key, QidiFilamentDict& dict, std::string& error) const { std::string url = join_url(base_url, "/server/files/config/officiall_filas_list.cfg"); std::string response_body; bool success = false; std::string http_error; auto http = Http::get(url); if (!api_key.empty()) { http.header("X-Api-Key", api_key); } http.timeout_connect(5) .timeout_max(10) .on_complete([&](std::string body, unsigned status) { if (status == 200) { response_body = body; success = true; } else { http_error = "HTTP error: " + std::to_string(status); } }) .on_error([&](std::string body, std::string err, unsigned status) { http_error = err; if (status > 0) { http_error += " (HTTP " + std::to_string(status) + ")"; } }) .perform_sync(); if (!success) { error = http_error.empty() ? "Connection failed" : http_error; return false; } dict.colors.clear(); dict.filaments.clear(); parse_ini_section(response_body, "colordict", dict.colors); parse_filament_sections(response_body, dict.filaments); return !dict.colors.empty(); } void QidiPrinterAgent::parse_ini_section(const std::string& content, const std::string& section_name, std::map& result) { std::istringstream stream(content); std::string line; bool in_section = false; std::string section_header = "[" + section_name + "]"; while (std::getline(stream, line)) { boost::trim(line); if (!line.empty() && line[0] == '[') { in_section = (line == section_header); continue; } if (line.empty() || line[0] == '#' || line[0] == ';') { continue; } if (in_section) { auto pos = line.find('='); if (pos != std::string::npos) { std::string key = line.substr(0, pos); std::string value = line.substr(pos + 1); boost::trim(key); boost::trim(value); try { int index = std::stoi(key); result[index] = value; } catch (...) {} } } } } void QidiPrinterAgent::parse_filament_sections(const std::string& content, std::map& result) { std::istringstream stream(content); std::string line; int current_fila_index = -1; while (std::getline(stream, line)) { boost::trim(line); if (!line.empty() && line[0] == '[') { current_fila_index = -1; if (line.size() > 5 && line.substr(0, 5) == "[fila" && line.back() == ']') { std::string num_str = line.substr(5, line.size() - 6); try { current_fila_index = std::stoi(num_str); } catch (...) { current_fila_index = -1; } } continue; } if (line.empty() || line[0] == '#' || line[0] == ';') { continue; } if (current_fila_index > 0) { auto pos = line.find('='); if (pos != std::string::npos) { std::string key = line.substr(0, pos); std::string value = line.substr(pos + 1); boost::trim(key); boost::trim(value); if (key == "filament") { result[current_fila_index] = value; } } } } } std::string QidiPrinterAgent::map_filament_type_to_setting_id(const std::string& filament_type) { const std::string upper = trim_and_upper(filament_type); if (upper == "PLA") { return "QD_1_0_1"; } if (upper == "ABS") { return "QD_1_0_11"; } if (upper == "PETG") { return "QD_1_0_41"; } if (upper == "TPU") { return "QD_1_0_50"; } return ""; } std::string QidiPrinterAgent::normalize_model_key(std::string value) { boost::algorithm::to_lower(value); std::string normalized; normalized.reserve(value.size()); for (unsigned char c : value) { if (std::isalnum(c)) { normalized.push_back(static_cast(c)); } } return normalized; } std::string QidiPrinterAgent::infer_series_id(const std::string& model_id, const std::string& dev_name) { std::string source = model_id.empty() ? dev_name : model_id; boost::trim(source); if (source.empty()) { return ""; } if (is_numeric(source)) { return source; } const std::string key = normalize_model_key(source); if (key.find("q2") != std::string::npos) { return "1"; } if (key.find("xmax") != std::string::npos && key.find("4") != std::string::npos) { return "3"; } if ((key.find("xplus") != std::string::npos || key.find("plus") != std::string::npos) && key.find("4") != std::string::npos) { return "0"; } return ""; } std::string QidiPrinterAgent::normalize_filament_type(const std::string& filament_type) { const std::string upper = trim_and_upper(filament_type); if (upper.find("PLA") != std::string::npos) return "PLA"; if (upper.find("ABS") != std::string::npos) return "ABS"; if (upper.find("PETG") != std::string::npos) return "PETG"; if (upper.find("TPU") != std::string::npos) return "TPU"; if (upper.find("ASA") != std::string::npos) return "ASA"; if (upper.find("PA") != std::string::npos || upper.find("NYLON") != std::string::npos) return "PA"; if (upper.find("PC") != std::string::npos) return "PC"; if (upper.find("PVA") != std::string::npos) return "PVA"; return upper; } } // namespace Slic3r