#include "MoonrakerPrinterAgent.hpp" #include "Http.hpp" #include "IPrinterAgent.hpp" #include "NetworkAgentFactory.hpp" #include "bambu_networking.hpp" #include "libslic3r/Preset.hpp" #include "libslic3r/PresetBundle.hpp" #include "slic3r/GUI/GUI_App.hpp" #include "slic3r/GUI/I18N.hpp" #include "slic3r/GUI/DeviceManager.hpp" #include "slic3r/GUI/DeviceCore/DevFilaSystem.h" #include "slic3r/GUI/DeviceCore/DevManager.h" #include "slic3r/GUI/DeviceCore/DevDefs.h" #include "../GUI/DeviceCore/DevStorage.h" #include "../GUI/DeviceCore/DevFirmware.h" #include "nlohmann/json.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { namespace beast = boost::beast; namespace http = beast::http; namespace websocket = beast::websocket; namespace net = boost::asio; using tcp = net::ip::tcp; // Decimal value of a string that is nothing but digits, or -1 when it is empty or not a number. int decimal_number(const std::string& digits) { if (digits.empty() || !std::all_of(digits.begin(), digits.end(), [](unsigned char c) { return std::isdigit(c) != 0; })) return -1; try { return std::stoi(digits); } catch (...) { return -1; } } // Lane database keys address a lane either as "T" (0-based tool index) or as "lane" // (1-based, the AFC/Happy Hare style). Returns -1 when the key carries no usable lane number. int lane_index_from_key(const std::string& lane_key) { if (lane_key.size() > 1 && (lane_key[0] == 'T' || lane_key[0] == 't')) return decimal_number(lane_key.substr(1)); if (lane_key.size() > 4 && lane_key.compare(0, 4, "lane") == 0) { const int lane_number = decimal_number(lane_key.substr(4)); return lane_number > 0 ? lane_number - 1 : -1; } return -1; } // AFC names toolheads "extruder", "extruder1", ... and derives the toolhead index from the name // itself (AFC_lane.lane_extruder_index): no suffix is 0, otherwise the suffix is the index. int extruder_index_from_name(const std::string& toolhead) { if (toolhead == "extruder") return 0; if (toolhead.size() > 8 && toolhead.compare(0, 8, "extruder") == 0) return decimal_number(toolhead.substr(8)); return -1; } struct WsEndpoint { std::string host; std::string port; std::string target; bool secure = false; }; bool parse_ws_endpoint(const std::string& base_url, WsEndpoint& endpoint) { if (base_url.empty()) { return false; } std::string url = base_url; if (boost::istarts_with(url, "https://")) { endpoint.secure = true; url = url.substr(8); } else if (boost::istarts_with(url, "http://")) { url = url.substr(7); } auto slash = url.find('/'); if (slash != std::string::npos) { url = url.substr(0, slash); } if (url.empty()) { return false; } endpoint.host = url; endpoint.port = endpoint.secure ? "443" : "80"; if (auto colon = url.rfind(':'); colon != std::string::npos && url.find(']') == std::string::npos) { endpoint.host = url.substr(0, colon); endpoint.port = url.substr(colon + 1); } endpoint.target = "/websocket"; return !endpoint.host.empty() && !endpoint.port.empty(); } std::string map_moonraker_state(std::string state) { boost::algorithm::to_lower(state); if (state == "printing") { return "RUNNING"; } if (state == "paused") { return "PAUSE"; } if (state == "complete") { return "FINISH"; } if (state == "error" || state == "cancelled") { return "FAILED"; } return "IDLE"; } } // namespace namespace Slic3r { const std::string MoonrakerPrinterAgent_VERSION = "1.0.0"; MoonrakerPrinterAgent::MoonrakerPrinterAgent(std::string log_dir) : m_cloud_agent(nullptr) { (void) log_dir; } MoonrakerPrinterAgent::~MoonrakerPrinterAgent() { { std::lock_guard lock(connect_mutex); device_info = MoonrakerDeviceInfo{}; ++connect_generation; } if (connect_thread.joinable()) { connect_thread.join(); } stop_status_stream(); } AgentInfo MoonrakerPrinterAgent::get_agent_info_static() { return AgentInfo{MOONRAKER_PRINTER_AGENT_ID, "Moonraker", MoonrakerPrinterAgent_VERSION, "Klipper/Moonraker printer agent"}; } void MoonrakerPrinterAgent::set_cloud_agent(std::shared_ptr cloud) { std::lock_guard lock(state_mutex); m_cloud_agent = cloud; } int MoonrakerPrinterAgent::send_message(std::string dev_id, std::string json_str, int qos, int flag) { (void) qos; (void) flag; return handle_request(dev_id, json_str); } int MoonrakerPrinterAgent::send_message_to_printer(std::string dev_id, std::string json_str, int qos, int flag) { (void) qos; (void) flag; return handle_request(dev_id, json_str); } int MoonrakerPrinterAgent::connect_printer(const PrinterConnectionParams& params) { if (params.dev_id.empty() || params.host.empty()) { BOOST_LOG_TRIVIAL(error) << "MoonrakerPrinterAgent: connect_printer missing dev_id or dev_ip"; return BAMBU_NETWORK_ERR_INVALID_HANDLE; } std::string base_url; std::string api_key; uint64_t gen; { std::lock_guard lock(connect_mutex); init_device_info(params.dev_id, params.host, params.username, params.password, params.use_ssl, params.port); gen = ++connect_generation; base_url = device_info.base_url; api_key = device_info.api_key; if (connect_thread.joinable()) { connect_thread.detach(); } } // Stop existing status stream and clear state stop_status_stream(); { std::lock_guard lock(payload_mutex); status_cache = nlohmann::json::object(); } ws_last_emit_ms.store(0); ws_last_dispatch_ms.store(0); last_print_state.clear(); // Launch connection in background thread (capture by value to avoid data races) { std::lock_guard lock(connect_mutex); connect_thread = std::thread([this, dev_id = params.dev_id, base_url, api_key, gen]() { perform_connection_async(dev_id, base_url, api_key, gen); }); } return BAMBU_NETWORK_SUCCESS; } int MoonrakerPrinterAgent::disconnect_printer() { { std::lock_guard lock(connect_mutex); device_info = MoonrakerDeviceInfo{}; ++connect_generation; // Invalidate any in-flight connection if (connect_thread.joinable()) { connect_thread.detach(); } } stop_status_stream(); return BAMBU_NETWORK_SUCCESS; } bool MoonrakerPrinterAgent::start_discovery(bool start, bool sending) { (void) sending; if (start) { announce_printhost_device(); } return true; } int MoonrakerPrinterAgent::bind_detect(std::string dev_ip, std::string sec_link, detectResult& detect) { (void) sec_link; detect.dev_id = device_info.dev_id.empty() ? dev_ip : device_info.dev_id; detect.model_id = device_info.model_id.empty() ? device_info.model_name : device_info.model_id; // Prefer fetched hostname, then preset model name, then generic fallback detect.dev_name = device_info.dev_name.empty() ? dev_ip : device_info.dev_name; detect.model_id = device_info.model_id; detect.version = device_info.version; detect.connect_type = "lan"; detect.bind_state = "free"; return BAMBU_NETWORK_SUCCESS; } int MoonrakerPrinterAgent::set_server_callback(OnServerErrFn fn) { std::lock_guard lock(state_mutex); on_server_err_fn = fn; return BAMBU_NETWORK_SUCCESS; } std::string MoonrakerPrinterAgent::get_user_selected_machine() { std::lock_guard lock(state_mutex); return selected_machine; } int MoonrakerPrinterAgent::set_user_selected_machine(std::string dev_id) { std::lock_guard lock(state_mutex); selected_machine = dev_id; return BAMBU_NETWORK_SUCCESS; } int MoonrakerPrinterAgent::start_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) { (void) params; (void) update_fn; (void) cancel_fn; (void) wait_fn; return BAMBU_NETWORK_SUCCESS; } int MoonrakerPrinterAgent::start_local_print_with_record(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) { (void) params; (void) update_fn; (void) cancel_fn; (void) wait_fn; return BAMBU_NETWORK_SUCCESS; } int MoonrakerPrinterAgent::start_send_gcode_to_sdcard(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) { (void) wait_fn; if (update_fn) update_fn(PrintingStageCreate, 0, _u8L("Preparing...")); std::string filename = params.filename; if (filename.empty()) { filename = params.task_name; } if (!boost::iends_with(filename, ".gcode")) { filename += ".gcode"; } // Sanitize filename to prevent path traversal attacks std::string safe_filename = sanitize_filename(filename); // Upload only, don't start print if (!upload_gcode(params.filename, safe_filename, device_info.base_url, device_info.api_key, update_fn, cancel_fn)) { return BAMBU_NETWORK_ERR_PRINT_SG_UPLOAD_FTP_FAILED; } if (update_fn) update_fn(PrintingStageFinished, 100, _u8L("File uploaded")); return BAMBU_NETWORK_SUCCESS; } int MoonrakerPrinterAgent::start_local_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn) { if (update_fn) update_fn(PrintingStageCreate, 0, _u8L("Preparing...")); // Check cancellation if (cancel_fn && cancel_fn()) { return BAMBU_NETWORK_ERR_CANCELED; } // Determine the G-code file to upload // params.filename may be .3mf, params.dst_file contains actual G-code std::string gcode_path = params.filename; if (!params.dst_file.empty()) { gcode_path = params.dst_file; } // Check if file exists and has .gcode extension namespace fs = boost::filesystem; fs::path source_path(gcode_path); if (!fs::exists(source_path)) { BOOST_LOG_TRIVIAL(error) << "MoonrakerPrinterAgent: G-code file does not exist: " << gcode_path; return BAMBU_NETWORK_ERR_FILE_NOT_EXIST; } // Extract filename for upload (relative to gcodes root) std::string upload_filename = source_path.filename().string(); if (!boost::iends_with(upload_filename, ".gcode")) { upload_filename += ".gcode"; } // Sanitize filename to prevent path traversal attacks (extra safety) upload_filename = sanitize_filename(upload_filename); // Upload file if (update_fn) update_fn(PrintingStageUpload, 0, _u8L("Uploading G-code...")); if (!upload_gcode(gcode_path, upload_filename, device_info.base_url, device_info.api_key, update_fn, cancel_fn)) { return BAMBU_NETWORK_ERR_PRINT_LP_UPLOAD_FTP_FAILED; } // Check cancellation if (cancel_fn && cancel_fn()) { return BAMBU_NETWORK_ERR_CANCELED; } // Start print via gcode script (simpler than JSON-RPC) if (update_fn) update_fn(PrintingStageSending, 0, _u8L("Starting print...")); std::string gcode = "SDCARD_PRINT_FILE FILENAME=" + upload_filename; if (!send_gcode_sync(device_info.dev_id, gcode)) { return BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED; } if (update_fn) update_fn(PrintingStageFinished, 100, _u8L("Print started")); return BAMBU_NETWORK_SUCCESS; } int MoonrakerPrinterAgent::start_sdcard_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn) { (void) params; (void) update_fn; (void) cancel_fn; return BAMBU_NETWORK_SUCCESS; } int MoonrakerPrinterAgent::set_on_ssdp_msg_fn(OnMsgArrivedFn fn) { { std::lock_guard lock(state_mutex); on_ssdp_msg_fn = fn; } return BAMBU_NETWORK_SUCCESS; } int MoonrakerPrinterAgent::set_on_printer_connected_fn(OnPrinterConnectedFn fn) { std::lock_guard lock(state_mutex); on_printer_connected_fn = fn; return BAMBU_NETWORK_SUCCESS; } int MoonrakerPrinterAgent::set_on_subscribe_failure_fn(GetSubscribeFailureFn fn) { std::lock_guard lock(state_mutex); on_subscribe_failure_fn = fn; return BAMBU_NETWORK_SUCCESS; } int MoonrakerPrinterAgent::set_on_message_fn(OnMessageFn fn) { std::lock_guard lock(state_mutex); on_message_fn = fn; return BAMBU_NETWORK_SUCCESS; } int MoonrakerPrinterAgent::set_on_user_message_fn(OnMessageFn fn) { std::lock_guard lock(state_mutex); on_user_message_fn = fn; return BAMBU_NETWORK_SUCCESS; } int MoonrakerPrinterAgent::set_on_local_connect_fn(OnLocalConnectedFn fn) { std::lock_guard lock(state_mutex); on_local_connect_fn = fn; return BAMBU_NETWORK_SUCCESS; } int MoonrakerPrinterAgent::set_on_local_message_fn(OnMessageFn fn) { std::lock_guard lock(state_mutex); on_local_message_fn = fn; return BAMBU_NETWORK_SUCCESS; } int MoonrakerPrinterAgent::set_queue_on_main_fn(QueueOnMainFn fn) { std::lock_guard lock(state_mutex); queue_on_main_fn = fn; return BAMBU_NETWORK_SUCCESS; } void MoonrakerPrinterAgent::build_ams_payload(int ams_count, int max_lane_index, const std::vector& trays) { // Build BBL-format JSON for DevFilaSystemParser::ParseV1_0 nlohmann::json ams_array = nlohmann::json::array(); // Calculate ams_exist_bits and tray_exist_bits unsigned long ams_exist_bits = 0; unsigned long tray_exist_bits = 0; for (int ams_id = 0; ams_id < ams_count; ++ams_id) { if (ams_id < 32) { ams_exist_bits |= (1UL << ams_id); } nlohmann::json tray_array = nlohmann::json::array(); int max_slot_in_this_ams = std::min(3, max_lane_index - ams_id * 4); for (int slot_id = 0; slot_id <= max_slot_in_this_ams; ++slot_id) { int slot_index = ams_id * 4 + slot_id; // Find tray with matching slot_index const AmsTrayData* tray = nullptr; for (const auto& t : trays) { if (t.slot_index == slot_index) { tray = &t; break; } } if (tray && tray->has_filament && slot_index < 32) { tray_exist_bits |= (1UL << slot_index); } static const AmsTrayData empty_tray; tray_array.push_back(make_ams_tray_json(std::to_string(slot_id), tray ? *tray : empty_tray)); } nlohmann::json ams_unit = nlohmann::json::object(); ams_unit["id"] = std::to_string(ams_id); // Generic unit: type 1 (AMS) maps to AMSModel::GENERIC_AMS, which drives the // variable-lane rendering. This is not a real Bambu AMS-Lite. ams_unit["info"] = "0001"; ams_unit["tray"] = tray_array; ams_array.push_back(ams_unit); } BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::build_ams_payload: Parsed " << trays.size() << " trays"; submit_ams_payload(ams_array, ams_exist_bits, tray_exist_bits); } void MoonrakerPrinterAgent::build_ams_payload_grouped(const std::vector& trays) { // One AMS box per extruder, best effort. Lane-data changers (AFC, recent Happy Hare) report an // extruder per lane but no physical box layout, so a box here is a logical feed group, not a // discovered unit. Lanes without an extruder fall back to the main extruder. std::map> boxes; for (const auto& tray : trays) { boxes[tray.extruder_id].push_back(&tray); } nlohmann::json ams_array = nlohmann::json::array(); unsigned long ams_exist_bits = 0; unsigned long tray_exist_bits = 0; int ams_id = 0; // Cumulative lane index across all boxes, in box then slot order. A lane maps to exactly one // extruder, so this is the lane's identity; the parser derives the same index for generic units. int lane_index = 0; for (auto& [extruder_id, lanes] : boxes) { if (ams_id < 32) { ams_exist_bits |= (1UL << ams_id); } // Keep a deterministic slot order within the box. std::stable_sort(lanes.begin(), lanes.end(), [](const AmsTrayData* a, const AmsTrayData* b) { return a->slot_index < b->slot_index; }); nlohmann::json tray_array = nlohmann::json::array(); for (size_t slot = 0; slot < lanes.size(); ++slot) { const AmsTrayData& tray = *lanes[slot]; // A logical feed group is not a four-slot Bambu AMS: the bit is the lane's cumulative // index, not ams_id*4+slot. DevFilaSystemParser::ParseV1_0 reads the same index. const int bit = lane_index++; if (tray.has_filament && bit < 32) { tray_exist_bits |= (1UL << bit); } tray_array.push_back(make_ams_tray_json(std::to_string(slot), tray)); } nlohmann::json ams_unit = nlohmann::json::object(); ams_unit["id"] = std::to_string(ams_id); // info: bits 0-3 = unit type (1 = AMS, i.e. AMSModel::GENERIC_AMS), bits 8-11 = bound // extruder id. A logical feed group is a generic unit, not a Bambu AMS-Lite. const int info_val = 1 | ((extruder_id & 0x0F) << 8); const char* hex_digits = "0123456789ABCDEF"; std::string info = "0000"; info[3] = hex_digits[info_val & 0x0F]; info[2] = hex_digits[(info_val >> 4) & 0x0F]; info[1] = hex_digits[(info_val >> 8) & 0x0F]; info[0] = hex_digits[(info_val >> 12) & 0x0F]; ams_unit["info"] = info; ams_unit["tray"] = tray_array; ams_array.push_back(ams_unit); ++ams_id; } size_t has_filament_count = 0; for (const auto& tray : trays) { if (tray.has_filament) ++has_filament_count; } std::ostringstream box_summary; bool first_box = true; for (const auto& [extruder_id, lanes] : boxes) { if (!first_box) box_summary << ", "; box_summary << "ext" << extruder_id << ":" << lanes.size(); first_box = false; } BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::build_ams_payload_grouped: input trays " << trays.size() << " extruders " << boxes.size() << " has_filament " << has_filament_count << " boxes [" << box_summary.str() << "]"; submit_ams_payload(ams_array, ams_exist_bits, tray_exist_bits); } nlohmann::json MoonrakerPrinterAgent::make_ams_tray_json(const std::string& tray_id, const AmsTrayData& tray) { nlohmann::json tray_json = nlohmann::json::object(); tray_json["id"] = tray_id; tray_json["tag_uid"] = "0000000000000000"; if (tray.has_filament) { tray_json["tray_info_idx"] = tray.tray_info_idx; tray_json["tray_type"] = tray.tray_type; tray_json["tray_color"] = normalize_color_value(tray.tray_color); // Add temperature data if provided if (tray.bed_temp > 0) { tray_json["bed_temp"] = std::to_string(tray.bed_temp); } if (tray.nozzle_temp > 0) { tray_json["nozzle_temp_max"] = std::to_string(tray.nozzle_temp); } } else { tray_json["tray_info_idx"] = ""; tray_json["tray_type"] = ""; tray_json["tray_color"] = "00000000"; tray_json["tray_slot_placeholder"] = "1"; } return tray_json; } void MoonrakerPrinterAgent::submit_ams_payload(const nlohmann::json& ams_array, unsigned long ams_exist_bits, unsigned long tray_exist_bits) { // Look up MachineObject via DeviceManager auto* dev_manager = GUI::wxGetApp().getDeviceManager(); if (!dev_manager) { return; } MachineObject* obj = dev_manager->get_my_machine(device_info.dev_id); if (!obj) { return; } // Format as hex strings (matching BBL protocol) std::ostringstream ams_exist_ss; ams_exist_ss << std::hex << std::uppercase << ams_exist_bits; std::ostringstream tray_exist_ss; tray_exist_ss << std::hex << std::uppercase << tray_exist_bits; nlohmann::json ams_json = nlohmann::json::object(); ams_json["ams"] = ams_array; ams_json["ams_exist_bits"] = ams_exist_ss.str(); ams_json["tray_exist_bits"] = tray_exist_ss.str(); // Wrap in the expected structure for ParseV1_0 nlohmann::json print_json = nlohmann::json::object(); print_json["ams"] = ams_json; // Diagnostic: log the AMS-box boundary input (box count and per-box tray counts) so what the // parser produces can be compared against what the agent actually submitted. std::ostringstream box_trays; for (size_t i = 0; i < ams_array.size(); ++i) { if (i > 0) box_trays << ", "; box_trays << "box" << ams_array[i].value("id", std::string()) << ":" << ((ams_array[i].contains("tray") && ams_array[i]["tray"].is_array()) ? ams_array[i]["tray"].size() : 0); } BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::submit_ams_payload: boundary boxes " << ams_array.size() << " trays [" << box_trays.str() << "]"; // Call the parser to populate DevFilaSystem DevFilaSystemParser::ParseV1_0(print_json, obj, obj->GetFilaSystem().get(), false); // ParseV1_0 handles only the AMS boxes, so grow the external-spool slots directly here too: // pull mode bypasses MachineObject::parse_json(), the only place the push-status vir_slot is // applied. Each Moonraker extruder gets one empty native virtual tray (id = 255 - extruder_id), // kept at its id-derived index so a non-contiguous extruder set does not shift slots. Slots that // already carry reported spool metadata are left untouched (only missing slots are added). const auto extruder_status = collect_extruder_status(); for (const auto& entry : extruder_status) { const int extruder_id = entry.first; if (extruder_id < 0 || extruder_id > VIRTUAL_TRAY_MAIN_ID) { continue; } // Address the slot by its virtual tray id (VIRTUAL_TRAY_MAIN_ID - extruder_id), which places // it at vector index extruder_id. Append-only, so a reported spool and the extruder // association survive refreshes. grow_virtual_tray_slots(obj->vt_slot, static_cast(VIRTUAL_TRAY_MAIN_ID - extruder_id)); auto& tray = obj->vt_slot[extruder_id]; tray.is_slot_placeholder = !tray.is_exists && tray.setting_id.empty() && tray.m_fila_type.empty(); } if (!extruder_status.empty()) { // A native virtual tray implies external-spool support, matching parse_vt_tray(). obj->ams_support_virtual_tray = true; } // Set printer_type so update_sync_status() can match it against the preset's printer type. // Without this, the comparison fails and all sync badges are cleared. obj->printer_type = device_info.model_id; // Set push counters so is_info_ready() returns true for pull-mode agents. if (obj->m_push_count == 0) { obj->m_push_count = 1; } if (obj->m_full_msg_count == 0) { obj->m_full_msg_count = 1; } obj->last_push_time = std::chrono::system_clock::now(); // Set storage state - Moonraker printers use virtual_sdcard, storage is always available. // This is required for SelectMachineDialog to allow printing (otherwise it blocks with "No SD card"). obj->GetStorage()->set_sdcard_state(DevStorage::HAS_SDCARD_NORMAL); // Populate module_vers so is_info_ready() passes the version check. // Moonraker printers don't have BBL-style version info, but we need a non-empty map. if (obj->module_vers.empty()) { DevFirmwareVersionInfo ota_info; ota_info.name = "ota"; ota_info.sw_ver = "1.0.0"; // Placeholder version for Moonraker printers obj->module_vers.emplace("ota", ota_info); } } bool MoonrakerPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode /*sync_mode*/) { std::vector trays; int max_lane_index = 0; // Try Moonraker filament data (more generic, supports any filament changer // software that reports lane data to Moonraker like AFC and recent Happy // Hare as of Feb 15, 2026) if (fetch_moonraker_filament_data(trays, max_lane_index)) { BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::fetch_filament_info: Detected Moonraker filament system with " << (max_lane_index + 1) << " lanes"; build_ams_payload_grouped(trays); return true; } // Fall back to the Happy Hare `mmu` object for HH builds that do not publish lane_data if (fetch_hh_filament_info(trays, max_lane_index)) { BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::fetch_filament_info: Detected Happy Hare MMU with " << (max_lane_index + 1) << " gates"; build_ams_payload_grouped(trays); return true; } // No MMU detected - this is normal for printers without MMU, not an error BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::fetch_filament_info: No MMU system detected (neither HH nor Moonraker)"; return false; } std::string MoonrakerPrinterAgent::trim_and_upper(const std::string& input) { std::string result = input; boost::trim(result); std::transform(result.begin(), result.end(), result.begin(), [](unsigned char c) { return static_cast(std::toupper(c)); }); return result; } std::string MoonrakerPrinterAgent::map_filament_type_to_generic_id(const std::string& filament_type) { const std::string upper = trim_and_upper(filament_type); // Normalize reported material names (trimmed, uppercased) to an OrcaFilamentLibrary // generic family. The family's filament_id is resolved from the loaded system presets // below rather than hardcoded, so profile id re-mints never require touching this table. // scripts/test_moonraker_lane_data.py parses this initializer; keep the {"A", "B"} format. static const std::map type_to_ofl_family = { // PLA variants {"PLA", "PLA"}, {"PLA-CF", "PLA-CF"}, {"PLA SILK", "PLA Silk"}, {"PLA-SILK", "PLA Silk"}, {"PLA HIGH SPEED", "PLA High Speed"}, {"PLA-HS", "PLA High Speed"}, {"PLA HS", "PLA High Speed"}, // ABS/ASA variants {"ABS", "ABS"}, {"ASA", "ASA"}, // PETG/PET variants {"PETG", "PETG"}, {"PET", "PETG"}, {"PCTG", "PCTG"}, // PA/Nylon variants {"PA", "PA"}, {"NYLON", "PA"}, {"PA-CF", "PA-CF"}, {"PPA", "PPA-CF"}, {"PPA-CF", "PPA-CF"}, {"PPA-GF", "PPA-GF"}, // PC variants {"PC", "PC"}, // PP/PE variants {"PE", "PE"}, {"PP", "PP"}, // Support materials {"PVA", "PVA"}, {"HIPS", "HIPS"}, {"BVOH", "BVOH"}, // TPU variants {"TPU", "TPU"}, // Other materials {"EVA", "EVA"}, {"PHA", "PHA"}, {"COPE", "CoPE"}, {"SBS", "SBS"}, }; auto it = type_to_ofl_family.find(upper); if (it == type_to_ofl_family.end()) return UNKNOWN_FILAMENT_ID; if (auto* bundle = GUI::wxGetApp().preset_bundle) { const Preset* preset = bundle->filaments.find_preset("Generic " + it->second + " @System"); if (preset != nullptr && preset->is_system && !preset->filament_id.empty()) return preset->filament_id; } // Unknown material, or no loaded preset data to resolve against return UNKNOWN_FILAMENT_ID; } // JSON helper methods - null-safe accessors std::string MoonrakerPrinterAgent::safe_json_string(const nlohmann::json& obj, const char* key) { auto it = obj.find(key); if (it != obj.end() && it->is_string()) return it->get(); return ""; } int MoonrakerPrinterAgent::safe_json_int(const nlohmann::json& obj, const char* key) { auto it = obj.find(key); if (it != obj.end() && it->is_number()) return it->get(); return 0; } int MoonrakerPrinterAgent::safe_json_int_flexible(const nlohmann::json& obj, const char* key, int default_value) { auto it = obj.find(key); if (it == obj.end()) return default_value; if (it->is_number_integer()) return it->get(); if (it->is_string()) { const std::string s = it->get(); if (s.empty()) return default_value; try { std::size_t consumed = 0; const int value = std::stoi(s, &consumed); if (consumed != s.size()) // reject partial parses such as "2oops" return default_value; return value; } catch (...) { return default_value; } } return default_value; } std::string MoonrakerPrinterAgent::safe_array_string(const nlohmann::json& arr, int idx) { if (arr.is_array() && idx >= 0 && idx < static_cast(arr.size()) && arr[idx].is_string()) return arr[idx].get(); return ""; } int MoonrakerPrinterAgent::safe_array_int(const nlohmann::json& arr, int idx) { if (arr.is_array() && idx >= 0 && idx < static_cast(arr.size()) && arr[idx].is_number()) return arr[idx].get(); return 0; } std::string MoonrakerPrinterAgent::normalize_color_value(const std::string& color) { std::string value = color; boost::trim(value); // Remove 0x or 0X prefix if present if (value.size() >= 2 && (value.rfind("0x", 0) == 0 || value.rfind("0X", 0) == 0)) { value = value.substr(2); } // Remove # prefix if present if (!value.empty() && value[0] == '#') { value = value.substr(1); } // Extract only hex digits std::string normalized; for (char c : value) { if (std::isxdigit(static_cast(c))) { normalized.push_back(static_cast(std::toupper(static_cast(c)))); } } // If 6 hex digits, add FF alpha if (normalized.size() == 6) { normalized += "FF"; } // Validate length - return default if invalid if (normalized.size() != 8) { return "00000000"; } return normalized; } // Fetch filament info from moonraker database bool MoonrakerPrinterAgent::fetch_moonraker_filament_data(std::vector& trays, int& max_lane_index) { // Fetch lane data from Moonraker database std::string url = join_url(device_info.base_url, "/server/database/item?namespace=lane_data"); std::string response_body; bool success = false; std::string http_error; auto http = Http::get(url); if (!device_info.api_key.empty()) { http.header("X-Api-Key", device_info.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) { BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent::fetch_moonraker_filament_data: Failed to fetch lane data: " << http_error; return false; } auto json = nlohmann::json::parse(response_body, nullptr, false, true); if (json.is_discarded()) { BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent::fetch_moonraker_filament_data: Invalid JSON response"; return false; } // Expected structure: { "result": { "namespace": "lane_data", "value": { "lane1": {...}, ... } } } if (!json.contains("result") || !json["result"].contains("value") || !json["result"]["value"].is_object()) { BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent::fetch_moonraker_filament_data: Unexpected JSON structure or no lane_data found"; return false; } // Parse response into AmsTrayData const auto& value = json["result"]["value"]; trays.clear(); max_lane_index = 0; size_t skipped_records = 0; // The lane_data namespace carries no extruder field; read the lane -> toolhead binding from the // AFC printer objects so lanes on different toolheads end up in different AMS boxes. std::map lane_extruder; fetch_afc_lane_extruders(lane_extruder); // Happy Hare also writes lane_data, where the lane number is the gate index. Its mmu_machine // status maps gates to units, and each unit's config maps to an extruder. Probe that mapping // only when a lane has no AFC/extruder field, preserving the existing AFC/generic behavior. std::map hh_gate_extruder; bool hh_probed = false; auto hh_extruder_for_lane = [&](int lane) { if (!hh_probed) { hh_probed = true; fetch_hh_gate_extruders(hh_gate_extruder); } const auto it = hh_gate_extruder.find(lane); return it != hh_gate_extruder.end() ? it->second : 0; }; for (const auto& [lane_key, lane_obj] : value.items()) { if (!lane_obj.is_object()) { ++skipped_records; continue; } // Lane tool number (0-based). AFC reports it as a string in the documented sample but it // can also be a number; accept either and skip lanes without a usable index. Databases // written without a "lane" field fall back to the key, which names the lane either as // "T" (0-based) or as "lane" (1-based). int lane_index = safe_json_int_flexible(lane_obj, "lane", -1); if (lane_index < 0) { lane_index = lane_index_from_key(lane_key); if (lane_index < 0) { ++skipped_records; continue; } } AmsTrayData tray; tray.slot_index = lane_index; // AFC exposes the lane's extruder/toolhead as "extruder_index"; tolerate "extruder_id" as // an alias. lane_data has neither, so fall back to the AFC lane objects, then to the Happy // Hare unit layout, and only then to the main extruder (best effort). const int source_extruder = safe_json_int_flexible(lane_obj, "extruder_index", safe_json_int_flexible(lane_obj, "extruder_id", -1)); const char* source_extruder_field = lane_obj.contains("extruder_index") ? "extruder_index" : lane_obj.contains("extruder_id") ? "extruder_id" : "none"; tray.extruder_id = source_extruder; if (tray.extruder_id < 0) { const auto it = lane_extruder.find(lane_key); tray.extruder_id = it != lane_extruder.end() ? it->second : hh_extruder_for_lane(lane_index); } tray.tray_color = safe_json_string(lane_obj, "color"); tray.tray_type = safe_json_string(lane_obj, "material"); tray.bed_temp = safe_json_int(lane_obj, "bed_temp"); tray.nozzle_temp = safe_json_int(lane_obj, "nozzle_temp"); tray.has_filament = !tray.tray_type.empty(); auto* bundle = GUI::wxGetApp().preset_bundle; tray.tray_info_idx = bundle ? bundle->filaments.filament_id_by_type(tray.tray_type) : map_filament_type_to_generic_id(tray.tray_type); max_lane_index = std::max(max_lane_index, lane_index); trays.push_back(tray); BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::fetch_moonraker_filament_data: lane " << lane_index << " src_extruder " << source_extruder_field << "=" << source_extruder << " mapped_extruder " << tray.extruder_id << " material " << tray.tray_type << " color " << tray.tray_color << " preset_resolved " << (!tray.tray_info_idx.empty()); } BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::fetch_moonraker_filament_data: lane_data records " << value.size() << " parsed " << trays.size() << " skipped " << skipped_records << " max_lane_index " << max_lane_index; if (trays.empty()) { BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::fetch_moonraker_filament_data: No lanes found"; return false; } return true; } // Fetch filament info from Happy Hare MMU bool MoonrakerPrinterAgent::fetch_hh_filament_info(std::vector& trays, int& max_lane_index) { // Query Happy Hare MMU status std::string url = join_url(device_info.base_url, "/printer/objects/query?mmu"); std::string response_body; bool success = false; std::string http_error; auto http = Http::get(url); if (!device_info.api_key.empty()) { http.header("X-Api-Key", device_info.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) { BOOST_LOG_TRIVIAL(debug) << "MoonrakerPrinterAgent::fetch_hh_filament_info: Failed to fetch HH data: " << http_error; return false; } auto json = nlohmann::json::parse(response_body, nullptr, false, true); if (json.is_discarded()) { BOOST_LOG_TRIVIAL(debug) << "MoonrakerPrinterAgent::fetch_hh_filament_info: Invalid JSON response"; return false; } // Expected structure: { "result": { "status": { "mmu": { ... } } } } if (!json.contains("result") || !json["result"].contains("status") || !json["result"]["status"].contains("mmu") || !json["result"]["status"]["mmu"].is_object()) { BOOST_LOG_TRIVIAL(debug) << "MoonrakerPrinterAgent::fetch_hh_filament_info: No mmu object in response"; return false; } const auto& mmu = json["result"]["status"]["mmu"]; // Check if HH is installed (empty mmu object means HH not installed) if (mmu.empty()) { BOOST_LOG_TRIVIAL(debug) << "MoonrakerPrinterAgent::fetch_hh_filament_info: Empty mmu object (HH not installed)"; return false; } // Get num_gates if (!mmu.contains("num_gates") || !mmu["num_gates"].is_number()) { BOOST_LOG_TRIVIAL(debug) << "MoonrakerPrinterAgent::fetch_hh_filament_info: No num_gates field"; return false; } int num_gates = mmu["num_gates"].get(); if (num_gates <= 0) { BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent::fetch_hh_filament_info: Invalid num_gates: " << num_gates; return false; } // Get arrays const auto& gate_status = mmu.contains("gate_status") ? mmu["gate_status"] : nlohmann::json::array(); const auto& gate_material = mmu.contains("gate_material") ? mmu["gate_material"] : nlohmann::json::array(); const auto& gate_color = mmu.contains("gate_color") ? mmu["gate_color"] : nlohmann::json::array(); const auto& gate_temperature = mmu.contains("gate_temperature") ? mmu["gate_temperature"] : nlohmann::json::array(); if (!gate_status.is_array() || !gate_material.is_array() || !gate_color.is_array() || !gate_temperature.is_array()) { BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent::fetch_hh_filament_info: HH arrays not found or invalid type"; return false; } // Parse gate data. Every gate becomes a lane so empty/unknown gates surface // as empty AMS slots rather than vanishing. Each gate is grouped by the toolhead its // mmu_machine unit feeds; when the unit layout is unavailable every gate keeps the previous // fallback of feed group 0. std::map gate_extruder; fetch_hh_gate_extruders(gate_extruder); trays.clear(); for (int gate_idx = 0; gate_idx < num_gates; ++gate_idx) { // Check gate_status: -1 = unknown, 0 = empty, 1 or 2 = available int status = safe_array_int(gate_status, gate_idx); std::string material = safe_array_string(gate_material, gate_idx); std::string color = safe_array_string(gate_color, gate_idx); int nozzle_temp = safe_array_int(gate_temperature, gate_idx); AmsTrayData tray; tray.slot_index = gate_idx; const auto extruder_it = gate_extruder.find(gate_idx); tray.extruder_id = extruder_it != gate_extruder.end() ? extruder_it->second : 0; if (status > 0 && !material.empty()) { tray.tray_type = material; tray.tray_color = color; tray.nozzle_temp = nozzle_temp; tray.bed_temp = 0; // HH doesn't provide bed temp in gate arrays tray.has_filament = true; auto* bundle = GUI::wxGetApp().preset_bundle; tray.tray_info_idx = bundle ? bundle->filaments.filament_id_by_type(tray.tray_type) : map_filament_type_to_generic_id(tray.tray_type); } else { tray.has_filament = false; } trays.push_back(tray); } max_lane_index = num_gates - 1; return true; } // GET a Moonraker path (relative to the device base URL) with the device's API key and parse the // response body. Returns false on a non-200 status or a body that is not valid JSON. bool MoonrakerPrinterAgent::http_get_json(const std::string& path, nlohmann::json& out) const { auto http = Http::get(join_url(device_info.base_url, path)); if (!device_info.api_key.empty()) { http.header("X-Api-Key", device_info.api_key); } std::string body; unsigned status_code = 0; http.timeout_connect(5) .timeout_max(10) .on_complete([&](std::string response, unsigned status) { body = std::move(response); status_code = status; }) .perform_sync(); if (status_code != 200) { return false; } out = nlohmann::json::parse(body, nullptr, false, true); return !out.is_discarded(); } // AFC writes its filament data into the lane_data database namespace, and the records of the // fixture/agent write path carry no extruder field, so the lane -> toolhead binding has to be read // from the printer objects: each AFC_lane .extruder names the toolhead its lane feeds, and // the toolhead's name carries its own index. Best effort: on failure every lane keeps the fallback // of the main extruder. bool MoonrakerPrinterAgent::fetch_afc_lane_extruders(std::map& lane_extruder) { nlohmann::json afc_json; if (!http_get_json("/printer/objects/query?AFC", afc_json) || !afc_json.contains("result") || !afc_json["result"].contains("status") || !afc_json["result"]["status"].contains("AFC")) { return false; } const auto& afc = afc_json["result"]["status"]["AFC"]; const auto& lanes = afc.contains("lanes") ? afc["lanes"] : nlohmann::json::array(); if (!lanes.is_array() || lanes.empty()) { return false; } // Query every lane object in one request; the object names are "AFC_lane ". std::string query_string; for (const auto& lane : lanes) { if (!lane.is_string()) { return false; } if (!query_string.empty()) { query_string += "&"; } query_string += "AFC_lane%20" + lane.get(); } nlohmann::json lanes_json; if (!http_get_json("/printer/objects/query?" + query_string, lanes_json) || !lanes_json.contains("result") || !lanes_json["result"].contains("status")) { return false; } const auto& status = lanes_json["result"]["status"]; for (const auto& lane : lanes) { const std::string name = lane.get(); const std::string key = "AFC_lane " + name; if (!status.contains(key) || !status[key].is_object() || !status[key].contains("extruder") || !status[key]["extruder"].is_string()) { continue; } const std::string toolhead = status[key]["extruder"].get(); const int toolhead_index = extruder_index_from_name(toolhead); if (toolhead_index >= 0) { lane_extruder[name] = toolhead_index; } } return !lane_extruder.empty(); } // Happy Hare maps its gates to toolheads through mmu_machine units: each unit entry carries // {name, first_gate, num_gates}, and the matching [mmu_unit ] config section names the // extruder the unit is attached to. Only Happy Hare publishes mmu_machine, so a generic // lane_data / AFC printer is never reassigned from here. Best effort: returns false and leaves the // map empty when Happy Hare or its unit data is not available. bool MoonrakerPrinterAgent::fetch_hh_gate_extruders(std::map& gate_extruder) { gate_extruder.clear(); nlohmann::json machine_json; if (!http_get_json("/printer/objects/query?mmu_machine", machine_json) || !machine_json.contains("result") || !machine_json["result"].contains("status") || !machine_json["result"]["status"].contains("mmu_machine") || !machine_json["result"]["status"]["mmu_machine"].is_object()) { return false; } const auto& machine = machine_json["result"]["status"]["mmu_machine"]; if (machine.empty()) { return false; } // Happy Hare publishes its config through the configfile object; read every unit's section from // one fetch. An absent or unreadable configfile leaves every unit at the main extruder. nlohmann::json sections; nlohmann::json config_json; if (http_get_json("/printer/objects/query?configfile", config_json) && config_json.contains("result") && config_json["result"].contains("status") && config_json["result"]["status"].contains("configfile") && config_json["result"]["status"]["configfile"].is_object()) { const auto& configfile = config_json["result"]["status"]["configfile"]; if (configfile.contains("config") && configfile["config"].is_object()) { sections = configfile["config"]; } } // One gate range -> extruder resolution per unit, reused for every gate the unit carries. std::map unit_extruder; for (const auto& [unit_key, unit_obj] : machine.items()) { // A unit entry is the one carrying a gate range; skip scalars such as num_units. if (!unit_obj.is_object() || !unit_obj.contains("first_gate") || !unit_obj.contains("num_gates")) { continue; } const int first_gate = safe_json_int_flexible(unit_obj, "first_gate", -1); const int num_gates = safe_json_int_flexible(unit_obj, "num_gates", -1); if (first_gate < 0 || num_gates <= 0) { continue; } // If status omits the unit name, use the unit key as a best-effort config section suffix. std::string unit_name = safe_json_string(unit_obj, "name"); if (unit_name.empty() && unit_key.rfind("unit_", 0) == 0) { unit_name = unit_key; } if (unit_name.empty()) { continue; } auto it = unit_extruder.find(unit_name); if (it == unit_extruder.end()) { it = unit_extruder.emplace(unit_name, hh_unit_extruder_from_config(sections, unit_name)).first; } for (int gate = first_gate; gate < first_gate + num_gates; ++gate) { gate_extruder[gate] = it->second; } } if (gate_extruder.empty()) { return false; } BOOST_LOG_TRIVIAL(debug) << "MoonrakerPrinterAgent::fetch_hh_gate_extruders: Mapped " << gate_extruder.size() << " Happy Hare gate(s) across " << unit_extruder.size() << " unit(s)"; return true; } // Read the [mmu_unit ] section of a configfile.config map and map its extruder_name to a // toolhead index ("extruder" -> 0, "extruderN" -> N). Happy Hare v4 defaults a missing // extruder_name to the main extruder, so an absent setting and an absent section both fall back to // 0 rather than dropping the unit. int MoonrakerPrinterAgent::hh_unit_extruder_from_config(const nlohmann::json& sections, const std::string& unit_name) { constexpr int default_extruder = 0; if (!sections.is_object()) { return default_extruder; } // Happy Hare names its unit sections "mmu_unit " (e.g. "mmu_unit unit0"). const auto it = sections.find("mmu_unit " + unit_name); if (it == sections.end() || !it->is_object()) { return default_extruder; } const std::string extruder_name = safe_json_string(*it, "extruder_name"); if (extruder_name.empty()) { return default_extruder; } const int extruder_index = extruder_index_from_name(extruder_name); return extruder_index >= 0 ? extruder_index : default_extruder; } int MoonrakerPrinterAgent::handle_request(const std::string& dev_id, const std::string& json_str) { auto json = nlohmann::json::parse(json_str, nullptr, false); if (json.is_discarded()) { BOOST_LOG_TRIVIAL(error) << "MoonrakerPrinterAgent: Invalid JSON request"; return BAMBU_NETWORK_ERR_INVALID_RESULT; } // Handle info commands if (json.contains("info") && json["info"].contains("command")) { const auto& command = json["info"]["command"]; if (command.is_string() && command.get() == "get_version") { return send_version_info(dev_id); } } // Handle system commands if (json.contains("system") && json["system"].contains("command")) { const auto& command = json["system"]["command"]; if (command.is_string() && command.get() == "get_access_code") { return send_access_code(dev_id); } } // Handle print commands if (json.contains("print") && json["print"].contains("command")) { const auto& command = json["print"]["command"]; if (!command.is_string()) { BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: print command is not a string"; return BAMBU_NETWORK_ERR_INVALID_RESULT; } const std::string cmd = command.get(); // Handle gcode_line command - this is how G-code commands are sent from OrcaSlicer if (cmd == "gcode_line") { if (!json["print"].contains("param") || !json["print"]["param"].is_string()) { BOOST_LOG_TRIVIAL(error) << "MoonrakerPrinterAgent: gcode_line missing param value, full json: " << json_str; return BAMBU_NETWORK_ERR_INVALID_RESULT; } std::string gcode = json["print"]["param"].get(); // Extract sequence_id from request if present std::string sequence_id; if (json["print"].contains("sequence_id") && json["print"]["sequence_id"].is_string()) { sequence_id = json["print"]["sequence_id"].get(); } nlohmann::json response; response["print"]["command"] = "gcode_line"; if (!sequence_id.empty()) { response["print"]["sequence_id"] = sequence_id; } response["print"]["param"] = gcode; send_gcode_async(dev_id, gcode, [this, dev_id, response](bool success) mutable { response["print"]["result"] = success ? "success" : "failed"; dispatch_message(dev_id, response.dump()); }); return BAMBU_NETWORK_SUCCESS; } // Print control commands if (cmd == "pause") { return pause_print(dev_id); } if (cmd == "resume") { return resume_print(dev_id); } if (cmd == "stop") { return cancel_print(dev_id); } // Bed temperature - UI sends "temp" field if (cmd == "set_bed_temp") { if (json["print"].contains("temp") && json["print"]["temp"].is_number()) { int temp = json["print"]["temp"].get(); std::string gcode = "SET_HEATER_TEMPERATURE HEATER=heater_bed TARGET=" + std::to_string(temp); send_gcode_async(dev_id, gcode); return BAMBU_NETWORK_SUCCESS; } } // Nozzle temperature - UI sends "target_temp" and "extruder_index" fields if (cmd == "set_nozzle_temp") { if (json["print"].contains("target_temp") && json["print"]["target_temp"].is_number()) { int temp = json["print"]["target_temp"].get(); int extruder_idx = 0; // Default to main extruder if (json["print"].contains("extruder_index") && json["print"]["extruder_index"].is_number()) { extruder_idx = json["print"]["extruder_index"].get(); } std::string heater = (extruder_idx == 0) ? "extruder" : "extruder" + std::to_string(extruder_idx); std::string gcode = "SET_HEATER_TEMPERATURE HEATER=" + heater + " TARGET=" + std::to_string(temp); send_gcode_async(dev_id, gcode); return BAMBU_NETWORK_SUCCESS; } } // Tool selection is printer-specific G-code. Moonraker forwards the // conventional T command to Klipper, where the printer can // implement logical selection or a physical toolhead attach macro. if (cmd == "select_extruder") { if (!json["print"].contains("extruder_index") || !json["print"]["extruder_index"].is_number_integer()) { BOOST_LOG_TRIVIAL(error) << "MoonrakerPrinterAgent: select_extruder missing integer extruder_index, full json: " << json_str; return BAMBU_NETWORK_ERR_INVALID_RESULT; } const int extruder_idx = json["print"]["extruder_index"].get(); if (extruder_idx < 0) { BOOST_LOG_TRIVIAL(error) << "MoonrakerPrinterAgent: select_extruder received negative extruder_index: " << extruder_idx; return BAMBU_NETWORK_ERR_INVALID_RESULT; } send_gcode_async(dev_id, "T" + std::to_string(extruder_idx)); return BAMBU_NETWORK_SUCCESS; } if (cmd == "home") { send_gcode_async(dev_id, "G28"); return BAMBU_NETWORK_SUCCESS; } } return BAMBU_NETWORK_SUCCESS; } bool MoonrakerPrinterAgent::init_device_info(const std::string& dev_id, const std::string& dev_ip, const std::string& username, const std::string& password, bool use_ssl, const std::string& port) { device_info = MoonrakerDeviceInfo{}; auto* preset_bundle = GUI::wxGetApp().preset_bundle; if (!preset_bundle) { return false; } auto& preset = preset_bundle->printers.get_edited_preset(); const auto& printer_cfg = preset.config; device_info.dev_ip = dev_ip; device_info.api_key = password; device_info.model_name = printer_cfg.opt_string("printer_model"); device_info.model_id = preset.get_printer_type(preset_bundle); device_info.base_url = normalize_base_url(use_ssl, dev_ip, port); device_info.dev_id = dev_id; device_info.version = ""; device_info.dev_name = device_info.dev_id; return true; } bool MoonrakerPrinterAgent::fetch_device_info(const std::string& base_url, const std::string& api_key, MoonrakerDeviceInfo& info, std::string& error) const { auto fetch_json = [&](const std::string& url, nlohmann::json& out) { 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; } out = nlohmann::json::parse(response_body, nullptr, false, true); if (out.is_discarded()) { error = "Invalid JSON response"; return false; } return true; }; nlohmann::json json; std::string url = join_url(base_url, "/server/info"); if (!fetch_json(url, json)) { return false; } nlohmann::json result = json.contains("result") ? json["result"] : json; info.dev_name = result.value("machine_name", result.value("hostname", "")); info.version = result.value("moonraker_version", ""); info.klippy_state = result.value("klippy_state", ""); return true; } bool MoonrakerPrinterAgent::query_printer_status(const std::string& base_url, const std::string& api_key, nlohmann::json& status, std::string& error) const { std::string url = join_url(base_url, "/printer/objects/query?print_stats&virtual_sdcard&toolhead&extruder&heater_bed&fan"); 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_code) { if (status_code == 200) { response_body = body; success = true; } else { http_error = "HTTP error: " + std::to_string(status_code); } }) .on_error([&](std::string body, std::string err, unsigned status_code) { http_error = err; if (status_code > 0) { http_error += " (HTTP " + std::to_string(status_code) + ")"; } }) .perform_sync(); if (!success) { error = http_error.empty() ? "Connection failed" : http_error; return false; } auto json = nlohmann::json::parse(response_body, nullptr, false, true); if (json.is_discarded()) { error = "Invalid JSON response"; return false; } if (!json.contains("result") || !json["result"].contains("status")) { error = "Unexpected JSON structure"; return false; } status = json["result"]["status"]; return true; } void MoonrakerPrinterAgent::send_gcode_async(const std::string& dev_id, const std::string& gcode, std::function on_result) const { (void) dev_id; const std::string base_url = device_info.base_url; const std::string api_key = device_info.api_key; auto http = Http::post(join_url(base_url, "/printer/gcode/script")); if (!api_key.empty()) { http.header("X-Api-Key", api_key); } http.header("Content-Type", "application/json") .set_post_body(nlohmann::json{{"script", gcode}}.dump()) .timeout_connect(5) .timeout_max(10) .on_complete([on_result](std::string body, unsigned status_code) { (void) body; const bool success = status_code == 200; if (!success) { BOOST_LOG_TRIVIAL(error) << "MoonrakerPrinterAgent: send_gcode failed: HTTP error " << status_code; } if (on_result) { on_result(success); } }) .on_error([on_result](std::string body, std::string err, unsigned status_code) { (void) body; std::string error = err; if (status_code > 0) { error += " (HTTP " + std::to_string(status_code) + ")"; } BOOST_LOG_TRIVIAL(error) << "MoonrakerPrinterAgent: send_gcode failed: " << error; if (on_result) { on_result(false); } }) .perform(); } bool MoonrakerPrinterAgent::send_gcode_sync(const std::string& dev_id, const std::string& gcode) const { nlohmann::json payload; payload["script"] = gcode; std::string payload_str = payload.dump(); std::string response_body; bool success = false; std::string http_error; auto http = Http::post(join_url(device_info.base_url, "/printer/gcode/script")); if (!device_info.api_key.empty()) { http.header("X-Api-Key", device_info.api_key); } http.header("Content-Type", "application/json") .set_post_body(payload_str) .timeout_connect(5) .timeout_max(10) .on_complete([&](std::string body, unsigned status_code) { if (status_code == 200) { response_body = body; success = true; } else { http_error = "HTTP error: " + std::to_string(status_code); } }) .on_error([&](std::string body, std::string err, unsigned status_code) { http_error = err; if (status_code > 0) { http_error += " (HTTP " + std::to_string(status_code) + ")"; } }) .perform_sync(); if (!success) { BOOST_LOG_TRIVIAL(error) << "MoonrakerPrinterAgent: send_gcode failed: " << http_error; return false; } return true; } bool MoonrakerPrinterAgent::fetch_object_list(const std::string& base_url, const std::string& api_key, std::set& objects, std::string& error) const { std::string response_body; bool success = false; std::string http_error; auto http = Http::get(join_url(base_url, "/printer/objects/list")); 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; } auto json = nlohmann::json::parse(response_body, nullptr, false, true); if (json.is_discarded()) { error = "Invalid JSON response"; return false; } nlohmann::json result = json.contains("result") ? json["result"] : json; if (!result.contains("objects") || !result["objects"].is_array()) { error = "Unexpected JSON structure"; return false; } objects.clear(); for (const auto& entry : result["objects"]) { if (entry.is_string()) { objects.insert(entry.get()); } } return !objects.empty(); } int MoonrakerPrinterAgent::send_version_info(const std::string& dev_id) { nlohmann::json payload; payload["info"]["command"] = "get_version"; payload["info"]["result"] = "success"; payload["info"]["module"] = nlohmann::json::array(); nlohmann::json module; module["name"] = "ota"; module["sw_ver"] = device_info.version; module["product_name"] = "Moonraker"; payload["info"]["module"].push_back(module); dispatch_message(dev_id, payload.dump()); return BAMBU_NETWORK_SUCCESS; } int MoonrakerPrinterAgent::send_access_code(const std::string& dev_id) { nlohmann::json payload; payload["system"]["command"] = "get_access_code"; payload["system"]["access_code"] = device_info.api_key; dispatch_message(dev_id, payload.dump()); return BAMBU_NETWORK_SUCCESS; } void MoonrakerPrinterAgent::announce_printhost_device() { OnMsgArrivedFn ssdp_fn; { std::lock_guard lock(state_mutex); ssdp_fn = on_ssdp_msg_fn; if (!ssdp_fn || device_info.dev_id.empty() || device_info.dev_ip.empty() || device_info.base_url.empty()) { return; } if (ssdp_announced_host == device_info.base_url && !ssdp_announced_id.empty()) { return; } } // Try to fetch actual device name from Moonraker // Priority: 1) Moonraker hostname, 2) Preset model name, 3) Generic fallback std::string dev_name; MoonrakerDeviceInfo info; std::string fetch_error; if (fetch_device_info(device_info.base_url, device_info.api_key, info, fetch_error) && !info.dev_name.empty()) { dev_name = info.dev_name; } else { dev_name = device_info.model_name.empty() ? "Moonraker Printer" : device_info.model_name; } const std::string model_id = device_info.model_id; nlohmann::json payload; payload["dev_name"] = dev_name; payload["dev_id"] = device_info.dev_id; payload["dev_ip"] = device_info.dev_ip; payload["dev_type"] = model_id.empty() ? dev_name : model_id; payload["dev_signal"] = "0"; payload["connect_type"] = "lan"; payload["bind_state"] = "free"; payload["sec_link"] = "secure"; payload["ssdp_version"] = "v1"; ssdp_fn(payload.dump()); { std::lock_guard lock(state_mutex); ssdp_announced_host = device_info.base_url; ssdp_announced_id = device_info.dev_id; // Set this as the selected machine if nothing is currently selected if (selected_machine.empty()) { selected_machine = device_info.dev_id; } } } void MoonrakerPrinterAgent::dispatch_local_connect(int state, const std::string& dev_id, const std::string& msg) { OnLocalConnectedFn local_fn; QueueOnMainFn queue_fn; { std::lock_guard lock(state_mutex); local_fn = on_local_connect_fn; queue_fn = queue_on_main_fn; } if (!local_fn) { return; } auto dispatch = [state, dev_id, msg, local_fn]() { local_fn(state, dev_id, msg); }; if (queue_fn) { queue_fn(dispatch); } else { dispatch(); } } void MoonrakerPrinterAgent::dispatch_printer_connected(const std::string& dev_id) { OnPrinterConnectedFn connected_fn; QueueOnMainFn queue_fn; { std::lock_guard lock(state_mutex); connected_fn = on_printer_connected_fn; queue_fn = queue_on_main_fn; } if (!connected_fn) { return; } auto dispatch = [dev_id, connected_fn]() { connected_fn(dev_id); }; if (queue_fn) { queue_fn(dispatch); } else { dispatch(); } } void MoonrakerPrinterAgent::start_status_stream(const std::string& dev_id, const std::string& base_url, const std::string& api_key) { stop_status_stream(); if (base_url.empty()) { return; } ws_stop.store(false); ws_thread = std::thread([this, dev_id, base_url, api_key]() { run_status_stream(dev_id, base_url, api_key); }); } void MoonrakerPrinterAgent::stop_status_stream() { ws_stop.store(true); if (ws_thread.joinable()) { ws_thread.join(); } } void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string base_url, std::string api_key) { WsEndpoint endpoint; if (!parse_ws_endpoint(base_url, endpoint)) { BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: websocket endpoint invalid for base_url=" << base_url; return; } if (endpoint.secure) { BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: websocket wss not supported for base_url=" << base_url; return; } // Reconnection logic ws_reconnect_requested.store(false); // Reset reconnect flag int retry_count = 0; const int max_retries = 10; const int base_delay_ms = 1000; while (!ws_stop.load() && retry_count < max_retries) { bool connection_lost = false; // Flag to distinguish clean shutdown from unexpected disconnect try { net::io_context ioc; tcp::resolver resolver{ioc}; beast::tcp_stream stream{ioc}; stream.expires_after(std::chrono::seconds(10)); auto const results = resolver.resolve(endpoint.host, endpoint.port); stream.connect(results); websocket::stream ws{std::move(stream)}; ws.set_option(websocket::stream_base::decorator([&](websocket::request_type& req) { req.set(http::field::user_agent, "OrcaSlicer"); if (!api_key.empty()) { req.set("X-Api-Key", api_key); } })); std::string host_header = endpoint.host; if (!endpoint.port.empty() && endpoint.port != "80") { host_header += ":" + endpoint.port; } ws.handshake(host_header, endpoint.target); ws.text(true); // Send client identification nlohmann::json identify; identify["jsonrpc"] = "2.0"; identify["method"] = "server.connection.identify"; identify["params"]["client_name"] = "OrcaSlicer"; identify["params"]["version"] = MoonrakerPrinterAgent_VERSION; identify["params"]["type"] = "agent"; identify["params"]["url"] = "https://github.com/SoftFever/OrcaSlicer"; identify["id"] = 0; ws.write(net::buffer(identify.dump())); std::set subscribe_objects = {"print_stats", "virtual_sdcard"}; std::set available_objects; std::string list_error; if (fetch_object_list(base_url, api_key, available_objects, list_error)) { { std::lock_guard lock(payload_mutex); this->available_objects = std::move(available_objects); } if (this->available_objects.count("heater_bed") != 0) { subscribe_objects.insert("heater_bed"); } if (this->available_objects.count("fan") != 0) { subscribe_objects.insert("fan"); } // Add toolhead for homing status if (this->available_objects.count("toolhead") != 0) { subscribe_objects.insert("toolhead"); } // Add display_status for layer info (if available) if (this->available_objects.count("display_status") != 0) { subscribe_objects.insert("display_status"); } for (const auto& name : this->available_objects) { const bool is_main_extruder = name == "extruder"; const bool is_additional_extruder = name.rfind("extruder", 0) == 0 && name.size() > 8 && std::all_of(name.begin() + 8, name.end(), [](unsigned char c) { return std::isdigit(c) != 0; }); if (is_main_extruder || is_additional_extruder) { subscribe_objects.insert(name); } } } else { subscribe_objects.insert("extruder"); subscribe_objects.insert("heater_bed"); subscribe_objects.insert("toolhead"); // Add toolhead as fallback subscribe_objects.insert("fan"); // Try to subscribe to fan as fallback } nlohmann::json subscribe; subscribe["jsonrpc"] = "2.0"; subscribe["method"] = "printer.objects.subscribe"; nlohmann::json objects = nlohmann::json::object(); for (const auto& name : subscribe_objects) { objects[name] = nullptr; } subscribe["params"]["objects"] = std::move(objects); subscribe["id"] = 1; ws.write(net::buffer(subscribe.dump())); // Read loop while (!ws_stop.load()) { ws.next_layer().expires_after(std::chrono::seconds(2)); beast::flat_buffer buffer; beast::error_code ec; ws.read(buffer, ec); if (ec == beast::error::timeout) { const auto now_ms = static_cast( std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()).count()); const auto last_ms = ws_last_emit_ms.load(); if (last_ms == 0 || now_ms - last_ms >= 10000) { nlohmann::json message; { std::lock_guard lock(payload_mutex); message = build_print_payload_locked(); } dispatch_message(dev_id, message.dump()); ws_last_emit_ms.store(now_ms); } continue; } if (ec == websocket::error::closed) { connection_lost = true; break; } if (ec) { BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: websocket read error: " << ec.message(); connection_lost = true; break; } handle_ws_message(dev_id, beast::buffers_to_string(buffer.data())); // Check if handle_ws_message triggered reconnection request if (ws_reconnect_requested.exchange(false)) { connection_lost = true; break; } } beast::error_code ec; ws.close(websocket::close_code::normal, ec); // Only reset retry count on clean shutdown (not connection_lost) if (!connection_lost && !ws_stop.load()) { retry_count = 0; } } catch (const std::exception& e) { BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: websocket disconnected: " << e.what(); connection_lost = true; } // Exit loop on clean shutdown if (!connection_lost) { break; } // Check if we should stop reconnection attempts if (ws_stop.load()) { break; } // Exponential backoff before reconnection int delay_ms = base_delay_ms * (1 << std::min(retry_count, 5)); BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent: Reconnecting in " << delay_ms << "ms (attempt " << (retry_count + 1) << ")"; std::this_thread::sleep_for(std::chrono::milliseconds(delay_ms)); retry_count++; } if (retry_count >= max_retries) { BOOST_LOG_TRIVIAL(error) << "MoonrakerPrinterAgent: Max reconnection attempts reached"; dispatch_local_connect(ConnectStatusLost, dev_id, "max_retries"); } } void MoonrakerPrinterAgent::handle_ws_message(const std::string& dev_id, const std::string& payload) { auto json = nlohmann::json::parse(payload, nullptr, false); if (json.is_discarded()) { BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: Invalid WebSocket message JSON"; return; } bool updated = false; bool is_critical = false; // Track if this is a critical update that bypasses throttle // Check for subscription response (has "result.status") - initial subscription is critical if (json.contains("result") && json["result"].contains("status") && json["result"]["status"].is_object()) { update_status_cache(json["result"]["status"]); updated = true; is_critical = true; // Initial subscription response - dispatch immediately } // Check for status update notifications if (json.contains("method") && json["method"].is_string()) { const std::string method = json["method"].get(); if (method == "notify_status_update" && json.contains("params") && json["params"].is_array() && !json["params"].empty() && json["params"][0].is_object()) { update_status_cache(json["params"][0]); updated = true; // Note: is_critical stays false for regular status updates (telemetry) } else if (method == "notify_klippy_ready") { nlohmann::json updates; updates["print_stats"]["state"] = "standby"; update_status_cache(updates); updated = true; is_critical = true; // Klippy events are critical } else if (method == "notify_klippy_shutdown") { nlohmann::json updates; updates["print_stats"]["state"] = "error"; update_status_cache(updates); updated = true; is_critical = true; // Klippy events are critical } // Handle Klippy disconnect - update status and trigger reconnection else if (method == "notify_klippy_disconnected") { // Klippy disconnected - update status to reflect disconnect state nlohmann::json updates; updates["print_stats"]["state"] = "error"; update_status_cache(updates); updated = true; is_critical = true; // Klippy events are critical // Set flag to trigger reconnection after dispatching the status update ws_reconnect_requested.store(true); BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: Klippy disconnected, triggering reconnection"; } } // Check for print state changes (critical - always dispatch immediately) if (updated && !is_critical) { std::string current_state; { std::lock_guard lock(payload_mutex); if (status_cache.contains("print_stats") && status_cache["print_stats"].contains("state") && status_cache["print_stats"]["state"].is_string()) { current_state = status_cache["print_stats"]["state"].get(); } } if (!current_state.empty() && current_state != last_print_state) { is_critical = true; last_print_state = current_state; } } if (updated) { const auto now_ms = static_cast( std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()).count()); const auto last_dispatch_ms = ws_last_dispatch_ms.load(); // Dispatch if: critical change OR throttle interval elapsed const bool should_dispatch = is_critical || last_dispatch_ms == 0 || now_ms - last_dispatch_ms >= STATUS_UPDATE_INTERVAL_MS; if (should_dispatch) { nlohmann::json message; { std::lock_guard lock(payload_mutex); message = build_print_payload_locked(); } dispatch_message(dev_id, message.dump()); ws_last_dispatch_ms.store(now_ms); ws_last_emit_ms.store(now_ms); // Also update heartbeat timer } // else: skip dispatch, cache is updated for next dispatch cycle } } void MoonrakerPrinterAgent::update_status_cache(const nlohmann::json& updates) { if (!updates.is_object()) { return; } std::lock_guard lock(payload_mutex); if (!status_cache.is_object()) { status_cache = nlohmann::json::object(); } for (const auto& item : updates.items()) { if (item.value().is_object()) { nlohmann::json& target = status_cache[item.key()]; if (!target.is_object()) { target = nlohmann::json::object(); } for (const auto& field : item.value().items()) { target[field.key()] = field.value(); } } else { status_cache[item.key()] = item.value(); } } } // Snapshot Moonraker toolhead status objects ("extruder", "extruder1", ...) keyed by extruder id. // An id is the suffix of the object name, so a non-contiguous set keeps its real ids. std::map MoonrakerPrinterAgent::collect_extruder_status() const { std::lock_guard lock(payload_mutex); std::map extruder_status; for (const auto& item : status_cache.items()) { int extruder_id = -1; if (item.key() == "extruder") { extruder_id = 0; } else if (item.key().rfind("extruder", 0) == 0 && item.key().size() > 8 && std::all_of(item.key().begin() + 8, item.key().end(), [](unsigned char c) { return std::isdigit(c) != 0; })) { try { extruder_id = std::stoi(item.key().substr(8)); } catch (...) { extruder_id = -1; } } if (extruder_id >= 0 && item.value().is_object()) { extruder_status.emplace(extruder_id, item.value()); } } return extruder_status; } nlohmann::json MoonrakerPrinterAgent::build_print_payload_locked() const { nlohmann::json payload; payload["print"]["command"] = "push_status"; payload["print"]["msg"] = 0; payload["print"]["support_mqtt_alive"] = true; std::string state = "IDLE"; if (status_cache.contains("print_stats") && status_cache["print_stats"].contains("state") && status_cache["print_stats"]["state"].is_string()) { state = map_moonraker_state(status_cache["print_stats"]["state"].get()); } payload["print"]["gcode_state"] = state; // Map Moonraker state to Bambu stage numbers int mc_print_stage = 0; if (status_cache.contains("print_stats") && status_cache["print_stats"].contains("state")) { std::string mr_state = status_cache["print_stats"]["state"].get(); if (mr_state == "printing") mc_print_stage = 1; else if (mr_state == "paused") mc_print_stage = 2; else if (mr_state == "complete") mc_print_stage = 3; else if (mr_state == "error") mc_print_stage = 4; } payload["print"]["mc_print_stage"] = mc_print_stage; // Leave mc_print_error_code and print_error at 0 // UI expects numeric HMS codes - setting to 1 shows generic error dialog // Only set if real mapping from Moonraker error strings to HMS codes is defined payload["print"]["mc_print_error_code"] = 0; payload["print"]["print_error"] = 0; // Map homed axes to bit field: X=bit0, Y=bit1, Z=bit2 // WARNING: This only sets bits 0-2, clearing support flags (bit 3+) // Bit 3 = 220V voltage, bit 4 = auto recovery, etc. // This is acceptable for Moonraker (no AMS, different feature set) int home_flag = 0; if (status_cache.contains("toolhead") && status_cache["toolhead"].contains("homed_axes")) { std::string homed = status_cache["toolhead"]["homed_axes"].get(); if (homed.find('X') != std::string::npos) home_flag |= 1; // bit 0 if (homed.find('Y') != std::string::npos) home_flag |= 2; // bit 1 if (homed.find('Z') != std::string::npos) home_flag |= 4; // bit 2 } payload["print"]["home_flag"] = home_flag; // Moonraker doesn't provide temperature ranges via API - use hardcoded defaults payload["print"]["nozzle_temp_range"] = {100, 370}; // Typical Klipper range payload["print"]["bed_temp_range"] = {0, 120}; // Typical bed range payload["print"]["support_send_to_sd"] = true; // Detect bed_leveling support from available objects (bed_mesh or probe) // Default to 0 (not supported) if neither object exists bool has_bed_leveling = (available_objects.count("bed_mesh") != 0 || available_objects.count("probe") != 0); payload["print"]["support_bed_leveling"] = has_bed_leveling ? 1 : 0; const nlohmann::json* extruder = nullptr; if (status_cache.contains("extruder") && status_cache["extruder"].is_object()) { extruder = &status_cache["extruder"]; } else { for (const auto& item : status_cache.items()) { if (item.value().is_object() && item.key().rfind("extruder", 0) == 0) { extruder = &item.value(); break; } } } if (extruder) { if (extruder->contains("temperature") && (*extruder)["temperature"].is_number()) { payload["print"]["nozzle_temper"] = (*extruder)["temperature"].get(); } if (extruder->contains("target") && (*extruder)["target"].is_number()) { payload["print"]["nozzle_target_temper"] = (*extruder)["target"].get(); } } // MachineObject::parse_new_info() only parses device.extruder when the four // legacy new-protocol fields are present. Moonraker has no equivalents, so // empty values act as compatibility markers without enabling any features. const auto extruder_status = collect_extruder_status(); if (!extruder_status.empty()) { auto& device_extruder = payload["print"]["device"]["extruder"]; int current_extruder_id = 0; if (status_cache.contains("toolhead") && status_cache["toolhead"].is_object() && status_cache["toolhead"].contains("extruder") && status_cache["toolhead"]["extruder"].is_string()) { const std::string active_extruder = status_cache["toolhead"]["extruder"].get(); if (active_extruder != "extruder" && active_extruder.rfind("extruder", 0) == 0 && active_extruder.size() > 8 && std::all_of(active_extruder.begin() + 8, active_extruder.end(), [](unsigned char c) { return std::isdigit(c) != 0; })) { try { const int parsed_id = std::stoi(active_extruder.substr(8)); if (parsed_id >= 0 && parsed_id < 16 && extruder_status.count(parsed_id) != 0) { current_extruder_id = parsed_id; } } catch (...) { // Keep the main extruder as the fallback for malformed status data. } } } const int extruder_count = std::min(static_cast(extruder_status.size()), 0xF); device_extruder["state"] = extruder_count | (current_extruder_id << 4); device_extruder["info"] = nlohmann::json::array(); auto encoded_temperature = [](const nlohmann::json& status, const char* key) { if (!status.contains(key) || !status[key].is_number()) { return 0; } const double temperature = status[key].get(); return std::clamp(static_cast(temperature + 0.5), 0, 65535); }; for (const auto& [extruder_id, status] : extruder_status) { const int current_temperature = encoded_temperature(status, "temperature"); const int target_temperature = encoded_temperature(status, "target"); device_extruder["info"].push_back({ {"id", extruder_id}, {"filam_bak", nlohmann::json::array()}, {"info", 1 << 3}, // The configured Moonraker extruder has a toolhead. {"temp", current_temperature | (target_temperature << 16)}, {"spre", 0}, {"snow", 0}, {"star", 0}, {"stat", 0}, {"hnow", 0}, }); } payload["print"]["cfg"] = ""; payload["print"]["fun"] = ""; payload["print"]["aux"] = ""; payload["print"]["stat"] = ""; // External-spool placeholders. Moonraker reports the extruders but no external-spool // filament, so emit one empty native virtual tray per extruder (id = 255 - extruder_id). // Emitting vir_slot makes the parser grow vt_slot and set ams_support_virtual_tray, giving // every extruder a load slot instead of only the main one. Ids come from the extruder_status // map, so a non-contiguous extruder set keeps its id mapping (the parser fills the holes // with placeholders) rather than shifting every slot down. auto& vir_slot = payload["print"]["vir_slot"]; vir_slot = nlohmann::json::array(); for (const auto& entry : extruder_status) { if (entry.first < 0 || entry.first > VIRTUAL_TRAY_MAIN_ID) { continue; } vir_slot.push_back({{"id", std::to_string(VIRTUAL_TRAY_MAIN_ID - entry.first)}}); } } if (status_cache.contains("heater_bed") && status_cache["heater_bed"].is_object()) { const auto& bed = status_cache["heater_bed"]; if (bed.contains("temperature") && bed["temperature"].is_number()) { payload["print"]["bed_temper"] = bed["temperature"].get(); } if (bed.contains("target") && bed["target"].is_number()) { payload["print"]["bed_target_temper"] = bed["target"].get(); } } // Handle fan speed - only if Moonraker provides "fan" object (standard API) if (status_cache.contains("fan") && status_cache["fan"].is_object() && !status_cache["fan"].empty()) { const auto& fan = status_cache["fan"]; if (fan.contains("speed") && fan["speed"].is_number()) { double speed = fan["speed"].get(); int pwm = 0; if (speed <= 1.0) { pwm = static_cast(speed * 255.0 + 0.5); } else { pwm = static_cast(speed + 0.5); } pwm = std::clamp(pwm, 0, 255); payload["print"]["fan_gear"] = pwm; } else if (fan.contains("power") && fan["power"].is_number()) { double power = fan["power"].get(); int pwm = static_cast(power * 255.0 + 0.5); pwm = std::clamp(pwm, 0, 255); payload["print"]["fan_gear"] = pwm; } } // If "fan" object doesn't exist, don't include fan_gear in payload if (status_cache.contains("print_stats") && status_cache["print_stats"].contains("filename") && status_cache["print_stats"]["filename"].is_string()) { payload["print"]["subtask_name"] = status_cache["print_stats"]["filename"].get(); } if (status_cache.contains("print_stats") && status_cache["print_stats"].contains("filename")) { payload["print"]["gcode_file"] = status_cache["print_stats"]["filename"]; } int mc_percent = -1; if (status_cache.contains("virtual_sdcard") && status_cache["virtual_sdcard"].contains("progress") && status_cache["virtual_sdcard"]["progress"].is_number()) { const double progress = status_cache["virtual_sdcard"]["progress"].get(); if (progress >= 0.0) { mc_percent = std::clamp(static_cast(progress * 100.0 + 0.5), 0, 100); } } if (mc_percent >= 0) { payload["print"]["mc_percent"] = mc_percent; } if (status_cache.contains("print_stats") && status_cache["print_stats"].contains("total_duration") && status_cache["print_stats"].contains("print_duration") && status_cache["print_stats"]["total_duration"].is_number() && status_cache["print_stats"]["print_duration"].is_number()) { const double total = status_cache["print_stats"]["total_duration"].get(); const double elapsed = status_cache["print_stats"]["print_duration"].get(); if (total > 0.0 && elapsed >= 0.0) { const auto remaining_minutes = std::max(0, static_cast((total - elapsed) / 60.0)); payload["print"]["mc_remaining_time"] = remaining_minutes; } } const auto now_ms = static_cast( std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count()); payload["t_utc"] = now_ms; return payload; } void MoonrakerPrinterAgent::dispatch_message(const std::string& dev_id, const std::string& payload) { OnMessageFn local_fn; OnMessageFn cloud_fn; QueueOnMainFn queue_fn; { std::lock_guard lock(state_mutex); local_fn = on_local_message_fn; cloud_fn = on_message_fn; queue_fn = queue_on_main_fn; } if (!local_fn && !cloud_fn) { BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: dispatch_message - no message callback registered!"; return; } auto dispatch = [dev_id, payload, local_fn, cloud_fn]() { if (local_fn) { local_fn(dev_id, payload); return; } if (cloud_fn) { cloud_fn(dev_id, payload); } }; if (queue_fn) { queue_fn(dispatch); } else { dispatch(); } } bool MoonrakerPrinterAgent::upload_gcode(const std::string& local_path, const std::string& filename, const std::string& base_url, const std::string& api_key, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn) { namespace fs = boost::filesystem; // Validate file exists fs::path source_path(local_path); if (!fs::exists(source_path)) { BOOST_LOG_TRIVIAL(error) << "MoonrakerPrinterAgent: File does not exist: " << local_path; return false; } // Check file size std::uintmax_t file_size = fs::file_size(source_path); if (file_size > 1024 * 1024 * 1024) { // 1GB limit BOOST_LOG_TRIVIAL(error) << "MoonrakerPrinterAgent: File too large: " << file_size << " bytes"; return false; } // Sanitize filename to prevent path traversal attacks std::string safe_filename = sanitize_filename(filename); bool result = true; std::string http_error; // Use Http::form_add and Http::form_add_file auto http = Http::post(join_url(base_url, "/server/files/upload")); if (!api_key.empty()) { http.header("X-Api-Key", api_key); } http.form_add("root", "gcodes") // Upload to gcodes directory .form_add("print", "false") // Don't auto-start print .form_add_file("file", source_path.string(), safe_filename) .timeout_connect(5) .timeout_max(300) // 5 minutes for large files .on_complete([&](std::string body, unsigned status) { (void) body; (void) status; }) .on_error([&](std::string body, std::string err, unsigned status) { BOOST_LOG_TRIVIAL(error) << "MoonrakerPrinterAgent: Upload error: " << err << " HTTP " << status; http_error = err; result = false; }) .on_progress([&](Http::Progress progress, bool& cancel) { // Check for cancellation via WasCancelledFn if (cancel_fn && cancel_fn()) { cancel = true; result = false; return; } // Report progress via OnUpdateStatusFn if (update_fn && progress.ultotal > 0) { int percent = static_cast((progress.ulnow * 100) / progress.ultotal); update_fn(PrintingStageUpload, percent, _u8L("Uploading...")); } }) .perform_sync(); if (!result) { BOOST_LOG_TRIVIAL(error) << "MoonrakerPrinterAgent: Upload failed: " << http_error; return false; } return true; } int MoonrakerPrinterAgent::pause_print(const std::string& dev_id) { send_gcode_async(dev_id, "PAUSE"); return BAMBU_NETWORK_SUCCESS; } int MoonrakerPrinterAgent::resume_print(const std::string& dev_id) { send_gcode_async(dev_id, "RESUME"); return BAMBU_NETWORK_SUCCESS; } int MoonrakerPrinterAgent::cancel_print(const std::string& dev_id) { send_gcode_async(dev_id, "CANCEL_PRINT"); return BAMBU_NETWORK_SUCCESS; } bool MoonrakerPrinterAgent::send_jsonrpc_command(const std::string& base_url, const std::string& api_key, const nlohmann::json& request, std::string& response) const { std::string request_str = request.dump(); std::string url = join_url(base_url, "/printer/print/start"); bool success = false; std::string http_error; auto http = Http::post(url); if (!api_key.empty()) { http.header("X-Api-Key", api_key); } http.header("Content-Type", "application/json") .set_post_body(request_str) .timeout_connect(5) .timeout_max(10) .on_complete([&](std::string body, unsigned status) { if (status == 200) { response = body; success = true; } else { http_error = "HTTP " + std::to_string(status); } }) .on_error([&](std::string body, std::string err, unsigned status) { http_error = err; }) .perform_sync(); if (!success) { BOOST_LOG_TRIVIAL(error) << "MoonrakerPrinterAgent: JSON-RPC command failed: " << http_error; } return success; } void MoonrakerPrinterAgent::perform_connection_async(const std::string& dev_id, const std::string& base_url, const std::string& api_key, uint64_t generation) { auto is_stale = [&]() { return generation != connect_generation.load(); }; int result = BAMBU_NETWORK_ERR_CONNECTION_TO_PRINTER_FAILED; std::string error_msg; // Early exit if a newer connection was started before we begin if (is_stale()) { return; } try { MoonrakerDeviceInfo fetched_info; if (!fetch_device_info(base_url, api_key, fetched_info, error_msg)) { BOOST_LOG_TRIVIAL(error) << "MoonrakerPrinterAgent: Failed to fetch server info: " << error_msg; // Orca todo: revist here, for now don't send error, this is set current MachineObject to null // dispatch_local_connect(ConnectStatusFailed, dev_id, "server_info_failed"); return; } // Commit fetched info back to device_info under lock, only if still current { std::lock_guard lock(connect_mutex); if (is_stale()) { return; } device_info.dev_name = fetched_info.dev_name.empty() ? dev_id : fetched_info.dev_name; device_info.version = fetched_info.version; device_info.klippy_state = fetched_info.klippy_state; } // Orca todo: disable websocket for now, as we don't use MonitorPanel for Moonraker printers yet #if 1 // Query initial status nlohmann::json initial_status; if (query_printer_status(base_url, api_key, initial_status, error_msg)) { { update_status_cache(initial_status); } BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent: Initial status queried successfully"; } else { BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: Initial status query failed: " << error_msg; } // Start WebSocket status stream start_status_stream(dev_id, base_url, api_key); #endif // Success! result = BAMBU_NETWORK_SUCCESS; } catch (const std::exception& e) { BOOST_LOG_TRIVIAL(error) << "MoonrakerPrinterAgent: Connection exception: " << e.what(); error_msg = std::string("exception: ") + e.what(); result = BAMBU_NETWORK_ERR_CONNECTION_TO_PRINTER_FAILED; } // Only dispatch if this connection is still the current one if (result == BAMBU_NETWORK_SUCCESS && !is_stale()) { dispatch_local_connect(ConnectStatusOk, dev_id, "0"); dispatch_printer_connected(dev_id); BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent: connect_printer completed - dev_id=" << dev_id; } else if (result != BAMBU_NETWORK_SUCCESS && result != BAMBU_NETWORK_ERR_CANCELED) { // Orca todo: revist here, for now don't send error, this is set current MachineObject to null // dispatch_local_connect(ConnectStatusFailed, dev_id, error_msg); } } bool MoonrakerPrinterAgent::is_numeric(const std::string& value) { return !value.empty() && std::all_of(value.begin(), value.end(), [](unsigned char c) { return std::isdigit(c) != 0; }); } std::string MoonrakerPrinterAgent::normalize_base_url(bool use_ssl, const std::string& host, const std::string& port) { std::string value = use_ssl ? "https://" : "http://"; value += host; value += port.empty() ? "" : (":" + port); return value; } std::string MoonrakerPrinterAgent::join_url(const std::string& base_url, const std::string& path) const { if (base_url.empty()) { return ""; } if (path.empty()) { return base_url; } if (base_url.back() == '/' && path.front() == '/') { return base_url.substr(0, base_url.size() - 1) + path; } if (base_url.back() != '/' && path.front() != '/') { return base_url + "/" + path; } return base_url + path; } // Sanitize filename to prevent path traversal attacks // Extracts only the basename, removing any path components std::string MoonrakerPrinterAgent::sanitize_filename(const std::string& filename) { if (filename.empty()) { return "print.gcode"; } namespace fs = boost::filesystem; fs::path p(filename); std::string basename = p.filename().string(); if (basename.empty() || basename == "." || basename == "..") { return "print.gcode"; } return basename; } } // namespace Slic3r