From 6f5856f06c6ccafba9f8b425bd71ffec6b033441 Mon Sep 17 00:00:00 2001 From: Lam Wei Lun Date: Fri, 18 Sep 2026 15:46:35 +0800 Subject: [PATCH] Initial Commit for OrcaPrinterAgent AMS Sync --- scripts/test_moonraker_lane_data.py | 12 +- src/slic3r/CMakeLists.txt | 2 + src/slic3r/GUI/Plater.cpp | 29 +- src/slic3r/GUI/Plater.hpp | 2 + src/slic3r/Utils/AmsPayload.cpp | 493 +++++++++++++++++ src/slic3r/Utils/AmsPayload.hpp | 93 ++++ src/slic3r/Utils/MoonrakerPrinterAgent.cpp | 321 +---------- src/slic3r/Utils/MoonrakerPrinterAgent.hpp | 24 +- src/slic3r/Utils/OrcaPrinterAgent.cpp | 518 ++++++++++++++++-- src/slic3r/Utils/OrcaPrinterAgent.hpp | 69 ++- src/slic3r/Utils/SnapmakerPrinterAgent.cpp | 55 +- src/slic3r/Utils/SnapmakerPrinterAgent.hpp | 7 + tests/slic3rutils/test_orca_printer_agent.cpp | 157 +++++- tests/slic3rutils/test_printer_agent.cpp | 130 +++++ 14 files changed, 1500 insertions(+), 412 deletions(-) create mode 100644 src/slic3r/Utils/AmsPayload.cpp create mode 100644 src/slic3r/Utils/AmsPayload.hpp diff --git a/scripts/test_moonraker_lane_data.py b/scripts/test_moonraker_lane_data.py index 16ed4924e0..02f7b9029f 100644 --- a/scripts/test_moonraker_lane_data.py +++ b/scripts/test_moonraker_lane_data.py @@ -24,7 +24,7 @@ NAMESPACE = "lane_data" # Repo-relative paths for the offline generic-map check REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -MOONRAKER_AGENT_CPP = os.path.join(REPO_ROOT, "src", "slic3r", "Utils", "MoonrakerPrinterAgent.cpp") +AMS_PAYLOAD_CPP = os.path.join(REPO_ROOT, "src", "slic3r", "Utils", "AmsPayload.cpp") OFL_FILAMENT_DIR = os.path.join(REPO_ROOT, "resources", "profiles", "OrcaFilamentLibrary", "filament") LANE_KEYS = [f"lane{i}" for i in range(1, 9)] # lane1-lane8 MATERIALS = ["PLA", "ABS", "PETG", "ASA", "ASA Sparkle", "TPU", ""] @@ -41,16 +41,16 @@ MATERIAL_TEMPS = { } def parse_cpp_type_map(): - """Extract the normalized-type -> OFL generic family table from MoonrakerPrinterAgent.cpp. + """Extract the normalized-type -> OFL generic family table from AmsPayload.cpp. - Reads MoonrakerPrinterAgent::map_filament_type_to_generic_id's type_to_ofl_family - initializer so the check tracks the C++ normalization without a duplicated list. + Reads map_filament_type_to_generic_id's type_to_ofl_family initializer so the + check tracks the C++ normalization without a duplicated list. """ - with open(MOONRAKER_AGENT_CPP, encoding="utf-8") as f: + with open(AMS_PAYLOAD_CPP, encoding="utf-8") as f: src = f.read() m = re.search(r"type_to_ofl_family\s*=\s*\{(.*?)\n\s*\};", src, re.DOTALL) if not m: - raise RuntimeError(f"type_to_ofl_family table not found in {MOONRAKER_AGENT_CPP}") + raise RuntimeError(f"type_to_ofl_family table not found in {AMS_PAYLOAD_CPP}") pairs = re.findall(r'\{\s*"([^"]+)"\s*,\s*"([^"]+)"\s*\}', m.group(1)) if not pairs: raise RuntimeError("type_to_ofl_family table parsed empty") diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index ac7974e7c2..9fd3d3f98f 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -740,6 +740,8 @@ set(SLIC3R_GUI_SOURCES Utils/OrcaMqttConnection.hpp Utils/OrcaPrinterAgent.cpp Utils/OrcaPrinterAgent.hpp + Utils/AmsPayload.cpp + Utils/AmsPayload.hpp Utils/OrcaCloudSignalingChannel.cpp Utils/OrcaCloudSignalingChannel.hpp Utils/QidiPrinterAgent.cpp diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 11cad80b63..1e5912da58 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -727,6 +727,7 @@ struct Sidebar::priv ScalableButton * m_bpButton_add_filament; ScalableButton * m_bpButton_del_filament; ScalableButton * m_bpButton_ams_filament; + bool m_ams_sync_button_show{true}; // last applied AMS sync button visibility ScalableButton * m_bpButton_set_filament; int m_menu_filament_id = -1; @@ -3427,6 +3428,22 @@ void Sidebar::remove_unused_filament_combos(const size_t current_extruder_count) } } +void Sidebar::update_ams_sync_button() +{ + if (!p->m_bpButton_ams_filament || !wxGetApp().preset_bundle) + return; + // BBL printers always advertise AMS sync; other agents follow the active + // agent's filament sync mode, which flips to subscription only after the + // printer's get_capabilities reply arrives. + const bool show = wxGetApp().preset_bundle->use_bbl_network() || + (wxGetApp().getAgent() && wxGetApp().getAgent()->get_filament_sync_mode() != FilamentSyncMode::none); + if (p->m_ams_sync_button_show == show) + return; + p->m_ams_sync_button_show = show; + p->m_bpButton_ams_filament->Show(show); + p->m_bpButton_ams_filament->GetParent()->Layout(); +} + void Sidebar::update_all_preset_comboboxes() { PresetBundle &preset_bundle = *wxGetApp().preset_bundle; @@ -3444,7 +3461,7 @@ void Sidebar::update_all_preset_comboboxes() //p->btn_connect_printer->Hide(); p->m_printer_connect->Hide(); //only show sync-ams button for BBL printer - p->m_bpButton_ams_filament->Show(); + update_ams_sync_button(); //update print button default value for bbl or third-party printer p_mainframe->set_print_button_to_default(MainFrame::PrintSelectType::ePrintPlate); } else { @@ -3453,11 +3470,7 @@ void Sidebar::update_all_preset_comboboxes() p->m_printer_connect->Show(!use_printer_agents); // ORCA: show/hide sync-ams button based on filament sync mode - auto agent = wxGetApp().getAgent(); - if (agent && agent->get_filament_sync_mode() != FilamentSyncMode::none) - p->m_bpButton_ams_filament->Show(); - else - p->m_bpButton_ams_filament->Hide(); + update_ams_sync_button(); // Orca: with "Support 3MF as gcode" (use_3mf) the local export is a .gcode.3mf bundle, so when no // printer host/IP is configured the default action is "Export plate sliced file" (mirrors the @@ -22152,10 +22165,14 @@ void Plater::update_machine_sync_status() DeviceManager *dev_maneger = wxGetApp().getDeviceManager(); if (!dev_maneger) { GUI::wxGetApp().sidebar().update_sync_status(nullptr); + GUI::wxGetApp().sidebar().update_ams_sync_button(); return; } MachineObject *obj = wxGetApp().getDeviceManager()->get_selected_machine(); GUI::wxGetApp().sidebar().update_sync_status(obj); + // The agent's sync mode flips once the printer's get_capabilities reply + // arrives; re-evaluate the filament-sync button on every device update. + GUI::wxGetApp().sidebar().update_ams_sync_button(); } bool Plater::get_machine_sync_status() diff --git a/src/slic3r/GUI/Plater.hpp b/src/slic3r/GUI/Plater.hpp index 84e64acea0..c816a0b24f 100644 --- a/src/slic3r/GUI/Plater.hpp +++ b/src/slic3r/GUI/Plater.hpp @@ -169,6 +169,8 @@ public: void init_filament_combo(PlaterPresetComboBox **combo, const int filament_idx); void remove_unused_filament_combos(const size_t current_extruder_count); void update_all_preset_comboboxes(); + // Show/hide the AMS filament-sync button from the active agent's sync mode. + void update_ams_sync_button(); //void update_partplate(PartPlateList& list); void update_presets(Slic3r::Preset::Type preset_type); //BBS diff --git a/src/slic3r/Utils/AmsPayload.cpp b/src/slic3r/Utils/AmsPayload.cpp new file mode 100644 index 0000000000..ce5853b781 --- /dev/null +++ b/src/slic3r/Utils/AmsPayload.cpp @@ -0,0 +1,493 @@ +#include "AmsPayload.hpp" + +#include "libslic3r/Preset.hpp" +#include "libslic3r/PresetBundle.hpp" +#include "slic3r/GUI/GUI_App.hpp" +#include "slic3r/GUI/DeviceManager.hpp" +#include "slic3r/GUI/DeviceCore/DevFilaSystem.h" +#include "slic3r/GUI/DeviceCore/DevManager.h" +#include "slic3r/GUI/DeviceCore/DevStorage.h" +#include "slic3r/GUI/DeviceCore/DevFirmware.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace Slic3r { + +std::string map_filament_type_to_generic_id(const std::string& filament_type) +{ + std::string upper = filament_type; + boost::trim(upper); + std::transform(upper.begin(), upper.end(), upper.begin(), + [](unsigned char c) { return static_cast(std::toupper(c)); }); + + // Normalize reported material names 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; +} + +std::string normalize_ams_color(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; +} + +bool parse_moonraker_lane_data(const nlohmann::json& body, + std::vector& trays, + int& max_lane_index) +{ + // Expected structure: { "result": { "namespace": "lane_data", "value": { "lane1": {...}, ... } } } + if (!body.is_object() || !body.contains("result") || !body["result"].is_object() || + !body["result"].contains("value") || !body["result"]["value"].is_object()) { + BOOST_LOG_TRIVIAL(warning) << "AmsPayload: unexpected lane_data response structure"; + return false; + } + + const auto& value = body["result"]["value"]; + trays.clear(); + max_lane_index = 0; + + for (const auto& lane_item : value.items()) { + const auto& lane_obj = lane_item.value(); + if (!lane_obj.is_object()) { + continue; + } + + // Extract lane index from the "lane" field (tool number, 0-based) + int lane_index = -1; + const auto lane_it = lane_obj.find("lane"); + if (lane_it != lane_obj.end() && lane_it->is_string()) { + try { + lane_index = std::stoi(lane_it->get()); + } catch (...) { + lane_index = -1; + } + } + + if (lane_index < 0) { + continue; + } + + AmsTrayData tray; + tray.slot_index = lane_index; + if (const auto it = lane_obj.find("color"); it != lane_obj.end() && it->is_string()) + tray.tray_color = it->get(); + if (const auto it = lane_obj.find("material"); it != lane_obj.end() && it->is_string()) + tray.tray_type = it->get(); + if (const auto it = lane_obj.find("bed_temp"); it != lane_obj.end() && it->is_number()) + tray.bed_temp = it->get(); + if (const auto it = lane_obj.find("nozzle_temp"); it != lane_obj.end() && it->is_number()) + tray.nozzle_temp = it->get(); + + // Presence is independent of declared material: a physically loaded lane + // with no user-declared material must not read as empty. Prefer an + // explicit signal, fall back to the legacy "has a material type" rule. + const auto has_it = lane_obj.find("has_filament"); + const auto loaded_it = lane_obj.find("loaded"); + if (has_it != lane_obj.end() && has_it->is_boolean()) + tray.has_filament = has_it->get(); + else if (loaded_it != lane_obj.end() && loaded_it->is_boolean()) + tray.has_filament = loaded_it->get(); + else + tray.has_filament = !tray.tray_type.empty(); + + max_lane_index = std::max(max_lane_index, lane_index); + trays.push_back(tray); + } + + if (trays.empty()) { + BOOST_LOG_TRIVIAL(info) << "AmsPayload: no lanes found"; + return false; + } + + return true; +} + +void resolve_tray_info_idx(std::vector& trays) +{ + auto* bundle = GUI::wxGetApp().preset_bundle; + for (auto& tray : trays) { + // Absent lanes render as placeholders; resolving an empty type is busy + // work at best and could bind a bogus id. + if (!tray.has_filament) + continue; + // A vendor-aware resolver may already have matched this lane; only fill + // what is still empty so the generic fallback cannot overwrite it. + if (!tray.tray_info_idx.empty()) + continue; + tray.tray_info_idx = bundle + ? bundle->filaments.filament_id_by_type(tray.tray_type) + : map_filament_type_to_generic_id(tray.tray_type); + } +} + +nlohmann::json build_bbl_ams_json(const std::vector& trays, + int ams_count, + int max_lane_index) +{ + nlohmann::json ams_array = nlohmann::json::array(); + + // ams_exist_bits marks the units; tray_exist_bits marks the occupied + // slots. uint64_t: unsigned long is 32-bit on MSVC, so 1UL << 32+ is UB. + // BBL's fields are 64-bit; a lane past 63 cannot be represented at all. + uint64_t ams_exist_bits = 0; + uint64_t tray_exist_bits = 0; + + auto set_exist_bit = [](uint64_t& bits, int index) { + if (index < 0) + return; + if (index >= 64) { + BOOST_LOG_TRIVIAL(warning) << "AmsPayload: lane index " << index << " exceeds the 64-bit exist-bits field; bit dropped"; + return; + } + bits |= (uint64_t{1} << index); + }; + + for (int ams_id = 0; ams_id < ams_count; ++ams_id) { + set_exist_bit(ams_exist_bits, ams_id); + + nlohmann::json ams_unit = nlohmann::json::object(); + ams_unit["id"] = std::to_string(ams_id); + ams_unit["info"] = "0002"; // treat as AMS_LITE + + 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; + } + } + + nlohmann::json tray_json = nlohmann::json::object(); + tray_json["id"] = std::to_string(slot_id); + tray_json["tag_uid"] = "0000000000000000"; + + if (tray && tray->has_filament) { + set_exist_bit(tray_exist_bits, slot_index); + + // Present lanes never carry tray_slot_placeholder: a loaded but + // undeclared lane stays occupied with an empty type, which is + // what distinguishes it from an absent slot. + tray_json["tray_info_idx"] = tray->tray_info_idx; + tray_json["tray_type"] = tray->tray_type; + tray_json["tray_color"] = normalize_ams_color(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"; + } + + tray_array.push_back(tray_json); + } + ams_unit["tray"] = tray_array; + ams_array.push_back(ams_unit); + } + + // 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(); + return ams_json; +} + +// --- Removal/absence state and op capability --------------------------------- + +// Last ams_count rendered per device: clear_ams_payload_for_device walks the +// same unit set to mark them all absent. +static std::mutex g_ams_state_mutex; +static std::map g_ams_last_count; +static std::map> g_ams_ops; +static std::map g_ams_capability; + +static void remember_ams_count(const std::string& dev_id, int ams_count) +{ + if (dev_id.empty() || ams_count <= 0) + return; + std::lock_guard lock(g_ams_state_mutex); + g_ams_last_count[dev_id] = std::max(g_ams_last_count[dev_id], ams_count); +} + +void register_ams_ops(const std::string& dev_id, const std::vector& ops) +{ + if (dev_id.empty()) + return; + std::lock_guard lock(g_ams_state_mutex); + g_ams_ops[dev_id] = ops; +} + +bool ams_op_supported(const std::string& dev_id, const std::string& op) +{ + std::lock_guard lock(g_ams_state_mutex); + auto it = g_ams_ops.find(dev_id); + if (it == g_ams_ops.end()) + return true; // no OrcaSonar capabilities seen: never gate + return std::find(it->second.begin(), it->second.end(), op) != it->second.end(); +} + +void register_ams_capability(const std::string& dev_id, bool has_ams) +{ + if (dev_id.empty()) + return; + std::lock_guard lock(g_ams_state_mutex); + g_ams_capability[dev_id] = has_ams; +} + +bool has_ams_capability(const std::string& dev_id) +{ + std::lock_guard lock(g_ams_state_mutex); + auto it = g_ams_capability.find(dev_id); + return it != g_ams_capability.end() && it->second; +} + +void build_ams_payload_for_device(const std::string& dev_id, + const std::optional& printer_type, + int ams_count, + int max_lane_index, + const std::vector& trays, + const QueueOnMainFn& queue_fn, + const TrayInfoResolver& vendor_resolver) +{ + remember_ams_count(dev_id, ams_count); + // A caller on the GUI thread must mutate DeviceManager inline: invoking + // queue_fn (CallAfter) would defer the work until after the caller has + // already read DevFilaSystem. Background callers route through queue_fn. + const bool on_main = wxIsMainThread(); + auto apply = [dev_id, printer_type, ams_count, max_lane_index, trays, vendor_resolver]() { + // Look up MachineObject via DeviceManager + auto* dev_manager = GUI::wxGetApp().getDeviceManager(); + if (!dev_manager) { + return; + } + MachineObject* obj = dev_manager->get_my_machine(dev_id); + if (!obj) { + return; + } + + // Resolve preset ids here: reads the GUI preset bundle, which is only safe + // on the main thread. trays is a copy, so mutating it is fine. The optional + // vendor resolver runs first; the generic resolver fills the rest. + std::vector resolved = trays; + if (vendor_resolver) + vendor_resolver(resolved); + resolve_tray_info_idx(resolved); + + // Wrap in the expected structure for ParseV1_0 + nlohmann::json print_json = nlohmann::json::object(); + print_json["ams"] = build_bbl_ams_json(resolved, ams_count, max_lane_index); + + // Call the parser to populate DevFilaSystem + DevFilaSystemParser::ParseV1_0(print_json, obj, obj->GetFilaSystem().get(), false); + BOOST_LOG_TRIVIAL(info) << "AmsPayload: parsed " << resolved.size() << " trays"; + + // Set printer_type so update_sync_status() can match it against the preset's + // printer type. nullopt = leave it (the caller's push_status already carries + // it); a value = assign, including empty, preserving the old behavior. + if (printer_type.has_value()) { + obj->printer_type = *printer_type; + } + + // 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. + if (obj->module_vers.empty()) { + DevFirmwareVersionInfo ota_info; + ota_info.name = "ota"; + ota_info.sw_ver = "1.0.0"; // Placeholder version + obj->module_vers.emplace("ota", ota_info); + } + }; + + if (queue_fn && !on_main) { + queue_fn(apply); + } else { + apply(); + } +} + +void clear_ams_payload_for_device(const std::string& dev_id, const QueueOnMainFn& queue_fn) +{ + int count = 0; + { + std::lock_guard lock(g_ams_state_mutex); + auto it = g_ams_last_count.find(dev_id); + if (it != g_ams_last_count.end()) + count = it->second; + g_ams_last_count[dev_id] = 0; + } + if (count == 0) + return; // nothing was ever rendered: nothing to clear + + const bool on_main = wxIsMainThread(); + auto apply = [dev_id, count]() { + auto* dev_manager = GUI::wxGetApp().getDeviceManager(); + if (!dev_manager) + return; + MachineObject* obj = dev_manager->get_my_machine(dev_id); + if (!obj) + return; + + // All units present-but-empty: exist bits 0 marks them absent while + // placeholder trays flush stale type/color data out of DevFilaSystem. + nlohmann::json units = nlohmann::json::array(); + for (int ams_id = 0; ams_id < count; ++ams_id) { + nlohmann::json trays = nlohmann::json::array(); + for (int slot_id = 0; slot_id < 4; ++slot_id) { + trays.push_back(nlohmann::json{ + {"id", std::to_string(slot_id)}, + {"tag_uid", "0000000000000000"}, + {"tray_info_idx", ""}, + {"tray_type", ""}, + {"tray_color", "00000000"}, + {"tray_slot_placeholder", "1"}, + }); + } + units.push_back(nlohmann::json{{"id", std::to_string(ams_id)}, {"info", "0002"}, {"tray", trays}}); + } + nlohmann::json ams_json; + ams_json["ams"] = units; + ams_json["ams_exist_bits"] = "0"; + ams_json["tray_exist_bits"] = "0"; + nlohmann::json print_json; + print_json["ams"] = ams_json; + DevFilaSystemParser::ParseV1_0(print_json, obj, obj->GetFilaSystem().get(), false); + BOOST_LOG_TRIVIAL(info) << "AmsPayload: cleared " << count << " AMS units for " << dev_id; + }; + if (queue_fn && !on_main) + queue_fn(apply); + else + apply(); +} + +} // namespace Slic3r diff --git a/src/slic3r/Utils/AmsPayload.hpp b/src/slic3r/Utils/AmsPayload.hpp new file mode 100644 index 0000000000..2ef99c3289 --- /dev/null +++ b/src/slic3r/Utils/AmsPayload.hpp @@ -0,0 +1,93 @@ +#ifndef __AMS_PAYLOAD_HPP__ +#define __AMS_PAYLOAD_HPP__ + +#include "bambu_networking.hpp" + +#include +#include +#include +#include +#include + +namespace Slic3r { + +// One lane of a multi-material system in the neutral shape every printer agent +// shares before it is rendered into the BBL AMS payload. +struct AmsTrayData { + int slot_index = 0; // 0-based global slot index + bool has_filament = false; + std::string tray_type; // Material type (e.g. "PLA", "ASA") + std::string tray_color; // Raw color (#RRGGBB, 0xRRGGBB, or RRGGBBAA) + std::string tray_info_idx; // OrcaFilamentLibrary preset id (optional) + int bed_temp = 0; // Optional + int nozzle_temp = 0; // Optional +}; + +// Normalize a driver color string to the BBL RRGGBBAA form; "00000000" when invalid. +std::string normalize_ams_color(const std::string& color); + +// Map a reported material type to an OrcaFilamentLibrary generic family id. +std::string map_filament_type_to_generic_id(const std::string& filament_type); + +// Parse a Moonraker `/server/database/item?namespace=lane_data` response body: +// result.value is keyed by lane, each entry carrying "lane", optional +// "material"/"color"/"bed_temp"/"nozzle_temp" and an optional presence signal +// ("has_filament" or "loaded"). Presence is preferred over material: a loaded +// lane with no declared material must not read as empty. Returns false when +// malformed or empty. Pure: tray_info_idx is left for resolve_tray_info_idx(). +bool parse_moonraker_lane_data(const nlohmann::json& body, + std::vector& trays, + int& max_lane_index); + +// Fill each tray's tray_info_idx from the loaded preset bundle (falling back to +// the generic family map). Reads GUI preset state, so it MUST run on the main +// thread. Ids already set by a vendor-aware resolver are left untouched. +void resolve_tray_info_idx(std::vector& trays); + +// Optional vendor-aware resolver, run on the main thread before the generic +// resolver. Agents with brand/color matching (Snapmaker, Creality) supply one +// so their results survive the shared path; the generic resolver only fills +// ids this leaves empty. +using TrayInfoResolver = std::function&)>; + +// Assemble the BBL-format AMS JSON (ams[] units + ams_exist_bits/tray_exist_bits) +// that DevFilaSystemParser::ParseV1_0 consumes. Pure and GUI-free: the +// MachineObject mutation stays in build_ams_payload_for_device. +nlohmann::json build_bbl_ams_json(const std::vector& trays, + int ams_count, + int max_lane_index); + +// Render trays into the BBL AMS payload and populate the device's DevFilaSystem. +// The resolver and the MachineObject mutation both run on the main thread via +// queue_fn when set. printer_type: nullopt leaves obj->printer_type untouched +// (OrcaSonar, whose push_status already carries it); a value assigns it +// verbatim (Moonraker). +void build_ams_payload_for_device(const std::string& dev_id, + const std::optional& printer_type, + int ams_count, + int max_lane_index, + const std::vector& trays, + const QueueOnMainFn& queue_fn, + const TrayInfoResolver& vendor_resolver = {}); + +// Clear the device's AMS view: render every previously-seen unit as absent +// with placeholder trays so a removed/absent material system never leaves +// stale filament data behind (lane_data read as authoritative empty). +void clear_ams_payload_for_device(const std::string& dev_id, const QueueOnMainFn& queue_fn); + +// Process-wide canonical AMS write capability (OrcaSonar REQ-STS-008), parsed +// from the info.get_capabilities reply. Devices with no record (Bambu, cloud +// profiles) report every op supported: gating only ever applies to OrcaSonar +// printers that answered. +void register_ams_ops(const std::string& dev_id, const std::vector& ops); +bool ams_op_supported(const std::string& dev_id, const std::string& op); + +// Whether the device has a material system, from the get_capabilities reply's +// protocol.features.fms (falling back to a non-empty ams_ops). No record reads +// false: an unconfirmed printer must not advertise filament sync. +void register_ams_capability(const std::string& dev_id, bool has_ams); +bool has_ams_capability(const std::string& dev_id); + +} // namespace Slic3r + +#endif diff --git a/src/slic3r/Utils/MoonrakerPrinterAgent.cpp b/src/slic3r/Utils/MoonrakerPrinterAgent.cpp index becd34d5f4..d0c07ae3c9 100644 --- a/src/slic3r/Utils/MoonrakerPrinterAgent.cpp +++ b/src/slic3r/Utils/MoonrakerPrinterAgent.cpp @@ -533,142 +533,27 @@ int MoonrakerPrinterAgent::set_queue_on_main_fn(QueueOnMainFn fn) void MoonrakerPrinterAgent::build_ams_payload(int ams_count, int max_lane_index, const std::vector& trays) { - // This may be called from a background thread (e.g. run_status_stream's read loop, - // for subscription-mode agents) as well as from the GUI thread (Sidebar's pull-mode - // path). Everything below touches MachineObject/DevFilaSystem, which the GUI thread - // reads without locking — so the actual mutation must run on the main thread. Snapshot - // queue_on_main_fn and the two device_info fields we need up front, then defer the rest, - // mirroring dispatch_message's existing queue_fn ? queue_fn(x) : x() idiom. + // May run from the status-stream thread (subscription agents) or the GUI + // thread (Sidebar's pull path). Snapshot queue_on_main_fn here; the shared + // builder defers the preset lookup and MachineObject mutation to the main + // thread. A heavy-handed empty model_id must still be assigned, so the + // optional wrapper always carries the value here. QueueOnMainFn queue_fn; { std::lock_guard lock(state_mutex); queue_fn = queue_on_main_fn; } + build_ams_payload_for_device(device_info.dev_id, std::optional(device_info.model_id), ams_count, max_lane_index, trays, queue_fn); +} - std::string dev_id = device_info.dev_id; - std::string model_id = device_info.model_id; - - auto apply = [dev_id, model_id, ams_count, max_lane_index, trays]() { - // Look up MachineObject via DeviceManager - auto* dev_manager = GUI::wxGetApp().getDeviceManager(); - if (!dev_manager) { - return; - } - MachineObject* obj = dev_manager->get_my_machine(dev_id); - if (!obj) { - return; - } - - // Build BBL-format JSON for DevFilaSystemParser::ParseV1_0 - nlohmann::json ams_json = nlohmann::json::object(); - 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) { - ams_exist_bits |= (1 << ams_id); - - nlohmann::json ams_unit = nlohmann::json::object(); - ams_unit["id"] = std::to_string(ams_id); - ams_unit["info"] = "0002"; // treat as AMS_LITE - - 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; - } - } - - nlohmann::json tray_json = nlohmann::json::object(); - tray_json["id"] = std::to_string(slot_id); - tray_json["tag_uid"] = "0000000000000000"; - - if (tray && tray->has_filament) { - tray_exist_bits |= (1 << slot_index); - - 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"; - } - - tray_array.push_back(tray_json); - } - ams_unit["tray"] = tray_array; - ams_array.push_back(ams_unit); - } - - // 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; - - 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; - - // Call the parser to populate DevFilaSystem - DevFilaSystemParser::ParseV1_0(print_json, obj, obj->GetFilaSystem().get(), false); - BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::build_ams_payload: Parsed " << trays.size() << " trays"; - - // 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 = 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); - } - }; - - if (queue_fn) { - queue_fn(apply); - } else { - apply(); +void MoonrakerPrinterAgent::build_ams_payload(int ams_count, int max_lane_index, const std::vector& trays, const TrayInfoResolver& vendor_resolver) +{ + QueueOnMainFn queue_fn; + { + std::lock_guard lock(state_mutex); + queue_fn = queue_on_main_fn; } + build_ams_payload_for_device(device_info.dev_id, std::optional(device_info.model_id), ams_count, max_lane_index, trays, queue_fn, vendor_resolver); } bool MoonrakerPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode) @@ -764,94 +649,7 @@ std::string MoonrakerPrinterAgent::trim_and_upper(const std::string& input) 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; -} - 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()) @@ -866,41 +664,6 @@ int MoonrakerPrinterAgent::safe_array_int(const nlohmann::json& arr, int idx) 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) { @@ -945,56 +708,11 @@ bool MoonrakerPrinterAgent::fetch_moonraker_filament_data(std::vectorfilaments.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_moonraker_filament_data: No lanes found"; + if (!parse_moonraker_lane_data(json, trays, max_lane_index)) { return false; } + // tray_info_idx is resolved later, on the main thread, inside + // build_ams_payload_for_device. return true; } @@ -1110,11 +828,6 @@ bool MoonrakerPrinterAgent::fetch_hh_filament_info(std::vector& tra 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); } diff --git a/src/slic3r/Utils/MoonrakerPrinterAgent.hpp b/src/slic3r/Utils/MoonrakerPrinterAgent.hpp index 0fc1959999..cdf90feee3 100644 --- a/src/slic3r/Utils/MoonrakerPrinterAgent.hpp +++ b/src/slic3r/Utils/MoonrakerPrinterAgent.hpp @@ -3,6 +3,7 @@ #include "IPrinterAgent.hpp" #include "ICloudServiceAgent.hpp" +#include "AmsPayload.hpp" #include #include @@ -84,19 +85,12 @@ protected: bool use_ssl = false; } device_info; - // Tray data for AMS payload building - struct AmsTrayData { - int slot_index = 0; // 0-based slot index - bool has_filament = false; - std::string tray_type; // Material type (e.g., "PLA", "ASA") - std::string tray_color; // Raw color (#RRGGBB, 0xRRGGBB, or RRGGBBAA) - std::string tray_info_idx; // Setting ID (optional) - int bed_temp = 0; // Optional - int nozzle_temp = 0; // Optional - }; - - // Build ams JSON and call parser + // Build ams JSON and call parser (AmsTrayData is shared; see AmsPayload.hpp). void build_ams_payload(int ams_count, int max_lane_index, const std::vector& trays); + // Overload for agents with vendor-aware matching (Snapmaker): the resolver + // runs on the main thread inside the shared builder, preserving the match + // without touching preset state on the fetch worker. + void build_ams_payload(int ams_count, int max_lane_index, const std::vector& trays, const TrayInfoResolver& vendor_resolver); // Methods that derived classes may need to override or access virtual bool init_device_info(std::string dev_id, std::string dev_ip, std::string username, std::string password, bool use_ssl); @@ -121,9 +115,6 @@ protected: // Trim whitespace and convert to uppercase static std::string trim_and_upper(const std::string& input); - // Map filament type to OrcaFilamentLibrary preset ID for AMS sync compatibility - static std::string map_filament_type_to_generic_id(const std::string& filament_type); - // Send a G-code script via Moonraker (/printer/gcode/script) bool send_gcode(const std::string& dev_id, const std::string& gcode) const; bool send_gcode(const std::string& dev_id, const std::string& gcode, @@ -188,11 +179,8 @@ private: bool fetch_moonraker_filament_data(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; diff --git a/src/slic3r/Utils/OrcaPrinterAgent.cpp b/src/slic3r/Utils/OrcaPrinterAgent.cpp index 79ec06111f..9580cecfd1 100644 --- a/src/slic3r/Utils/OrcaPrinterAgent.cpp +++ b/src/slic3r/Utils/OrcaPrinterAgent.cpp @@ -1,5 +1,6 @@ #include "OrcaPrinterAgent.hpp" #include "OrcaCloudSignalingChannel.hpp" +#include "AmsPayload.hpp" #include "Http.hpp" #include "IPrinterAgent.hpp" #include "NetworkAgentFactory.hpp" @@ -448,6 +449,15 @@ OrcaPrinterAgent::~OrcaPrinterAgent() start_discovery(false, false); ++m_lan_generation; // fence any late worker callback ++m_cloud_generation; + { + std::lock_guard lock(state_mutex); + m_shutting_down = true; // workers stop arming new HTTP fetches + } + + // Drain the detached filament-refresh workers: the flag and generation bump + // above end their loops, so this waits at most one in-flight HTTP fetch. + while (m_filament_in_flight.load() > 0) + std::this_thread::sleep_for(std::chrono::milliseconds(20)); // Drop the cloud status callback before anything else: it holds `this`, and the // cloud agent outlives the printer agent (NetworkAgent::set_printer_agent swaps @@ -541,7 +551,7 @@ std::string OrcaPrinterAgent::merge_capabilities(const std::string& dev_id, cons // removes its storage too. Process-wide and keyed by the globally-unique // dev_id, with its own mutex; C++11 makes the one-time init thread-safe. static std::unordered_map nozzle_diameter_cache; - static std::mutex nozzle_diameter_cache_mutex; + static std::mutex nozzle_diameter_cache_mutex; bool modified = false; @@ -557,7 +567,7 @@ std::string OrcaPrinterAgent::merge_capabilities(const std::string& dev_id, cons // ("N/A" -> NozzleType::ntUndefine, as MoonrakerPrinterAgent // does; Klipper has no Bambu nozzle type) onto later push_status // frames that carry no real nozzle data. - const auto info_it = envelope.find("info"); + const auto info_it = envelope.find("info"); const bool is_capabilities_reply = info_it != envelope.end() && info_it->is_object() && info_it->value("command", "") == "get_capabilities"; @@ -590,13 +600,46 @@ std::string OrcaPrinterAgent::merge_capabilities(const std::string& dev_id, cons std::lock_guard l(nozzle_diameter_cache_mutex); nozzle_diameter_cache[dev_id] = nozzle_dia; } - // The capabilities reply itself is forwarded unchanged. - } - else { - const auto print_it = envelope.find("print"); - if (print_it != envelope.end() && print_it->is_object() && - print_it->value("command", "") == "push_status" && !print_it->contains("nozzle_diameter")) { + // The capabilities reply itself is forwarded unchanged. Its declared + // ams_ops (OPCP §7.8) gate every AMS control, and protocol.features.fms + // declares whether a material system actually exists. + { + const auto caps_it = info_it->find("capabilities"); + if (caps_it != info_it->end() && caps_it->is_object()) { + const auto proto_it = caps_it->find("protocol"); + if (proto_it != caps_it->end() && proto_it->is_object()) { + const auto ops_it = proto_it->find("ams_ops"); + std::vector ops; + if (ops_it != proto_it->end() && ops_it->is_array()) { + for (const auto& op : *ops_it) + if (op.is_string()) + ops.push_back(op.get()); + register_ams_ops(dev_id, ops); + } + // features.fms is the authoritative "has a material system" + // flag (topology.material_units non-empty). Payloads without + // it fall back to a non-empty ams_ops. + bool has_ams = false; + bool fms_known = false; + const auto features_it = proto_it->find("features"); + if (features_it != proto_it->end() && features_it->is_object()) { + const auto fms_it = features_it->find("fms"); + if (fms_it != features_it->end() && fms_it->is_boolean()) { + has_ams = fms_it->get(); + fms_known = true; + } + } + if (!fms_known) + has_ams = !ops.empty(); + register_ams_capability(dev_id, has_ams); + } + } + } + } else { + const auto print_it = envelope.find("print"); + if (print_it != envelope.end() && print_it->is_object() && print_it->value("command", "") == "push_status" && + !print_it->contains("nozzle_diameter")) { double nozzle_dia = 0.0; { std::lock_guard l(nozzle_diameter_cache_mutex); @@ -607,7 +650,7 @@ std::string OrcaPrinterAgent::merge_capabilities(const std::string& dev_id, cons if (nozzle_dia > 0.0) { (*print_it)["nozzle_diameter"] = nozzle_dia; (*print_it)["nozzle_type"] = "N/A"; - modified = true; + modified = true; } } } @@ -618,6 +661,11 @@ std::string OrcaPrinterAgent::merge_capabilities(const std::string& dev_id, cons void OrcaPrinterAgent::deliver_to_sink(const std::string& dev_id, const std::string& payload, bool local) { + // Subscription doorbell, on the raw payload before the UI marshal so the + // (possibly blocking) lane_data refresh never queues behind it. + if (local && filament_doorbell_needed(dev_id, payload)) + request_filament_refresh(dev_id); + parse_ipcam_info(dev_id, payload); std::string merged_payload = merge_capabilities(dev_id, payload); @@ -629,10 +677,11 @@ void OrcaPrinterAgent::deliver_to_sink(const std::string& dev_id, const std::str q = queue_on_main_fn; } BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: delivering " << (local ? "local" : "cloud") << " report payload dev_id=" << dev_id - << " payload=" << merged_payload - << " callback=" << (fn ? "set" : "null") << " queue_on_main=" << (q ? "set" : "null"); + << " payload=" << merged_payload << " callback=" << (fn ? "set" : "null") + << " queue_on_main=" << (q ? "set" : "null"); if (!fn) { - BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: dropping " << (local ? "local" : "cloud") << " message because on_message_fn is not set" + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: dropping " << (local ? "local" : "cloud") + << " message because on_message_fn is not set" << " dev_id=" << dev_id; return; } @@ -654,6 +703,20 @@ void OrcaPrinterAgent::dispatch_local_connect(int state, const std::string& dev_ BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: LAN connection callback state=" << state << " dev_id=" << dev_id << " message=" << message << " callback=" << (callback ? "set" : "null") << " queue_on_main=" << (queue ? "set" : "null"); + + // Eager filament sync on every (re)connect, like the Moonraker agents: the + // cached DevFilaSystem may predate the drop. The doorbell cache resets too, + // so the post-connect frame re-arms if the lane content moved meanwhile. + // Runs before the callback check so it also fires when no GUI listener is + // installed yet. + if (state == ConnectStatusOk) { + { + std::lock_guard lock(state_mutex); + m_material_hash.clear(); + } + request_filament_refresh(dev_id); + } + if (!callback) return; @@ -745,7 +808,9 @@ int OrcaPrinterAgent::command_ams_select_tray(std::string dev_id, std::string tr nlohmann::json j; j["print"]["command"] = "ams_change_filament"; j["print"]["sequence_id"] = std::to_string(sequence_id); - j["print"]["target"] = tray_number; + // tray_id here is the flat global lane (DevFilaSystem slot index). + j["print"]["selector"] = "lane"; + j["print"]["lane"] = tray_number; return route_send(lan_mode, dev_id, j.dump()); } @@ -843,6 +908,240 @@ int OrcaPrinterAgent::command_axis_control(std::string dev_id, return route_send(lan_mode, dev_id, j.dump()); } +FilamentSyncMode OrcaPrinterAgent::get_filament_sync_mode() const +{ + std::string dev_id; + { + std::lock_guard lock(state_mutex); + dev_id = (m_current_connection == LAN) ? m_lan_dev_id : selected_machine; + } + if (!dev_id.empty() && has_ams_capability(dev_id)) { + return FilamentSyncMode::subscription; + } + return FilamentSyncMode::none; +} + +bool OrcaPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode) +{ + if (sync_mode != get_filament_sync_mode()) + return false; + return fetch_lane_data(dev_id) == LaneDataState::synced; +} + +// Read the lane_data projection once and apply the REQ-STS-007 tri-state to +// DevFilaSystem. Only LaneDataState::error arms the retry latch: 404 means +// "not knowable yet" (pre-bootstrap or acknowledged-unknown topology) and {} +// means "authoritatively no lanes" — both are settled states, and a printer +// without a material system must not turn into a per-frame 404 poll. +OrcaPrinterAgent::LaneDataState OrcaPrinterAgent::fetch_lane_data(const std::string& dev_id) +{ + std::string origin; + QueueOnMainFn queue_fn; + { + std::lock_guard lock(state_mutex); + if (m_shutting_down || m_current_connection != LAN || m_lan_dev_id != dev_id) + return LaneDataState::unknown; + origin = m_lan_http_origin; + queue_fn = queue_on_main_fn; + } + if (origin.empty()) + return LaneDataState::unknown; + const std::string api_key = lan_api_key(origin); + + // OrcaSonar serves the canonical topology's lane projection on its + // Moonraker-compatible façade. Called on the refresh worker (subscription + // mode), so the payload mutation is marshalled onto the main thread through + // queue_fn; a GUI-thread caller reads DevFilaSystem inline. + const std::string url = origin + "/server/database/item?namespace=lane_data"; + + std::string response_body; + unsigned http_status = 0; + bool transport_error = 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) { + http_status = status; + if (status == 200) { + response_body = std::move(body); + } else { + http_error = "HTTP error: " + std::to_string(status); + } + }) + .on_error([&](std::string, std::string err, unsigned status) { + transport_error = true; + http_status = status; + http_error = err; + if (status > 0) + http_error += " (HTTP " + std::to_string(status) + ")"; + }) + .perform_sync(); + + if (http_status == 404 && !transport_error) { + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::fetch_lane_data: lane_data not served yet (unknown topology)"; + return LaneDataState::unknown; + } + if (http_status != 200) { + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::fetch_lane_data: lane_data fetch failed: " << http_error; + return LaneDataState::error; + } + + auto json = nlohmann::json::parse(response_body, nullptr, false, true); + if (json.is_discarded()) { + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent::fetch_lane_data: invalid lane_data JSON"; + return LaneDataState::error; + } + const bool empty_value = json.is_object() && json.contains("result") && json["result"].is_object() && + json["result"].contains("value") && json["result"]["value"].is_object() && json["result"]["value"].empty(); + if (empty_value) { + // Authoritative empty: flush stale trays so a removed AMS does not + // linger in the device panel. + clear_ams_payload_for_device(dev_id, queue_fn); + return LaneDataState::none; + } + + std::vector trays; + int max_lane_index = 0; + if (!parse_moonraker_lane_data(json, trays, max_lane_index)) + return LaneDataState::error; + + const int ams_count = (max_lane_index + 4) / 4; + // printer_type stays unset: push_status already carries the OrcaSonar printer + // type, and overwriting it here would clear it. build_ams_payload_for_device + // marshals the DevFilaSystem mutation through queue_fn when set. + build_ams_payload_for_device(dev_id, std::nullopt, ams_count, max_lane_index, trays, queue_fn); + return LaneDataState::synced; +} + +// Subscription scheduler for filament sync. A single detached worker drains +// m_filament_wanted; extra requests arriving while it runs fold into its loop, so +// a burst of topology_state doorbells costs one extra fetch that picks up the +// trailing change. A failed fetch does not busy-retry: m_filament_failed makes +// the next inbound LAN frame request again (an idle printer is silent, but it +// cannot have changed lanes either), and a reconnect re-primes via the +// ConnectStatusOk path in dispatch_local_connect(). +void OrcaPrinterAgent::request_filament_refresh(const std::string& dev_id) +{ + uint64_t gen; + { + std::lock_guard lock(state_mutex); + if (m_current_connection != LAN || m_lan_dev_id != dev_id) + return; + gen = m_lan_generation.load(); + m_filament_wanted = true; + if (m_filament_working) + return; // the running worker will take the flag + m_filament_working = true; + } + + m_filament_in_flight.fetch_add(1, std::memory_order_relaxed); + std::thread([this, dev_id, gen] { + struct InFlightGuard + { + std::atomic& counter; + ~InFlightGuard() { counter.fetch_sub(1, std::memory_order_relaxed); } + } guard{m_filament_in_flight}; + + for (;;) { + bool needed; + { + std::lock_guard lock(state_mutex); + needed = !m_shutting_down && m_filament_wanted && m_lan_generation.load() == gen && m_current_connection == LAN && + m_lan_dev_id == dev_id; + if (needed) + m_filament_wanted = false; + else + m_filament_working = false; // same critical section that saw no work: no lost wake-up + } + if (!needed) + return; + const auto state = fetch_lane_data(dev_id); + std::lock_guard lock(state_mutex); + m_filament_failed = state == LaneDataState::error; // latch read by filament_doorbell_needed() + } + }).detach(); +} + +// A LAN frame is a refresh trigger when it carries an OrcaSonar material +// change (a new print.topology_state.material_hash, spec REQ-STS-007 §7.7) or +// when the last fetch errored and this frame is the retry beat. The substring +// guard keeps the JSON parse off the steady temp-tick cadence, and hash +// equality absorbs the tick's topology_state re-emissions. +bool OrcaPrinterAgent::filament_doorbell_needed(const std::string& dev_id, const std::string& payload) +{ + { + std::lock_guard lock(state_mutex); + if (m_current_connection != LAN || m_lan_dev_id != dev_id) + return false; + if (m_filament_failed) + return true; + } + // The cheap guard is the §7.7 doorbell token itself, so the steady + // temperature-only tick never reaches the JSON parse. + if (payload.find("material_hash") == std::string::npos) + return false; + auto json = nlohmann::json::parse(payload, nullptr, false); + if (json.is_discarded() || !json.is_object()) + return false; + const auto print_it = json.find("print"); + if (print_it == json.end() || !print_it->is_object()) + return false; + const auto topo_it = print_it->find("topology_state"); + if (topo_it == print_it->end() || !topo_it->is_object()) + return false; + const auto hash_it = topo_it->find("material_hash"); + if (hash_it == topo_it->end() || !hash_it->is_string()) + return false; + + std::lock_guard lock(state_mutex); + if (hash_it->get() == m_material_hash) + return false; // content unchanged: not a doorbell + m_material_hash = hash_it->get(); + return true; +} + +// Moonraker's client bootstrap: /access/api_key hands the façade key to a +// trusted source (the default LAN ranges include the slicer). Cache it per +// connection generation. If the request is refused — a hardened trusted_clients +// list, or no façade — fall back to the access code, which is what earlier +// builds sent, so behavior degrades to the status quo rather than breaking. +std::string OrcaPrinterAgent::lan_api_key(const std::string& origin) +{ + if (origin.empty()) + return {}; + + std::string fallback; + uint64_t gen; + { + std::lock_guard lock(state_mutex); + if (!m_lan_api_key.empty() && m_lan_api_key_gen == m_lan_generation.load()) + return m_lan_api_key; + fallback = m_lan_password; + gen = m_lan_generation.load(); + } + + std::string resolved; + if (std::string body; fetch_orcasonar_body(origin + "/access/api_key", body)) { + auto j = nlohmann::json::parse(body, nullptr, false, true); + if (!j.is_discarded() && j.contains("result") && j["result"].is_string()) + resolved = j["result"].get(); + } + if (resolved.empty()) { + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: /access/api_key unavailable; using the access code as X-Api-Key"; + resolved = fallback; + } + + std::lock_guard lock(state_mutex); + if (m_lan_generation.load() == gen && !resolved.empty()) { + m_lan_api_key = resolved; + m_lan_api_key_gen = gen; + } + return resolved; +} + bool OrcaPrinterAgent::parse_lan_endpoint(const std::string& dev_ip, std::string& host, std::string& port) { std::string s = dev_ip; @@ -1040,9 +1339,13 @@ int OrcaPrinterAgent::connect_printer(std::string dev_id, std::string dev_ip, st CurrentConn previous_connection; { std::lock_guard l(state_mutex); - previous_connection = m_current_connection; - m_lan_dev_id = dev_id; - m_lan_url = cfg.url; + previous_connection = m_current_connection; + m_lan_dev_id = dev_id; + m_lan_url = cfg.url; + m_lan_http_origin = http_origin_from_lan_ws(cfg.url); + m_lan_password = password; // access code; the façade key is bootstrapped lazily + m_lan_api_key.clear(); + m_lan_api_key_gen = gen; m_camera_stream_mode = CameraStreamMode::none; m_camera_url.clear(); m_current_connection = LAN; @@ -1116,6 +1419,10 @@ int OrcaPrinterAgent::disconnect_printer() doomed = std::move(lan_mqtt_connection); prev_dev = m_lan_dev_id; m_lan_dev_id.clear(); + m_lan_http_origin.clear(); + m_lan_api_key.clear(); + m_filament_wanted = false; // a stale worker self-exits on the generation mismatch + m_filament_failed = false; if (m_current_connection == LAN) { m_current_connection = NONE; m_camera_stream_mode = CameraStreamMode::none; @@ -1145,11 +1452,107 @@ int OrcaPrinterAgent::disconnect_printer() int OrcaPrinterAgent::send_message_to_printer(std::string dev_id, std::string json_str, int /*qos*/, int /*flag*/) { return route_send(/*is_lan=*/true, dev_id, json_str); } +// Rewrite Bambu-convention print.ams_* payloads onto the canonical OrcaSonar +// bodies (OPCP spec §7.8) and enforce the device's declared ams_ops. DevFilaSystem +// is built from flat lane indices, so the Bambu encodings the shared +// MachineObject command builders emit — ams_id/slot_id 4-tray pseudo-groups, +// virtual tray ids 254/255 — are decoded here, at the single funnel all print +// commands cross, and never reach the server. A command whose op token the +// device did not declare short-circuits as *unsupported (CAP_NOT_AVAILABLE), +// which publish_json already renders as the friendly unsupported dialog. +std::string OrcaPrinterAgent::canonicalize_ams_payload(const std::string& dev_id, const std::string& json_str, bool* unsupported) +{ + if (unsupported) + *unsupported = false; + try { + auto envelope = nlohmann::json::parse(json_str, nullptr, false); + if (envelope.is_discarded() || !envelope.is_object() || !envelope.contains("print") || !envelope["print"].is_object()) + return json_str; + auto& print = envelope["print"]; + const std::string cmd = print.value("command", std::string()); + if (cmd.empty() || (cmd.rfind("ams_", 0) != 0 && cmd != "auto_stop_ams_dry")) + return json_str; + if (cmd == "ams_change_filament" && print.contains("selector")) + return json_str; // already canonical (e.g. command_ams_select_tray) + + auto int_or = [&print](const char* key, int fallback) { + const auto it = print.find(key); + return (it != print.end() && it->is_number_integer()) ? it->get() : fallback; + }; + std::string op; + if (cmd == "ams_change_filament") { + const int target = int_or("target", -1); + const int slot = int_or("slot_id", -1); + const int ams = int_or("ams_id", -1); + print.erase("target"); + print.erase("slot_id"); + print.erase("tray_id"); + print.erase("ams_id"); + if (target == 255 && slot == 255) { + print["selector"] = "unload"; + op = "unload"; + } else if (target == 255 || ams == 254 || ams == 255) { + print["selector"] = "external"; + op = "external"; + } else { + const int lane = target >= 0 ? target : (ams >= 0 ? ams * 4 + slot : slot); + print["selector"] = "lane"; + print["lane"] = lane; + op = "change_filament"; + } + } else if (cmd == "ams_filament_setting") { + const int ams = int_or("ams_id", -1); + const int slot = int_or("slot_id", -1); + op = "filament_setting"; + if (ams >= 254) { + if (unsupported) + *unsupported = true; // no lane for a virtual tray + return json_str; + } + print["lane"] = ams * 4 + slot; + print.erase("slot_id"); + print.erase("tray_id"); + print.erase("ams_id"); + } else if (cmd == "ams_control") { + std::string action = print.value("action", print.value("param", std::string())); + op = action; // only "pause" is ever declared; the rest gate out + } else if (cmd == "ams_user_setting") { + op = "user_setting"; + } else if (cmd == "ams_get_rfid") { + op = "get_rfid"; + if (!print.contains("tray_id")) { + const int ams = int_or("ams_id", -1); + const int slot = int_or("slot_id", -1); + if (ams >= 0 && slot >= 0) { + print["tray_id"] = ams * 4 + slot; // legacy ams+slot call shape + print.erase("ams_id"); + print.erase("slot_id"); + } + } + } else if (cmd == "auto_stop_ams_dry") { + op = "stop_dry"; + } + if (!op.empty() && !ams_op_supported(dev_id, op)) { + if (unsupported) + *unsupported = true; + } + return envelope.dump(); + } catch (const std::exception&) { + return json_str; + } +} + int OrcaPrinterAgent::route_send(bool is_lan, const std::string& dev_id, const std::string& json_str) { + bool unsupported = false; + const std::string canonical = canonicalize_ams_payload(dev_id, json_str, &unsupported); + if (unsupported) { + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::route_send: ams op not declared by device capabilities, dev_id=" << dev_id; + return ORCA_NETWORK_ERR_CAP_NOT_AVAILABLE; + } std::string command = ""; try { - const nlohmann::json envelope = nlohmann::json::parse(json_str); + const nlohmann::json envelope = nlohmann::json::parse(canonical); for (const char* namespace_name : {"pushing", "info", "print", "system", "camera", "xcam", "upgrade", "event", "files"}) { const auto namespace_it = envelope.find(namespace_name); if (namespace_it != envelope.end() && namespace_it->is_object()) { @@ -1171,7 +1574,7 @@ int OrcaPrinterAgent::route_send(bool is_lan, const std::string& dev_id, const s OrcaMqttConnection* conn = get_appropriate_mqtt_connection(is_lan); if (!conn) return BAMBU_NETWORK_ERR_INVALID_HANDLE; - const bool queued = conn->send_request(dev_id, json_str); + const bool queued = conn->send_request(dev_id, canonical); BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::route_send command=" << command << " queued=" << queued << " is_lan=" << is_lan << " dev_id=" << dev_id; return queued ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED; @@ -1345,9 +1748,9 @@ int OrcaPrinterAgent::set_user_selected_machine(std::string dev_id) auto state_handler = [this](bool connected, bool initial) { if (!connected || initial) return; - auto* current_cloud = get_orca_cloud_agent(); + auto* current_cloud = get_orca_cloud_agent(); OrcaMqttConnection* current_conn = current_cloud ? current_cloud->get_mqtt_connection() : nullptr; - const std::string selected = get_user_selected_machine(); + const std::string selected = get_user_selected_machine(); if (current_conn && !selected.empty()) on_connected(selected, current_conn, m_cloud_generation.load()); }; @@ -1370,7 +1773,8 @@ AgentInfo OrcaPrinterAgent::get_agent_info_static() // Print Job Operations - All Stubs // ============================================================================ -// Simply uploads the file to the printer via HTTP (cloud) then sends a HTTP request to start print. In the future, this might be a MQTT command to start print instead of HTTP. +// Simply uploads the file to the printer via HTTP (cloud) then sends a HTTP request to start print. In the future, this might be a MQTT +// command to start print instead of HTTP. int OrcaPrinterAgent::start_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) { (void) wait_fn; @@ -1476,37 +1880,49 @@ int OrcaPrinterAgent::start_send_gcode_to_sdcard(PrintParams params, std::string http_error; std::string response_body; + // One façade key for the whole upload exchange (directory probe + POST). + // If the bootstrapped key is unavailable (and there was no stored access + // code), fall back to the job's own password rather than sending none. + std::string api_key = lan_api_key(origin); + if (api_key.empty()) + api_key = params.password; + // check if printer has enough storage - Http::get(origin + "/server/files/directory?path=gcodes") - .on_complete([&](std::string body, unsigned status) { - if (body.empty()) { - http_status = 400; - http_error = "Failed to get gcodes directory."; - } - - int free = 0; - - nlohmann::json json = nlohmann::json::parse(body); - if (json.contains("result")) { - json = json["result"]; - if (json.contains("disk_usage")) { - json = json["disk_usage"]; - if (json.contains("free")) - free = json["free"].get(); + { + auto dir_req = Http::get(origin + "/server/files/directory?path=gcodes"); + if (!api_key.empty()) + dir_req.header("X-Api-Key", api_key); + dir_req + .on_complete([&](std::string body, unsigned status) { + if (body.empty()) { + http_status = 400; + http_error = "Failed to get gcodes directory."; } - } - if (free < file_size) { - http_status = 507; - http_error = "Not enough storage on the printer."; - } - }) - .on_error([&](std::string body, std::string err, unsigned status) { - http_status = status; - http_error = std::move(err); - response_body = std::move(body); - }) - .perform_sync(); + int free = 0; + + nlohmann::json json = nlohmann::json::parse(body); + if (json.contains("result")) { + json = json["result"]; + if (json.contains("disk_usage")) { + json = json["disk_usage"]; + if (json.contains("free")) + free = json["free"].get(); + } + } + + if (free < file_size) { + http_status = 507; + http_error = "Not enough storage on the printer."; + } + }) + .on_error([&](std::string body, std::string err, unsigned status) { + http_status = status; + http_error = std::move(err); + response_body = std::move(body); + }) + .perform_sync(); + } if (http_status >= 400) { BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << " failed with error code: " << http_status << ", " << http_error; @@ -1514,8 +1930,8 @@ int OrcaPrinterAgent::start_send_gcode_to_sdcard(PrintParams params, } auto http = Http::post(origin + "/server/files/upload"); - if (!params.password.empty()) - http.header("X-Api-Key", params.password); // trusted LAN facades may not require it; harmless when they do not + if (!api_key.empty()) + http.header("X-Api-Key", api_key); // bootstrapped façade key, or the access code fallback http.form_add("root", "gcodes") .form_add("print", "false") .form_add_file("file", source, upload_name) diff --git a/src/slic3r/Utils/OrcaPrinterAgent.hpp b/src/slic3r/Utils/OrcaPrinterAgent.hpp index 60a25cae7a..a451b36a4f 100644 --- a/src/slic3r/Utils/OrcaPrinterAgent.hpp +++ b/src/slic3r/Utils/OrcaPrinterAgent.hpp @@ -36,8 +36,7 @@ public: void set_cloud_agent(std::shared_ptr cloud) override; CameraStreamMode get_camera_stream_mode() const override; std::string get_camera_url() const override; - std::unique_ptr - create_camera_signaling_channel(const std::string& dev_id) override; + std::unique_ptr create_camera_signaling_channel(const std::string& dev_id) override; // Communication int send_message(std::string dev_id, std::string json_str, int qos, int flag) override; @@ -100,6 +99,14 @@ public: int sequence_id, bool lan_mode) override; + // Filament sync (subscription): `subscription` when the selected printer + // reports a material system (get_capabilities protocol.features.fms), + // `none` otherwise. The mode is transport-agnostic; the lane_data fetch and + // topology_state doorbell that keep DevFilaSystem fresh are LAN-only, and + // cloud printers get their AMS view from the mirrored push_status. + FilamentSyncMode get_filament_sync_mode() const override; + bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override; + // Test-only: drive emit_connect_sequence directly (no socket). void run_connect_sequence_for_test(const std::string& dev_id) { @@ -111,7 +118,22 @@ public: // Test-only: the same for the (independent) cloud selection epoch. void bump_cloud_generation_for_test() { ++m_cloud_generation; } - FilamentSyncMode get_filament_sync_mode() const override { return FilamentSyncMode::subscription; } + // Test-only: observe the subscription doorbell policy without spawning the refresh worker. + bool filament_doorbell_needed_for_test(const std::string& dev_id, const std::string& payload) + { + return filament_doorbell_needed(dev_id, payload); + } + // Test-only: arm the fetch-failure latch (retry-on-next-LAN-frame rule). + void set_filament_failed_for_test(bool v) + { + std::lock_guard lock(state_mutex); + m_filament_failed = v; + } + // Test-only: Bambu ams_* wire JSON -> canonical OrcaSonar bodies (§7.8). + // Returns the (possibly rewritten) payload; *unsupported is set when the + // device's declared ams_ops exclude the operation (CAP_NOT_AVAILABLE in + // the live route_send path). + static std::string canonicalize_ams_payload(const std::string& dev_id, const std::string& json_str, bool* unsupported); protected: // Forward one inbound printer message to on_message_fn or on_local_message_fn (marshalled onto the UI @@ -191,10 +213,45 @@ private: std::unique_ptr m_discovery; - std::string m_lan_dev_id; // guarded by state_mutex - std::string m_lan_url; // guarded by state_mutex — the Config.url of the live LAN session + std::string m_lan_dev_id; // guarded by state_mutex + std::string m_lan_url; // guarded by state_mutex — the Config.url of the live LAN session + std::string m_lan_http_origin; // guarded by state_mutex — http(s) origin of the LAN façade + std::string m_lan_password; // guarded by state_mutex — MQTT access code; X-Api-Key fallback + std::string m_lan_api_key; // guarded by state_mutex — cached Moonraker-façade key, "" = unresolved + uint64_t m_lan_api_key_gen = 0; // m_lan_generation the cached key belongs to CameraStreamMode m_camera_stream_mode = CameraStreamMode::none; // guarded by state_mutex - std::string m_camera_url; // guarded by state_mutex + std::string m_camera_url; // guarded by state_mutex + + // The Moonraker-façade X-Api-Key, bootstrapped from /access/api_key (trusted + // clients only) and cached per connection generation; falls back to the + // access code when the endpoint is unavailable (untrusted/hardened config). + std::string lan_api_key(const std::string& origin); + + // Subscription-mode filament sync. One detached worker per burst drains + // m_filament_wanted by re-reading lane_data; doorbell frames (a pushed + // print.topology_state change) and the failure latch are the only triggers — + // no timer: an idle OrcaSonar emits no frames, and cannot change lanes either. + void request_filament_refresh(const std::string& dev_id); + // OPCP §7.7: the doorbell is a CHANGE in print.topology_state.material_hash + // (lane content only — tool temperature churn inside topology_state must not + // re-fetch); consuming a new value updates m_material_hash. + bool filament_doorbell_needed(const std::string& dev_id, const std::string& payload); + + // Tri-state lane_data fetch outcome (REQ-STS-007 §7.7 read semantics): + // synced — 200 with lane entries, payload built; + // none — 200 with an empty value: authoritative "no lanes", AMS view cleared; + // unknown — 404: topology not bootstrapped yet or material_units unknown; + // never latched, the next doorbell or reconnect retries; + // error — transport/5xx: latched, retried on the next inbound frame. + enum class LaneDataState { synced, none, unknown, error }; + LaneDataState fetch_lane_data(const std::string& dev_id); + + bool m_filament_wanted = false; // guarded by state_mutex; a fetch is pending + bool m_filament_working = false; // guarded by state_mutex; a worker owns the queue + bool m_filament_failed = false; // guarded by state_mutex; retry-on-next-LAN-frame rule + bool m_shutting_down = false; // guarded by state_mutex; workers stop taking fetches + std::string m_material_hash; // guarded by state_mutex; last doorbell value seen + std::atomic m_filament_in_flight{0}; // detached workers; drained by the destructor OrcaCloudServiceAgent* get_orca_cloud_agent(); diff --git a/src/slic3r/Utils/SnapmakerPrinterAgent.cpp b/src/slic3r/Utils/SnapmakerPrinterAgent.cpp index 2ae67f8140..57926f87d5 100644 --- a/src/slic3r/Utils/SnapmakerPrinterAgent.cpp +++ b/src/slic3r/Utils/SnapmakerPrinterAgent.cpp @@ -239,25 +239,6 @@ bool SnapmakerPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSync tray.tray_type = combine_filament_type(safe_at(filament_type, i, empty_str), safe_at(filament_sub_type, i, empty_str)); tray.tray_color = safe_at(filament_color, i, default_color); - auto* bundle = GUI::wxGetApp().preset_bundle; - // Try to find a matching preset for this filament based on vendor, type and color. - // If not found, default to traditional search by type only or generic type mapping. - if (bundle) { - std::string vendor = safe_at(filament_vendor, i, empty_str); - std::string filament_id = find_closest_color_preset_by_vendor_and_type(bundle->filaments, vendor, tray.tray_type, - tray.tray_color); - - if (!filament_id.empty()) { - tray.tray_info_idx = filament_id; - BOOST_LOG_TRIVIAL(warning) - << "Filament sync: Found manufacturer-specific profile for slot " << i << ": " << filament_id; - } else { - tray.tray_info_idx = bundle->filaments.filament_id_by_type(tray.tray_type); - } - } else { - tray.tray_info_idx = map_filament_type_to_generic_id(tray.tray_type); - } - // Extract NFC temperature data if available if (nfc_info.is_array() && i < static_cast(nfc_info.size()) && nfc_info[i].is_object()) { auto& nfc_slot = nfc_info[i]; @@ -272,12 +253,46 @@ bool SnapmakerPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSync trays.emplace_back(std::move(tray)); } - build_ams_payload(1, slot_count - 1, trays); + // Preset matching (vendor + closest color) reads GUI preset state, so it + // runs on the main thread via this resolver, not on the fetch worker. + std::vector vendors; + vendors.reserve(slot_count); + for (int i = 0; i < slot_count; ++i) + vendors.push_back(safe_at(filament_vendor, i, empty_str)); + + build_ams_payload(1, slot_count - 1, trays, + [vendors](std::vector& resolved) { resolve_snapmaker_tray_info(resolved, vendors); }); }).detach(); return true; } +void SnapmakerPrinterAgent::resolve_snapmaker_tray_info(std::vector& trays, + const std::vector& vendors) +{ + auto* bundle = GUI::wxGetApp().preset_bundle; + for (auto& tray : trays) { + if (!tray.has_filament) + continue; + const std::string vendor = (tray.slot_index >= 0 && tray.slot_index < static_cast(vendors.size())) + ? vendors[tray.slot_index] + : std::string(); + if (bundle) { + // Try a matching preset by vendor, type and color; fall back to + // type only, then to the generic family map. + std::string filament_id = find_closest_color_preset_by_vendor_and_type(bundle->filaments, vendor, tray.tray_type, tray.tray_color); + if (!filament_id.empty()) { + tray.tray_info_idx = filament_id; + BOOST_LOG_TRIVIAL(warning) << "Filament sync: Found manufacturer-specific profile for slot " << tray.slot_index << ": " << filament_id; + } else { + tray.tray_info_idx = bundle->filaments.filament_id_by_type(tray.tray_type); + } + } else { + tray.tray_info_idx = map_filament_type_to_generic_id(tray.tray_type); + } + } +} + FilamentSyncMode SnapmakerPrinterAgent::get_filament_sync_mode() const { if (GUI::wxGetApp().app_config->get_bool("use_printer_agents")) diff --git a/src/slic3r/Utils/SnapmakerPrinterAgent.hpp b/src/slic3r/Utils/SnapmakerPrinterAgent.hpp index db3ea4622c..0e2d7916c0 100644 --- a/src/slic3r/Utils/SnapmakerPrinterAgent.hpp +++ b/src/slic3r/Utils/SnapmakerPrinterAgent.hpp @@ -6,6 +6,7 @@ #include #include #include +#include namespace Slic3r { @@ -28,6 +29,12 @@ private: // Combine filament_type + filament_sub_type into a unified type string static std::string combine_filament_type(const std::string& type, const std::string& sub_type); + // Resolve tray_info_idx for the collected trays. Reads the GUI preset + // bundle, so it is invoked on the main thread from within the shared + // build_ams_payload_for_device() (never on the fetch worker). + static void resolve_snapmaker_tray_info(std::vector& trays, + const std::vector& vendors); + void start_camera_monitor(); void on_status_loop_tick(const std::string& dev_id) override; diff --git a/tests/slic3rutils/test_orca_printer_agent.cpp b/tests/slic3rutils/test_orca_printer_agent.cpp index 7ffc4f3d2f..15378ae799 100644 --- a/tests/slic3rutils/test_orca_printer_agent.cpp +++ b/tests/slic3rutils/test_orca_printer_agent.cpp @@ -1,4 +1,6 @@ #include +#include +#include #include #include @@ -46,7 +48,7 @@ TEST_CASE("OrcaPrinterAgent stamps the get_capabilities nozzle diameter onto pus // is cached for the device. agent.deliver_to_sink( "dev-1", - R"({"info":{"command":"get_capabilities","capabilities":{"topology":{"tools":[{"id":"T0","nozzle":{"diameter_mm":0.4}}]}}}})", + R"({"info":{"command":"get_capabilities","capabilities":{"topology":{"tools":[{"id":"T0","nozzle":{"diameter_mm":0.4}}]},"protocol":{"ams_ops":["change_filament"]}}}})", /*local=*/false); CHECK(last_payload.find("\"command\":\"get_capabilities\"") != std::string::npos); CHECK(last_payload.find("\"print\"") == std::string::npos); @@ -65,6 +67,14 @@ TEST_CASE("OrcaPrinterAgent stamps the get_capabilities nozzle diameter onto pus agent.deliver_to_sink("dev-1", R"({"print":{"command":"push_status","nozzle_diameter":0.6}})", /*local=*/false); CHECK(last_payload.find("\"nozzle_diameter\":0.6") != std::string::npos); CHECK(last_payload.find("N/A") == std::string::npos); + + // The reply's declared ams_ops register on the device (OPCP §7.8): the one + // declared op passes the client gate, an undeclared one is rejected. + bool unsupported = false; + OrcaPrinterAgent::canonicalize_ams_payload("dev-1", R"({"print":{"command":"ams_change_filament","target":1}})", &unsupported); + CHECK_FALSE(unsupported); + OrcaPrinterAgent::canonicalize_ams_payload("dev-1", R"({"print":{"command":"ams_control","param":"pause"}})", &unsupported); + CHECK(unsupported); } TEST_CASE("OrcaPrinterAgent::parse_lan_endpoint", "[OrcaPrinterAgent]") { @@ -92,6 +102,89 @@ TEST_CASE("connect_printer wires up a LAN Config", "[OrcaPrinterAgent][.integrat agent.disconnect_printer(); } +// why hidden: connect_printer starts a detached connect worker, like the LAN +// test above. The sync mode follows the printer's own AMS capability, not the +// transport: a connected printer stays `none` until its get_capabilities reply +// declares a material system. The registry is process-wide, so this test uses +// ids no other test feeds capabilities for. +TEST_CASE("filament sync follows the printer's AMS capability", "[OrcaPrinterAgent][.integration]") { + Probe agent("/tmp"); + CHECK(agent.get_filament_sync_mode() == Slic3r::FilamentSyncMode::none); + + REQUIRE(agent.connect_printer("dev-ams-1", "10.255.255.1", "orcasonar", "code", false) == BAMBU_NETWORK_SUCCESS); + // Connected, but no capability reply yet: still none. + CHECK(agent.get_filament_sync_mode() == Slic3r::FilamentSyncMode::none); + + // features.fms=true is the authoritative material-system flag. + agent.deliver_to_sink("dev-ams-1", + R"({"info":{"command":"get_capabilities","capabilities":{"protocol":{"features":{"fms":true},"ams_ops":["change_filament"]}}}})", + /*local=*/true); + CHECK(agent.get_filament_sync_mode() == Slic3r::FilamentSyncMode::subscription); + + // An explicit false clears it again. + agent.deliver_to_sink("dev-ams-1", + R"({"info":{"command":"get_capabilities","capabilities":{"protocol":{"features":{"fms":false}}}}})", + /*local=*/true); + CHECK(agent.get_filament_sync_mode() == Slic3r::FilamentSyncMode::none); + + // Older payloads without features.fms fall back to a non-empty ams_ops. + agent.deliver_to_sink("dev-ams-1", + R"({"info":{"command":"get_capabilities","capabilities":{"protocol":{"ams_ops":["change_filament"]}}}})", + /*local=*/true); + CHECK(agent.get_filament_sync_mode() == Slic3r::FilamentSyncMode::subscription); + + // The pull contract (Sidebar's blocking path) is gone: a pull-mode fetch is + // refused by the mode guard, and a subscription fetch for a device that is + // not the active printer is refused before any HTTP. + CHECK_FALSE(agent.fetch_filament_info("dev-ams-1", Slic3r::FilamentSyncMode::pull)); + CHECK_FALSE(agent.fetch_filament_info("dev-ams-2", Slic3r::FilamentSyncMode::subscription)); + + agent.disconnect_printer(); + CHECK(agent.get_filament_sync_mode() == Slic3r::FilamentSyncMode::none); +} + +// The subscription refresh is self-triggered: a pushed frame whose print block +// carries a CHANGED topology_state.material_hash (spec REQ-STS-007 §7.7) is +// the doorbell; repeat hashes, temperature-only frames and hash-less blocks +// are not (no per-frame polling regression). +TEST_CASE("a material_hash change is the filament-sync doorbell", "[OrcaPrinterAgent][.integration]") { + Probe agent("/tmp"); + REQUIRE(agent.connect_printer("dev-1", "10.255.255.1", "orcasonar", "code", false) == BAMBU_NETWORK_SUCCESS); + + const std::string frame_a = R"({"print":{"command":"push_status","topology_state":{"material_hash":"sha256:aaaaaaaaaaaaaaaa","units":[]}}})"; + CHECK(agent.filament_doorbell_needed_for_test("dev-1", frame_a)); + CHECK_FALSE(agent.filament_doorbell_needed_for_test("dev-1", frame_a)); + CHECK(agent.filament_doorbell_needed_for_test("dev-1", + R"({"print":{"command":"push_status","topology_state":{"material_hash":"sha256:bbbbbbbbbbbbbbbb","units":[]}}})")); + // Topology block without the doorbell token (older/foreign payload): stays quiet. + CHECK_FALSE(agent.filament_doorbell_needed_for_test("dev-1", + R"({"print":{"command":"push_status","topology_state":{"units":[]}}})")); + CHECK_FALSE(agent.filament_doorbell_needed_for_test("dev-1", R"({"print":{"command":"push_status","mc_percent":10}})")); + // Not the active LAN device: never a doorbell. + CHECK_FALSE(agent.filament_doorbell_needed_for_test("dev-2", frame_a)); + + agent.disconnect_printer(); + CHECK_FALSE(agent.filament_doorbell_needed_for_test("dev-1", frame_a)); +} + +// After a failed lane_data fetch there is no retry timer: any next LAN frame +// re-arms the refresh, because a changing printer keeps pushing. +TEST_CASE("a failed filament fetch retries on the next LAN frame", "[OrcaPrinterAgent][.integration]") { + Probe agent("/tmp"); + REQUIRE(agent.connect_printer("dev-1", "10.255.255.1", "orcasonar", "code", false) == BAMBU_NETWORK_SUCCESS); + + const std::string telemetry = R"({"print":{"command":"push_status","mc_percent":10}})"; + CHECK_FALSE(agent.filament_doorbell_needed_for_test("dev-1", telemetry)); + agent.set_filament_failed_for_test(true); + CHECK(agent.filament_doorbell_needed_for_test("dev-1", telemetry)); + + agent.disconnect_printer(); + // disconnect clears the latch; the next connect eager-fetches instead. + REQUIRE(agent.connect_printer("dev-1", "10.255.255.1", "orcasonar", "code", false) == BAMBU_NETWORK_SUCCESS); + CHECK_FALSE(agent.filament_doorbell_needed_for_test("dev-1", telemetry)); + agent.disconnect_printer(); +} + TEST_CASE("post-connect sequence is subscribe then 4 requests in order", "[OrcaPrinterAgent]") { struct SeqProbe : OrcaPrinterAgent { using OrcaPrinterAgent::OrcaPrinterAgent; @@ -202,3 +295,65 @@ TEST_CASE("destroying an agent mid-connect does not hang or crash", "[OrcaPrinte } SUCCEED(); } + +// OPCP spec §7.8: Bambu wire conventions must be decoded at the agent funnel, +// never smuggled through as numeric lanes (an external-spool select once read +// as slot_id=0 and physically loaded gate 0). +TEST_CASE("OrcaPrinterAgent rewrites Bambu ams_* payloads onto the canonical OrcaSonar bodies", "[OrcaPrinterAgent]") { + auto canon = [](const std::string& dev, const std::string& in, bool* unsupported = nullptr) { + bool local = false; + return OrcaPrinterAgent::canonicalize_ams_payload(dev, in, unsupported ? unsupported : &local); + }; + + auto out = nlohmann::json::parse(canon("dev-c1", + R"({"print":{"command":"ams_change_filament","sequence_id":"1","target":5,"slot_id":1,"ams_id":1,"curr_temp":210,"tar_temp":220}})")); + CHECK(out["print"]["selector"] == "lane"); + CHECK(out["print"]["lane"] == 5); + CHECK(!out["print"].contains("target")); + CHECK(!out["print"].contains("slot_id")); + CHECK(!out["print"].contains("ams_id")); + CHECK(out["print"]["tar_temp"] == 220); + + // External-spool selection ("254" arrives hacked to 255 with slot_id=0): + // must become the selector op, never a lane. + out = nlohmann::json::parse(canon("dev-c1", R"({"print":{"command":"ams_change_filament","ams_id":255,"target":255,"slot_id":0}})")); + CHECK(out["print"]["selector"] == "external"); + CHECK(!out["print"].contains("lane")); + + out = nlohmann::json::parse(canon("dev-c1", R"({"print":{"command":"ams_change_filament","ams_id":0,"target":255,"slot_id":255}})")); + CHECK(out["print"]["selector"] == "unload"); + + out = nlohmann::json::parse(canon("dev-c1", R"({"print":{"command":"ams_change_filament","ams_id":1,"slot_id":2}})")); + CHECK(out["print"]["lane"] == 6); + + out = nlohmann::json::parse(canon("dev-c1", R"({"print":{"command":"ams_filament_setting","ams_id":1,"slot_id":2,"tray_id":2,"tray_type":"PLA"}})")); + CHECK(out["print"]["lane"] == 6); + CHECK(out["print"]["tray_type"] == "PLA"); + + // Legacy RFID call shape (ams_id+slot_id, no tray_id) flattens to tray_id. + out = nlohmann::json::parse(canon("dev-c1", R"({"print":{"command":"ams_get_rfid","ams_id":1,"slot_id":2}})")); + CHECK(out["print"]["tray_id"] == 6); + CHECK(!out["print"].contains("ams_id")); + + const std::string canonical = R"({"print":{"command":"ams_change_filament","selector":"lane","lane":3}})"; + CHECK(canon("dev-c1", canonical) == canonical); + CHECK(canon("dev-c1", R"({"print":{"command":"pause"}})") == R"({"print":{"command":"pause"}})"); + CHECK(canon("dev-c1", "not json at all") == "not json at all"); + + // Declared ams_ops gate at the client: only change_filament is supported. + Slic3r::register_ams_ops("dev-c2", {"change_filament"}); + bool unsupported = false; + canon("dev-c2", R"({"print":{"command":"ams_change_filament","target":255,"slot_id":255}})", &unsupported); + CHECK(unsupported); + unsupported = false; + canon("dev-c2", R"({"print":{"command":"ams_control","param":"pause"}})", &unsupported); + CHECK(unsupported); + unsupported = false; + canon("dev-c2", R"({"print":{"command":"ams_change_filament","target":1}})", &unsupported); + CHECK(!unsupported); + + // A device with no capabilities record is never gated (server backstops). + unsupported = false; + canon("dev-c3", R"({"print":{"command":"ams_user_setting","ams_id":0}})", &unsupported); + CHECK(!unsupported); +} diff --git a/tests/slic3rutils/test_printer_agent.cpp b/tests/slic3rutils/test_printer_agent.cpp index 9a78ceeccc..9e70cdd729 100644 --- a/tests/slic3rutils/test_printer_agent.cpp +++ b/tests/slic3rutils/test_printer_agent.cpp @@ -68,6 +68,136 @@ TEST_CASE("Moonraker parses nozzle diameter from raw config and tolerates missin CHECK(MoonrakerParserProbe::parse_nozzle_diameter(missing_response) == 0.0f); } +// why: the lane_data projection is shared by Moonraker and OrcaSonar; the +// parser is pure so it can be checked without a live printer or a GUI. +TEST_CASE("unit: lane_data projection maps lanes into AMS trays", "[unit][moonraker]") +{ + const auto body = nlohmann::json::parse(R"({ + "result": { + "namespace": "lane_data", + "value": { + "lane0": {"lane": "0", "material": "PLA", "color": "FF0000FF"}, + "lane1": {"lane": "1"}, + "lane2": {"lane": "not-a-number"}, + "lane3": {"lane": "3", "material": "PETG", "color": "#00FF00", "nozzle_temp": 240} + } + } + })"); + + std::vector trays; + int max_lane_index = -1; + REQUIRE(parse_moonraker_lane_data(body, trays, max_lane_index)); + REQUIRE(trays.size() == 3); + CHECK(trays[0].slot_index == 0); + CHECK(trays[0].has_filament); + CHECK(trays[0].tray_type == "PLA"); + CHECK(trays[0].tray_color == "FF0000FF"); + CHECK(trays[1].slot_index == 1); + CHECK_FALSE(trays[1].has_filament); + CHECK(trays[1].tray_type.empty()); + CHECK(trays[2].slot_index == 3); + CHECK(trays[2].nozzle_temp == 240); + CHECK(max_lane_index == 3); +} + +TEST_CASE("unit: lane_data projection rejects malformed and empty responses", "[unit][moonraker]") +{ + std::vector trays; + int max_lane_index = -1; + CHECK_FALSE(parse_moonraker_lane_data(nlohmann::json::object(), trays, max_lane_index)); + CHECK_FALSE(parse_moonraker_lane_data(nlohmann::json::parse(R"({"result":{"value":{}}})"), trays, max_lane_index)); + CHECK_FALSE(parse_moonraker_lane_data( + nlohmann::json::parse(R"({"result":{"value":{"lane0":{"lane":"x"},"lane1":42}}})"), trays, max_lane_index)); +} + +// why: OrcaSonar projects sensed presence separately from declared material, so a +// loaded lane with no type must not collapse to "empty". +TEST_CASE("unit: lane_data presence is independent of declared material", "[unit][moonraker]") +{ + const auto body = nlohmann::json::parse(R"({ + "result": {"value": { + "lane0": {"lane": "0", "has_filament": true}, + "lane1": {"lane": "1", "has_filament": false, "material": "PLA"}, + "lane2": {"lane": "2", "loaded": true}, + "lane3": {"lane": "3", "material": "ASA"} + }} + })"); + + std::vector trays; + int max_lane_index = -1; + REQUIRE(parse_moonraker_lane_data(body, trays, max_lane_index)); + REQUIRE(trays.size() == 4); + + CHECK(trays[0].has_filament); + CHECK(trays[0].tray_type.empty()); + + CHECK_FALSE(trays[1].has_filament); + CHECK(trays[1].tray_type == "PLA"); + + CHECK(trays[2].has_filament); + + CHECK(trays[3].has_filament); + CHECK(trays[3].tray_type == "ASA"); +} + +// why: the exist bits and per-slot placeholder flag drive the sidebar's occupied +// vs empty rendering; the builder is pure so this needs no GUI. +TEST_CASE("unit: AMS payload sets exist bits and omits placeholder for loaded lanes", "[unit][moonraker]") +{ + std::vector trays = { + {0, true, "PLA", "FF0000FF", "", 0, 0}, + {2, true, "", "", "", 0, 0}, // loaded but undeclared + {5, true, "PETG", "00FF00", "", 0, 0}, + }; + const auto ams = build_bbl_ams_json(trays, 2, 5); + + // Units 0 and 1 exist; slots 0, 2 and 5 hold filament (0b100101). + CHECK(ams["ams_exist_bits"].get() == "3"); + CHECK(ams["tray_exist_bits"].get() == "25"); + + const auto& u0 = ams["ams"][0]["tray"]; + CHECK_FALSE(u0[0].contains("tray_slot_placeholder")); + CHECK(u0[1].contains("tray_slot_placeholder")); + CHECK_FALSE(u0[2].contains("tray_slot_placeholder")); + CHECK(u0[2]["tray_type"].get() == ""); + CHECK(u0[2]["tray_color"].get() == "00000000"); + + const auto& u1 = ams["ams"][1]["tray"]; + CHECK(u1[0].contains("tray_slot_placeholder")); // slot 4 absent + CHECK_FALSE(u1[1].contains("tray_slot_placeholder")); // slot 5 present + CHECK(u1[1]["tray_color"].get() == "00FF00FF"); // 6-hex padded +} + +// why: the exist-bits fields are 64-bit; multi-box rigs past lane 31 would be +// corrupted by a 32-bit unsigned long shift on MSVC. Guards the 64-bit path. +TEST_CASE("unit: AMS payload sets exist bits beyond 31 lanes", "[unit][moonraker]") +{ + std::vector trays = { + {33, true, "PLA", "FF0000FF", "", 0, 0}, + }; + const auto ams = build_bbl_ams_json(trays, 9, 33); + + CHECK(ams["ams_exist_bits"].get() == "1FF"); // units 0..8 + CHECK(ams["tray_exist_bits"].get() == "200000000"); // 1 << 33 + + const auto& u8 = ams["ams"][8]["tray"]; + CHECK_FALSE(u8[1].contains("tray_slot_placeholder")); // slot 33 present +} + +// why: the sync mode keys off the printer's declared material system; a device +// with no capability record must never read as AMS-capable. +TEST_CASE("unit: AMS capability registry reports only declared material systems", "[unit][moonraker]") +{ + CHECK_FALSE(has_ams_capability("cap-none")); + register_ams_capability("cap-true", true); + CHECK(has_ams_capability("cap-true")); + register_ams_capability("cap-false", false); + CHECK_FALSE(has_ams_capability("cap-false")); + // Later replies overwrite: a removed material system must clear the flag. + register_ams_capability("cap-true", false); + CHECK_FALSE(has_ams_capability("cap-true")); +} + // why: these builders preserve the Bambu firmware dialect byte-for-byte, including its trailing space. TEST_CASE("unit: BBL AMS gcode builders preserve command bytes", "[unit][bbl]") {