From 05cb8b4d899b166fe3ff7477eed9eb125eef17f4 Mon Sep 17 00:00:00 2001
From: Branden Cash <203336+ammmze@users.noreply.github.com>
Date: Sat, 14 Feb 2026 23:29:58 -0700
Subject: [PATCH] feat(MoonrakerPrinterAgent): support Happy Hare as
alternative to AFC for filament sync (#12307)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
# Description
# Screenshots/Recordings/Graphs
https://github.com/user-attachments/assets/5558b4be-24eb-4f2d-83fd-8482560a0014
## Tests
Removed configured filaments and pressed the sync button. Observed the filaments configured in my system were populated.
---
src/slic3r/Utils/MoonrakerPrinterAgent.cpp | 453 ++++++++++++++-------
src/slic3r/Utils/MoonrakerPrinterAgent.hpp | 11 +
2 files changed, 321 insertions(+), 143 deletions(-)
diff --git a/src/slic3r/Utils/MoonrakerPrinterAgent.cpp b/src/slic3r/Utils/MoonrakerPrinterAgent.cpp
index cc19fe7db1..a46e7be8f1 100644
--- a/src/slic3r/Utils/MoonrakerPrinterAgent.cpp
+++ b/src/slic3r/Utils/MoonrakerPrinterAgent.cpp
@@ -461,42 +461,6 @@ void MoonrakerPrinterAgent::build_ams_payload(int ams_count, int max_lane_index,
return;
}
-
- // Color normalization helper (handles #RRGGBB, 0xRRGGBB -> RRGGBBAA)
- auto normalize_color = [](const std::string& color) -> std::string {
- 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;
- };
-
// Build BBL-format JSON for DevFilaSystemParser::ParseV1_0
nlohmann::json ams_json = nlohmann::json::object();
nlohmann::json ams_array = nlohmann::json::array();
@@ -535,7 +499,7 @@ void MoonrakerPrinterAgent::build_ams_payload(int ams_count, int max_lane_index,
tray_json["tray_info_idx"] = tray->tray_info_idx;
tray_json["tray_type"] = tray->tray_type;
- tray_json["tray_color"] = normalize_color(tray->tray_color);
+ tray_json["tray_color"] = normalize_color_value(tray->tray_color);
// Add temperature data if provided
if (tray->bed_temp > 0) {
@@ -604,119 +568,30 @@ void MoonrakerPrinterAgent::build_ams_payload(int ams_count, int max_lane_index,
bool MoonrakerPrinterAgent::fetch_filament_info(std::string dev_id)
{
- // Fetch AFC lane data from Moonraker database (inline)
- 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_filament_info: 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_filament_info: 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_filament_info: Unexpected JSON structure or no lane_data found";
- return false;
- }
-
- // Parse response into AmsTrayData
- const auto& value = json["result"]["value"];
std::vector trays;
int max_lane_index = 0;
- // Null-safe JSON accessors: nlohmann::json::value() throws type_error
- // when the key exists but the value is null (type mismatch).
- auto safe_string = [](const nlohmann::json& obj, const char* key) -> std::string {
- auto it = obj.find(key);
- if (it != obj.end() && it->is_string())
- return it->get();
- return "";
- };
- auto safe_int = [](const nlohmann::json& obj, const char* key) -> int {
- auto it = obj.find(key);
- if (it != obj.end() && it->is_number())
- return it->get();
- return 0;
- };
-
- for (const auto& [lane_key, lane_obj] : value.items()) {
- if (!lane_obj.is_object()) {
- continue;
- }
-
- // Extract lane index from the "lane" field (tool number, 0-based)
- std::string lane_str = safe_string(lane_obj, "lane");
- int lane_index = -1;
- if (!lane_str.empty()) {
- try {
- lane_index = std::stoi(lane_str);
- } catch (...) {
- lane_index = -1;
- }
- }
-
- if (lane_index < 0) {
- continue;
- }
-
- AmsTrayData tray;
- tray.slot_index = lane_index;
- tray.tray_color = safe_string(lane_obj, "color");
- tray.tray_type = safe_string(lane_obj, "material");
- tray.bed_temp = safe_int(lane_obj, "bed_temp");
- tray.nozzle_temp = safe_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);
+ // Try Happy Hare first (more widely adopted, supports more filament changers)
+ 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";
+ int ams_count = (max_lane_index + 4) / 4;
+ build_ams_payload(ams_count, max_lane_index, trays);
+ return true;
}
- if (trays.empty()) {
- BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::fetch_filament_info: No AFC lanes found";
- return false;
+ // Fallback to AFC
+ if (fetch_afc_filament_info(trays, max_lane_index)) {
+ BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::fetch_filament_info: Detected AFC with "
+ << (max_lane_index + 1) << " lanes";
+ int ams_count = (max_lane_index + 4) / 4;
+ build_ams_payload(ams_count, max_lane_index, trays);
+ return true;
}
- // Calculate AMS count from max lane index (4 trays per AMS unit)
- int ams_count = (max_lane_index + 4) / 4;
-
- // Build and parse the AMS payload
- build_ams_payload(ams_count, max_lane_index, 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 AFC)";
+ return false;
}
std::string MoonrakerPrinterAgent::trim_and_upper(const std::string& input)
@@ -780,6 +655,298 @@ std::string MoonrakerPrinterAgent::map_filament_type_to_generic_id(const std::st
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;
+}
+
+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 Armored Turtle AFC
+bool MoonrakerPrinterAgent::fetch_afc_filament_info(std::vector& trays, int& max_lane_index)
+{
+ // Fetch AFC 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_afc_filament_info: 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_afc_filament_info: 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_afc_filament_info: 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;
+
+ for (const auto& [lane_key, lane_obj] : value.items()) {
+ if (!lane_obj.is_object()) {
+ continue;
+ }
+
+ // Extract lane index from the "lane" field (tool number, 0-based)
+ std::string lane_str = safe_json_string(lane_obj, "lane");
+ int lane_index = -1;
+ if (!lane_str.empty()) {
+ try {
+ lane_index = std::stoi(lane_str);
+ } catch (...) {
+ lane_index = -1;
+ }
+ }
+
+ if (lane_index < 0) {
+ continue;
+ }
+
+ AmsTrayData tray;
+ tray.slot_index = 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);
+ }
+
+ if (trays.empty()) {
+ BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::fetch_afc_filament_info: No AFC 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
+ trays.clear();
+ max_lane_index = 0;
+
+ 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);
+ if (status <= 0) {
+ continue; // Skip unknown or empty gates
+ }
+
+ // Extract gate data
+ 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);
+
+ // Skip if no material type (empty gate)
+ if (material.empty()) {
+ continue;
+ }
+
+ AmsTrayData tray;
+ tray.slot_index = gate_idx;
+ 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);
+
+ max_lane_index = std::max(max_lane_index, gate_idx);
+ trays.push_back(tray);
+ }
+
+ if (trays.empty()) {
+ BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::fetch_hh_filament_info: No valid HH gates found";
+ return false;
+ }
+
+ return true;
+}
+
int MoonrakerPrinterAgent::handle_request(const std::string& dev_id, const std::string& json_str)
{
auto json = nlohmann::json::parse(json_str, nullptr, false);
diff --git a/src/slic3r/Utils/MoonrakerPrinterAgent.hpp b/src/slic3r/Utils/MoonrakerPrinterAgent.hpp
index bddf25296a..525d5fed86 100644
--- a/src/slic3r/Utils/MoonrakerPrinterAgent.hpp
+++ b/src/slic3r/Utils/MoonrakerPrinterAgent.hpp
@@ -160,6 +160,17 @@ private:
const std::string& api_key,
uint64_t generation);
+ // System-specific filament fetch methods
+ bool fetch_hh_filament_info(std::vector& trays, int& max_lane_index);
+ bool fetch_afc_filament_info(std::vector& trays, int& max_lane_index);
+
+ // JSON helper methods
+ static std::string safe_json_string(const nlohmann::json& obj, const char* key);
+ static int safe_json_int(const nlohmann::json& obj, const char* key);
+ static std::string safe_array_string(const nlohmann::json& arr, int idx);
+ static int safe_array_int(const nlohmann::json& arr, int idx);
+ static std::string normalize_color_value(const std::string& color);
+
std::string ssdp_announced_host;
std::string ssdp_announced_id;
std::shared_ptr m_cloud_agent;