diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp index 1002f5be89..d47ad2213d 100644 --- a/src/libslic3r/Preset.cpp +++ b/src/libslic3r/Preset.cpp @@ -3446,7 +3446,7 @@ size_t PresetCollection::first_visible_idx() const return first_visible; } -size_t PresetCollection::first_visible_idx_by_type(const std::string& filament_type) const +size_t PresetCollection::first_matching_filament_idx(const std::string& filament_type) const { size_t start = m_default_suppressed ? m_num_default_presets : 0; @@ -3471,14 +3471,17 @@ size_t PresetCollection::first_visible_idx_by_type(const std::string& filament_t // e.g. "PLA High Speed" -> "PLA" // Dash-separated types like "PA-CF", "PET-CF" are distinct materials, not modifiers. auto sep = filament_type.find(' '); - if (sep != std::string::npos) { - idx = find_by_type(filament_type.substr(0, sep)); - if (idx != size_t(-1)) - return idx; - } + if (sep != std::string::npos) + return find_by_type(filament_type.substr(0, sep)); + return size_t(-1); +} + +size_t PresetCollection::first_visible_idx_by_type(const std::string& filament_type) const +{ + size_t idx = first_matching_filament_idx(filament_type); // 3. Any visible preset - return first_visible_idx(); + return idx != size_t(-1) ? idx : first_visible_idx(); } std::string PresetCollection::filament_id_by_type(const std::string& filament_type) const @@ -3486,6 +3489,15 @@ std::string PresetCollection::filament_id_by_type(const std::string& filament_ty return preset(first_visible_idx_by_type(filament_type)).filament_id; } +bool PresetCollection::filament_id_by_type(const std::string& filament_type, std::string& out) const +{ + size_t idx = first_matching_filament_idx(filament_type); + if (idx == size_t(-1)) + return false; + out = preset(idx).filament_id; + return true; +} + std::vector PresetCollection::diameters_of_selected_printer() { std::set diameters; diff --git a/src/libslic3r/Preset.hpp b/src/libslic3r/Preset.hpp index c66518e29d..bec467c4d3 100644 --- a/src/libslic3r/Preset.hpp +++ b/src/libslic3r/Preset.hpp @@ -756,6 +756,11 @@ public: size_t first_visible_idx_by_type(const std::string& filament_type) const; // Return the filament_id of the best-matching visible preset for the given filament type. std::string filament_id_by_type(const std::string& filament_type) const; + // As above, but returns false (and leaves `out` untouched) when no preset + // matches instead of falling back to first_visible_idx(). The fallback is a + // plain first-visible/PLA preset, which would bind an unknown material to an + // unrelated default. + bool filament_id_by_type(const std::string& filament_type, std::string& out) const; // Return index of the first compatible preset. Certainly at least the '- default -' preset shall be compatible. // If one of the prefered_alternates is compatible, select it. template size_t first_compatible_idx(PreferedCondition prefered_condition) const @@ -889,6 +894,10 @@ protected: void set_custom_preset_alias(Preset &preset); private: + // Index of the first visible, compatible, system base preset matching + // filament_type (exact, then base type), or (size_t)-1 when none matches. + size_t first_matching_filament_idx(const std::string& filament_type) const; + std::string canonical_preset_name(const std::string &name, const PresetOrigin &load_origin = PresetOrigin()) const; // Comparator that sorts "Generic " prefixed presets before others, then alphabetically within each group. diff --git a/src/slic3r/GUI/DeviceCore/DevCalib.cpp b/src/slic3r/GUI/DeviceCore/DevCalib.cpp index ddf71e0c62..1a5c9bcfed 100644 --- a/src/slic3r/GUI/DeviceCore/DevCalib.cpp +++ b/src/slic3r/GUI/DeviceCore/DevCalib.cpp @@ -162,8 +162,10 @@ void DevCalib::ExtrusionCalibSetParse(const json & jj){ int slot_id = tray_ams_slot_map.find(tray_id) != tray_ams_slot_map.end() ? tray_ams_slot_map[tray_id].second : -1; if(tray_id == VIRTUAL_TRAY_MAIN_ID) { - GetOwner()->vt_slot[MAIN_EXTRUDER_ID].k = jj.value("k_value", GetOwner()->vt_slot[MAIN_EXTRUDER_ID].k); - GetOwner()->vt_slot[MAIN_EXTRUDER_ID].n = jj.value("n_value", GetOwner()->vt_slot[MAIN_EXTRUDER_ID].n); + if (GetOwner()->vt_slot.size() > MAIN_EXTRUDER_ID) { + GetOwner()->vt_slot[MAIN_EXTRUDER_ID].k = jj.value("k_value", GetOwner()->vt_slot[MAIN_EXTRUDER_ID].k); + GetOwner()->vt_slot[MAIN_EXTRUDER_ID].n = jj.value("n_value", GetOwner()->vt_slot[MAIN_EXTRUDER_ID].n); + } }else{ auto tray_item = GetOwner()->GetFilaSystem()->GetAmsTray(std::to_string(ams_id), std::to_string(slot_id)); if (tray_item) { diff --git a/src/slic3r/GUI/DeviceCore/DevFilaSystem.cpp b/src/slic3r/GUI/DeviceCore/DevFilaSystem.cpp index 2cce41db9d..8c58a20b7b 100644 --- a/src/slic3r/GUI/DeviceCore/DevFilaSystem.cpp +++ b/src/slic3r/GUI/DeviceCore/DevFilaSystem.cpp @@ -849,154 +849,4 @@ void DevFilaSystemParser::ParseV1_0(const json& jj, MachineObject* obj, DevFilaS } } -static DevAms::AmsType ams_type_from_string(const std::string& s) -{ - if (s == "ams_lite" || s == "ams-lite") return DevAms::AMS_LITE; - if (s == "n3f") return DevAms::N3F; - if (s == "n3s") return DevAms::N3S; - return DevAms::AMS; // default -} - -void DevFilaSystemParser::ParseAgentFilament(const json& data, MachineObject* obj, DevFilaSystem* system) -{ - if (!system || !data.is_object()) - return; - - // --- AMS units --- - if (data.contains("units") && data["units"].is_array()) - { - std::set seen_units; - - for (const auto& u : data["units"]) - { - if (!u.is_object() || !u.contains("id")) - continue; - const std::string ams_id = u.value("id", std::string()); - if (ams_id.empty()) - continue; - seen_units.insert(ams_id); - - const int ext_id = u.value("extruder", MAIN_EXTRUDER_ID); - const DevAms::AmsType type = ams_type_from_string(u.value("type", std::string("ams"))); - - DevAms* ams = nullptr; - auto it = system->amsList.find(ams_id); - if (it == system->amsList.end()) - { - ams = new DevAms(ams_id, ext_id, type); - system->amsList.insert(std::make_pair(ams_id, ams)); - } - else - { - ams = it->second; - ams->m_ext_id = ext_id; - ams->SetAmsType(type); - } - - ams->m_exist = true; - ams->m_current_temperature = u.value("temperature", (float) INVALID_AMS_TEMPERATURE); - ams->m_humidity_percent = u.value("humidity_percent", -1); - ams->m_left_dry_time = u.value("dry_time_min", 0); - - // --- slots / trays --- - std::set seen_slots; - if (u.contains("slots") && u["slots"].is_array()) - { - for (const auto& s : u["slots"]) - { - if (!s.is_object()) - continue; - const std::string tray_id = std::to_string(s.value("index", -1)); - seen_slots.insert(tray_id); - - DevAmsTray* tray = nullptr; - auto tit = ams->m_trays.find(tray_id); - if (tit == ams->m_trays.end()) - { - tray = new DevAmsTray(tray_id); - ams->m_trays.insert(std::make_pair(tray_id, tray)); - } - else - { - tray = tit->second; - } - - tray->is_exists = s.value("loaded", false); - tray->m_fila_type = s.value("material", std::string()); - tray->setting_id = s.value("preset_id", std::string()); - tray->UpdateColorFromStr(s.value("color", std::string())); - tray->nozzle_temp_min = std::to_string(s.value("nozzle_temp_min", 0)); - tray->nozzle_temp_max = std::to_string(s.value("nozzle_temp_max", 0)); - tray->remain = s.value("remain_percent", -1); - tray->k = s.value("k", 0.0f); - if (s.contains("diameter_mm") && s["diameter_mm"].is_number()) - tray->diameter = std::to_string(s["diameter_mm"].get()); - if (s.contains("weight_g") && s["weight_g"].is_number()) - tray->weight = std::to_string(s["weight_g"].get()); - - tray->cols.clear(); - if (s.contains("colors") && s["colors"].is_array()) - { - for (const auto& c : s["colors"]) - if (c.is_string()) - tray->cols.push_back(c.get()); - } - tray->ctype = tray->cols.size() > 1 ? 1 : 0; - } - } - - // prune trays no longer reported - for (auto tit = ams->m_trays.begin(); tit != ams->m_trays.end();) - { - if (seen_slots.count(tit->first) == 0) - { - delete tit->second; - tit = ams->m_trays.erase(tit); - } - else - { - ++tit; - } - } - } - - // prune units no longer reported - for (auto it = system->amsList.begin(); it != system->amsList.end();) - { - if (seen_units.count(it->first) == 0) - { - delete it->second; - it = system->amsList.erase(it); - } - else - { - ++it; - } - } - } - - // --- external / direct spools -> obj->vt_slot --- - // extruder 0 -> main virtual slot, extruder >0 -> deputy. - if (obj && data.contains("external") && data["external"].is_array()) - { - obj->vt_slot.clear(); - for (const auto& e : data["external"]) - { - if (!e.is_object()) - continue; - const int ext = e.value("extruder", MAIN_EXTRUDER_ID); - const int vt_id = (ext == MAIN_EXTRUDER_ID) ? VIRTUAL_TRAY_MAIN_ID : VIRTUAL_TRAY_DEPUTY_ID; - DevAmsTray tray(std::to_string(vt_id)); - tray.is_exists = e.value("loaded", false); - tray.m_fila_type = e.value("material", std::string()); - tray.setting_id = e.value("preset_id", std::string()); - tray.UpdateColorFromStr(e.value("color", std::string())); - tray.nozzle_temp_min = std::to_string(e.value("nozzle_temp_min", 0)); - tray.nozzle_temp_max = std::to_string(e.value("nozzle_temp_max", 0)); - tray.remain = e.value("remain_percent", -1); - obj->vt_slot.push_back(tray); - } - } -} - } diff --git a/src/slic3r/GUI/DeviceCore/DevFilaSystem.h b/src/slic3r/GUI/DeviceCore/DevFilaSystem.h index 51fb744f72..1b8fadccc4 100644 --- a/src/slic3r/GUI/DeviceCore/DevFilaSystem.h +++ b/src/slic3r/GUI/DeviceCore/DevFilaSystem.h @@ -415,8 +415,6 @@ class DevFilaSystemParser { public: static void ParseV1_0(const json& print_json, MachineObject* obj, DevFilaSystem* system, bool key_field_only); - - static void ParseAgentFilament(const json& data, MachineObject* obj, DevFilaSystem* system); }; struct DevFilamentDryingPreset diff --git a/src/slic3r/GUI/DeviceManager.cpp b/src/slic3r/GUI/DeviceManager.cpp index 7f175cc11e..60f51ac875 100644 --- a/src/slic3r/GUI/DeviceManager.cpp +++ b/src/slic3r/GUI/DeviceManager.cpp @@ -536,6 +536,23 @@ PrinterSeries MachineObject::get_printer_series() const return PrinterSeries::SERIES_P1P; } +bool MachineObject::is_bbl_agent() const +{ + // Empty id means "unattributed", treated as BBL to preserve the RFID lock. + return printer_agent_id == BBL_PRINTER_AGENT_ID || printer_agent_id.empty(); +} + +bool MachineObject::ams_filament_ack_failed(const nlohmann::json& jj, std::string& reason) +{ + reason.clear(); + if (!jj.contains("result") || !jj["result"].is_string() || + jj["result"].get() == "success") + return false; + if (jj.contains("reason") && jj["reason"].is_string()) + reason = jj["reason"].get(); + return true; +} + PrinterArch MachineObject::get_printer_arch() const { return DevPrinterConfigUtil::get_printer_arch(printer_type); @@ -619,8 +636,7 @@ MachineObject::MachineObject(DeviceManager* manager, NetworkAgent* agent, std::s has_ipcam = true; // default true - auto vslot = DevAmsTray(std::to_string(VIRTUAL_TRAY_MAIN_ID)); - vt_slot.push_back(vslot); + // vt_slot is seeded by reset() above; do not push a second copy here. { m_lamp = new DevLamp(this); @@ -747,6 +763,7 @@ DevAmsTray *MachineObject::get_curr_tray() { const std::string& cur_ams_id = m_extder_system->GetCurrentAmsId(); if (cur_ams_id.compare(std::to_string(VIRTUAL_TRAY_MAIN_ID)) == 0) { + if (vt_slot.empty()) return nullptr; return &vt_slot[0]; } @@ -2568,13 +2585,11 @@ void MachineObject::reset() json empty_j; print_json.diff2all_base_reset(empty_j); - for (auto i = 0; i < vt_slot.size(); i++) { - vt_slot[i].reset(); - - if (i == 1) { - vt_slot.erase(vt_slot.begin() + 1); - } - } + // Restore the ctor seed rather than only resetting what is left: an + // authoritative vir_slot:[] erases every tray, so reset must not leave + // vt_slot permanently empty. + vt_slot.clear(); + vt_slot.push_back(DevAmsTray(std::to_string(VIRTUAL_TRAY_MAIN_ID))); // why: reset reuses MachineObject, so release its lazy subtask // before dropping the pointer to prevent reconnect leaks. if (subtask_) { @@ -2749,6 +2764,7 @@ int MachineObject::local_publish_json(std::string json_str, int qos, int flag) std::string MachineObject::setting_id_to_type(std::string setting_id, std::string tray_type) { std::string type; + if (wxTheApp == nullptr) return tray_type; PresetBundle* preset_bundle = GUI::wxGetApp().preset_bundle; if (preset_bundle) { for (auto it = preset_bundle->filaments.begin(); it != preset_bundle->filaments.end(); it++) { @@ -3968,8 +3984,12 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_ catch (...) { ; } - update_printer_preset_name(); - update_filament_list(); + // Both read the GUI preset bundle; skip them in a headless + // process (unit tests), where wxTheApp is null. + if (wxTheApp != nullptr) { + update_printer_preset_name(); + update_filament_list(); + } if (jj.contains("ams")) { DevFilaSystemParser::ParseV1_0(jj, this, m_fila_system.get(), key_field_only); } @@ -3979,6 +3999,19 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_ try { if (jj.contains("vir_slot") && jj["vir_slot"].is_array()) { + if (jj["vir_slot"].empty()) { + // Authoritative empty: OrcaSonar pushes [] when the + // topology is known but has no slots; clear stale trays. + vt_slot.clear(); + ams_support_virtual_tray = false; + } + else { + // A keyed, populated vir_slot means virtual trays + // are supported; without this a prior clear left + // the flag false and the trays were ignored. + ams_support_virtual_tray = true; + } + for (auto it = jj["vir_slot"].begin(); it != jj["vir_slot"].end(); it++) { auto vslot = parse_vt_tray(it.value().get()); @@ -3992,11 +4025,13 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_ } } else if (vslot.id == std::to_string(VIRTUAL_TRAY_DEPUTY_ID)) { - auto it = std::next(vt_slot.begin(), 1); - if (it != vt_slot.end()) { + // vt_slot[1] is the deputy. Only the main + // branch creates index 0, so an orphan + // deputy (no main) is dropped, not indexed. + if (vt_slot.size() > 1) { vt_slot[1] = vslot; } - else { + else if (vt_slot.size() == 1) { vt_slot.push_back(vslot); } } @@ -4004,6 +4039,7 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_ } else if (jj.contains("vt_tray")) { + ams_support_virtual_tray = true; auto main_slot = parse_vt_tray(jj["vt_tray"].get()); main_slot.id = std::to_string(VIRTUAL_TRAY_MAIN_ID); @@ -4016,9 +4052,9 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_ vt_slot.push_back(main_slot); } } - else { - ams_support_virtual_tray = false; - } + // No virtual-tray key at all: leave the flag as-is. An + // authoritative clear is an explicit vir_slot: [], and + // incremental frames must not hide existing trays. } catch (...) { ; @@ -4057,13 +4093,28 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_ // BBS trigger ams UI update ams_version = -1; - if (jj["ams_id"].is_number()) { + // OPCP acks a rejected/failed write with result "fail" and an + // errno (-19 validation, -4 persist). Without this the failure + // looked like success and left the old spool on screen. + std::string ack_reason; + const bool ack_failed = ams_filament_ack_failed(jj, ack_reason); + if (ack_failed) { + BOOST_LOG_TRIVIAL(warning) << "ams_filament_setting rejected: errno=" + << (jj.contains("errno") ? jj["errno"].dump() : "?") + << ", reason=" << ack_reason; + wxString text = _L("Failed to set AMS filament"); + if (!ack_reason.empty()) + text += wxString::FromUTF8(": ") + wxString::FromUTF8(ack_reason); + GUI::wxGetApp().push_notification(this, text); + } + + if (!ack_failed && jj["ams_id"].is_number()) { int ams_id = jj["ams_id"].get(); int tray_id = 0; if (jj.contains("tray_id")) { tray_id = jj["tray_id"].get(); } - if (ams_id == 255 && tray_id == VIRTUAL_TRAY_MAIN_ID) { + if (ams_id == 255 && tray_id == VIRTUAL_TRAY_MAIN_ID && !vt_slot.empty()) { BOOST_LOG_TRIVIAL(info) << "ams_filament_setting, parse tray info"; vt_slot[0].nozzle_temp_max = std::to_string(jj["nozzle_temp_max"].get()); vt_slot[0].nozzle_temp_min = std::to_string(jj["nozzle_temp_min"].get()); @@ -4076,7 +4127,6 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_ } else { auto ams = m_fila_system->GetAmsById(std::to_string(ams_id)); if (ams) { - tray_id = jj["tray_id"].get(); auto tray_it = ams->GetTrays().find(std::to_string(tray_id)); if (tray_it != ams->GetTrays().end()) { BOOST_LOG_TRIVIAL(trace) << "ams_filament_setting, parse tray info"; @@ -4216,7 +4266,7 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_ ; } } - if (tray_id == VIRTUAL_TRAY_MAIN_ID) { + if (tray_id == VIRTUAL_TRAY_MAIN_ID && !vt_slot.empty()) { if (jj.contains("k_value")) vt_slot[0].k = jj["k_value"].get(); if (jj.contains("n_coef")) @@ -5630,7 +5680,9 @@ int MachineObject::get_flag_bits(int num, int start, int count, int base) const void MachineObject::update_filament_list() { + if (wxTheApp == nullptr) return; PresetBundle *preset_bundle = Slic3r::GUI::wxGetApp().preset_bundle; + if (preset_bundle == nullptr) return; // custom filament typedef std::map> map_pair; @@ -5700,6 +5752,7 @@ void MachineObject::update_filament_list() void MachineObject::update_printer_preset_name() { BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " " << __LINE__ << "start update preset_name"; + if (wxTheApp == nullptr) return; PresetBundle * preset_bundle = Slic3r::GUI::wxGetApp().preset_bundle; if (!preset_bundle) return; auto printer_model = DevPrinterConfigUtil::get_printer_display_name(this->printer_type); diff --git a/src/slic3r/GUI/DeviceManager.hpp b/src/slic3r/GUI/DeviceManager.hpp index 5016682599..a438682a45 100644 --- a/src/slic3r/GUI/DeviceManager.hpp +++ b/src/slic3r/GUI/DeviceManager.hpp @@ -161,6 +161,13 @@ public: void set_agent(NetworkAgent* agent) { m_agent = agent; } NetworkAgent* get_agent() const { return m_agent; } // Orca: needed by DeviceCore modules (DevAxisCtrl) + // Orca: true only for devices managed by Bambu's own agent, where a + // non-zero tag_uid is an RFID lock. Other agents report it as metadata. + bool is_bbl_agent() const; + + // Orca: an OPCP failure ack (result != "success"). Fills reason when present. + static bool ams_filament_ack_failed(const nlohmann::json& jj, std::string& reason); + // Orca: these DeviceCore module accessors are unwired on the read side — axis/chamber/status // are fed every MQTT push but no GUI consumer reads them yet, and for calib/upgrade the inline // parse in DeviceManager.cpp remains authoritative. Do not wire DevUpgrade naively: its diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 6457b2c1b2..26813a38c2 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -2325,8 +2325,13 @@ void GUI_App::init_networking_callbacks() if (MachineObject* obj = m_device_manager->get_my_machine(dev_id)) { obj->parse_json("lan", msg); - // Orca: skip it if it doesn't support subscription based filament sync - if (this->m_device_manager->get_selected_machine() == obj && + // Orca: skip it if it doesn't support subscription based filament + // sync, and skip frames that carry no filament state at all + // (progress, temps, hms) so the AMS list is not rebuilt every push. + const bool has_filament_state = msg.find("\"vir_slot\"") != std::string::npos || + msg.find("\"ams\":") != std::string::npos; + if (has_filament_state && + this->m_device_manager->get_selected_machine() == obj && m_agent->get_filament_sync_mode() == FilamentSyncMode::subscription) { GUI::wxGetApp().sidebar().load_ams_list(obj); } diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index b9e20cd5ec..001954c5e9 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -5703,10 +5703,20 @@ std::map Sidebar::build_filament_ams_list(MachineObject } auto build_tray_config = [](DevAmsTray const &tray, std::string const &name, std::string ams_id, std::string slot_id) { + // Type-only agents (OrcaSonar/Qidi Box) report the material but not the + // slicer's preset id. Resolve it from the type so AMS sync can match a + // preset instead of skipping every tray as "unknown". + std::string filament_id = tray.setting_id; + if (filament_id.empty() && !tray.m_fila_type.empty() && tray.is_exists) { + // Strict lookup: an unmatched material leaves filament_id empty so + // the sync flow reports it as unknown instead of binding Generic PLA. + if (auto *bundle = wxGetApp().preset_bundle) + bundle->filaments.filament_id_by_type(tray.m_fila_type, filament_id); + } BOOST_LOG_TRIVIAL(info) << boost::format("build_filament_ams_list: name %1% setting_id %2% type %3% color %4%") - % name % tray.setting_id % tray.m_fila_type % tray.color; + % name % filament_id % tray.m_fila_type % tray.color; DynamicPrintConfig tray_config; - tray_config.set_key_value("filament_id", new ConfigOptionStrings{tray.setting_id}); + tray_config.set_key_value("filament_id", new ConfigOptionStrings{filament_id}); tray_config.set_key_value("tag_uid", new ConfigOptionStrings{tray.tag_uid}); tray_config.set_key_value("ams_id", new ConfigOptionStrings{ams_id}); tray_config.set_key_value("slot_id", new ConfigOptionStrings{slot_id}); @@ -5719,7 +5729,7 @@ std::map Sidebar::build_filament_ams_list(MachineObject tray_config.set_key_value("filament_slot_placeholder", new ConfigOptionBools{tray.is_slot_placeholder}); std::optional info; if (wxGetApp().preset_bundle) { - info = wxGetApp().preset_bundle->get_filament_by_filament_id(tray.setting_id); + info = wxGetApp().preset_bundle->get_filament_by_filament_id(filament_id); } tray_config.set_key_value("filament_is_support", new ConfigOptionBools{ info.has_value() ? info->is_support : false}); for (int i = 0; i < tray.cols.size(); ++i) { diff --git a/src/slic3r/GUI/StatusPanel.cpp b/src/slic3r/GUI/StatusPanel.cpp index 39b7c43de2..ce746f76d9 100644 --- a/src/slic3r/GUI/StatusPanel.cpp +++ b/src/slic3r/GUI/StatusPanel.cpp @@ -4350,12 +4350,12 @@ void StatusPanel::on_ams_load_curr() vt_slot_idx = 1; } + if (vt_slot_idx < 0 || vt_slot_idx >= (int)obj->vt_slot.size()) return; + int old_temp = -1; int new_temp = -1; DevAmsTray* curr_tray = &obj->vt_slot[vt_slot_idx]; - if (!curr_tray) return; - try { if (!curr_tray->nozzle_temp_max.empty() && !curr_tray->nozzle_temp_min.empty()) old_temp = (atoi(curr_tray->nozzle_temp_min.c_str()) + atoi(curr_tray->nozzle_temp_max.c_str())) / 2; @@ -4602,7 +4602,11 @@ void StatusPanel::on_filament_edit(wxCommandEvent &event) m_filament_setting_dlg->set_colors(cols); } - m_filament_setting_dlg->m_is_third = !DevFilaSystem::IsBBL_Filament(tray->tag_uid); + // A non-zero tag_uid is an RFID lock only on Bambu's own agent. + // Agent-managed printers (Orca/Qidi/Snapmaker) send it as + // metadata, and the spec says it must never be interpreted — so + // the tag alone must not make their trays read-only. + m_filament_setting_dlg->m_is_third = !obj->is_bbl_agent() || !DevFilaSystem::IsBBL_Filament(tray->tag_uid); if (!m_filament_setting_dlg->m_is_third) { sn_number = tray->uuid; @@ -4642,6 +4646,8 @@ void StatusPanel::on_ext_spool_edit(wxCommandEvent &event) m_filament_setting_dlg->slot_id = slot_id; int nozzle_index = ams_id == VIRTUAL_TRAY_MAIN_ID ? 0 : 1; + if (nozzle_index < 0 || nozzle_index >= (int)obj->vt_slot.size()) return; + try { std::string sn_number; std::string filament; @@ -4668,7 +4674,7 @@ void StatusPanel::on_ext_spool_edit(wxCommandEvent &event) m_filament_setting_dlg->set_colors(cols); } - m_filament_setting_dlg->m_is_third = !DevFilaSystem::IsBBL_Filament(obj->vt_slot[nozzle_index].tag_uid); + m_filament_setting_dlg->m_is_third = !obj->is_bbl_agent() || !DevFilaSystem::IsBBL_Filament(obj->vt_slot[nozzle_index].tag_uid); if (!m_filament_setting_dlg->m_is_third) { sn_number = obj->vt_slot[nozzle_index].uuid; filament = obj->vt_slot[nozzle_index].sub_brands; diff --git a/src/slic3r/Utils/AmsPayload.cpp b/src/slic3r/Utils/AmsPayload.cpp index 61658b291c..7c5b9563f5 100644 --- a/src/slic3r/Utils/AmsPayload.cpp +++ b/src/slic3r/Utils/AmsPayload.cpp @@ -378,29 +378,19 @@ nlohmann::json build_bbl_ams_json(const std::vector& trays, // --- 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; // One device's declaration from its get_capabilities reply. ops_known separates // "no reply yet" (never gate) from "answered without ops" (gate every write). struct AmsDeviceCaps { std::vector ops; - bool ops_known = false; - bool has_ams = false; + bool ops_known = false; + bool has_ams = false; + bool filament_slots = false; }; static std::map g_ams_caps; -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()) @@ -435,6 +425,29 @@ bool has_ams_capability(const std::string& dev_id) return it != g_ams_caps.end() && it->second.has_ams; } +void register_filament_slots(const std::string& dev_id, bool has_slots) +{ + if (dev_id.empty()) + return; + std::lock_guard lock(g_ams_state_mutex); + g_ams_caps[dev_id].filament_slots = has_slots; +} + +bool has_filament_slots(const std::string& dev_id) +{ + std::lock_guard lock(g_ams_state_mutex); + auto it = g_ams_caps.find(dev_id); + return it != g_ams_caps.end() && it->second.filament_slots; +} + +void clear_ams_caps(const std::string& dev_id) +{ + if (dev_id.empty()) + return; + std::lock_guard lock(g_ams_state_mutex); + g_ams_caps.erase(dev_id); +} + void build_ams_payload_for_device(const std::string& dev_id, const std::optional& printer_type, int ams_count, @@ -443,7 +456,6 @@ void build_ams_payload_for_device(const std::string& dev_id, 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. @@ -511,58 +523,5 @@ void build_ams_payload_for_device(const std::string& dev_id, } } -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 index 9d4411121d..e4abc54bdc 100644 --- a/src/slic3r/Utils/AmsPayload.hpp +++ b/src/slic3r/Utils/AmsPayload.hpp @@ -90,11 +90,6 @@ void build_ams_payload_for_device(const std::string& dev_id, 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. A device with no record (no reply yet; // non-OrcaSonar agents never register) reports every op supported: gating only @@ -109,6 +104,17 @@ bool ams_op_supported(const std::string& dev_id, const std::string& op); void register_ams_capability(const std::string& dev_id, bool has_ams); bool has_ams_capability(const std::string& dev_id); +// Whether the device exposes the filament-slot model, from the +// get_capabilities reply's protocol.features.filament_slots. The slot model is +// connector state, independent of fms: a printer with no material hardware +// still has slots, so this alone enables filament sync (REQ-FMS-001). +void register_filament_slots(const std::string& dev_id, bool has_slots); +bool has_filament_slots(const std::string& dev_id); + +// Forget a device's declared capabilities, so a reconnect starts from "no +// reply yet" instead of a stale declaration. +void clear_ams_caps(const std::string& dev_id); + } // namespace Slic3r #endif diff --git a/src/slic3r/Utils/MoonrakerPrinterAgent.cpp b/src/slic3r/Utils/MoonrakerPrinterAgent.cpp index b0af222de8..c237e37300 100644 --- a/src/slic3r/Utils/MoonrakerPrinterAgent.cpp +++ b/src/slic3r/Utils/MoonrakerPrinterAgent.cpp @@ -658,10 +658,10 @@ int MoonrakerPrinterAgent::safe_array_int(const nlohmann::json& arr, int idx) // Fetch filament info from moonraker database bool MoonrakerPrinterAgent::fetch_moonraker_filament_data(std::vector& trays, int& max_lane_index) { - // Shared with OrcaPrinterAgent's lane_data read; only the synced outcome - // matters here, since a missing namespace or an empty one both fall through - // to the Happy Hare query. tray_info_idx is resolved later, on the main - // thread, inside build_ams_payload_for_device. + // Only the synced outcome matters here: a missing namespace, an empty one, + // a transport error and an unreadable body all fall through to the Happy + // Hare query. tray_info_idx is resolved later, on the main thread, inside + // build_ams_payload_for_device. return read_moonraker_lane_data(device_info.base_url, device_info.api_key, trays, max_lane_index) == LaneDataFetch::synced; } diff --git a/src/slic3r/Utils/OrcaPrinterAgent.cpp b/src/slic3r/Utils/OrcaPrinterAgent.cpp index 00edf7b902..8ae96c28ae 100644 --- a/src/slic3r/Utils/OrcaPrinterAgent.cpp +++ b/src/slic3r/Utils/OrcaPrinterAgent.cpp @@ -444,15 +444,6 @@ 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 @@ -633,6 +624,9 @@ void OrcaPrinterAgent::register_ams_capabilities(const std::string& dev_id, cons if (info_it == envelope.end() || !info_it->is_object() || info_it->value("command", "") != "get_capabilities") return; const auto caps_it = info_it->find("capabilities"); + // A reply that is not a complete capabilities answer is ignored: treating a + // transient malformed reply as "no capabilities" would gate every AMS write + // until a good one arrives. Stale state is cleared on disconnect instead. if (caps_it == info_it->end() || !caps_it->is_object()) return; const auto proto_it = caps_it->find("protocol"); @@ -667,15 +661,20 @@ void OrcaPrinterAgent::register_ams_capabilities(const std::string& dev_id, cons if (!fms_known) has_ams = !ops.empty(); register_ams_capability(dev_id, has_ams); + + // features.filament_slots is the connector's slot model (REQ-FMS-001), + // independent of fms: a printer with no material hardware still has slots. + bool has_slots = false; + if (features_it != proto_it->end() && features_it->is_object()) { + const auto slots_it = features_it->find("filament_slots"); + if (slots_it != features_it->end() && slots_it->is_boolean()) + has_slots = slots_it->get(); + } + register_filament_slots(dev_id, has_slots); } 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); register_ams_capabilities(dev_id, payload); std::string merged_payload = merge_capabilities(dev_id, payload); @@ -715,19 +714,6 @@ 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; @@ -803,9 +789,12 @@ 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); - // tray_id here is the flat global lane (DevFilaSystem slot index). + // tray_id is the BBL tray id (ams_id*4 + tray). Send the coordinates, not a + // fabricated flat lane: the server's one resolver maps wide and sparse + // boxes correctly (REQ-STS-008 §7.8). j["print"]["selector"] = "lane"; - j["print"]["lane"] = tray_number; + j["print"]["ams_id"] = tray_number / 4; + j["print"]["slot_id"] = tray_number % 4; return route_send(lan_mode, dev_id, j.dump()); } @@ -870,148 +859,12 @@ FilamentSyncMode OrcaPrinterAgent::get_filament_sync_mode() const 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)) { + if (!dev_id.empty() && (has_ams_capability(dev_id) || has_filament_slots(dev_id))) { return FilamentSyncMode::subscription; } return FilamentSyncMode::none; } -// 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. - std::vector trays; - int max_lane_index = 0; - switch (read_moonraker_lane_data(origin, api_key, trays, max_lane_index)) { - case LaneDataFetch::synced: - break; - case LaneDataFetch::none: - // 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; - case LaneDataFetch::unknown: - // 404: not knowable yet (pre-bootstrap or acknowledged-unknown topology). - // Never latched; the next doorbell or reconnect retries. - return LaneDataState::unknown; - case LaneDataFetch::error: - return LaneDataState::error; - } - - // 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_for_lanes(max_lane_index), 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 @@ -1251,7 +1104,6 @@ int OrcaPrinterAgent::connect_printer(std::string dev_id, std::string dev_ip, st 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; @@ -1328,10 +1180,7 @@ 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; @@ -1344,6 +1193,10 @@ int OrcaPrinterAgent::disconnect_printer() << " connected=" << (doomed && doomed->is_connected() ? "yes" : "no") << " transport=" << connection_type_name(previous_connection) << "->" << connection_type_name(current_connection); + // Drop the device's declared capabilities so a reconnect starts from "no + // reply yet" instead of a stale declaration from the previous session. + if (!prev_dev.empty()) + clear_ams_caps(prev_dev); // Tell the printer to stop pushing and drop the report topic before the socket // goes away (§3.2/§5.4: deselect issues pushing.stop on both transports). if (doomed && !prev_dev.empty() && doomed->is_connected()) { @@ -1381,8 +1234,23 @@ std::string OrcaPrinterAgent::canonicalize_ams_payload(const std::string& dev_id 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) + + // filament_setting is exempt from the ams_ops union: it persists + // connector state through the filament-slot model, so filament_slots + // alone advertises it (OrcaSonar OPCP §7.8). + auto op_allowed = [&dev_id](const std::string& o) { + return o.empty() || ams_op_supported(dev_id, o) || (o == "filament_setting" && has_filament_slots(dev_id)); + }; + + if (cmd == "ams_change_filament" && print.contains("selector")) { + // Already canonical (e.g. command_ams_select_tray): gate the op, + // but never rewrite the body. + const std::string sel = print.value("selector", std::string()); + const std::string sel_op = (sel == "lane") ? "change_filament" : sel; + if (!op_allowed(sel_op) && unsupported) + *unsupported = true; + return json_str; + } auto int_or = [&print](const char* key, int fallback) { const auto it = print.find(key); @@ -1394,34 +1262,45 @@ std::string OrcaPrinterAgent::canonicalize_ams_payload(const std::string& dev_id 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.erase("slot_id"); + print.erase("ams_id"); print["selector"] = "unload"; op = "unload"; } else if (target == 255 || ams == 254 || ams == 255) { + print.erase("slot_id"); + print.erase("ams_id"); print["selector"] = "external"; op = "external"; } else { - const int lane = target >= 0 ? target : (ams >= 0 ? ams * 4 + slot : slot); + // Box change: forward the wire coordinates unchanged. Erase + // both first so a half-present coordinate pair from the client + // cannot leak into the body (a lone lane alias is untouched). A + // coordinate-less body is left for the server's own -19. print["selector"] = "lane"; - print["lane"] = lane; - op = "change_filament"; + print.erase("ams_id"); + print.erase("slot_id"); + if (ams >= 0 && slot >= 0) { + print["ams_id"] = ams; + print["slot_id"] = slot; + } + op = "change_filament"; } } else if (cmd == "ams_filament_setting") { + op = "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; + if (ams >= 0 && slot >= 0) { + // Coordinates are the server resolver's own form (REQ-FMS-001 + // §6.2.13). Forward them unchanged for box and external spools; + // never fabricate a flat lane (tray id != layout lane). + print["ams_id"] = ams; + print["slot_id"] = slot; + print.erase("tray_id"); } - print["lane"] = ams * 4 + slot; - print.erase("slot_id"); - print.erase("tray_id"); - print.erase("ams_id"); + // A lane-only or coordinate-less body is left as sent so the server + // applies the dual-form rule and its own -19, never -5. } 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 @@ -1441,10 +1320,8 @@ std::string OrcaPrinterAgent::canonicalize_ams_payload(const std::string& dev_id } else if (cmd == "auto_stop_ams_dry") { op = "stop_dry"; } - if (!op.empty() && !ams_op_supported(dev_id, op)) { - if (unsupported) - *unsupported = true; - } + if (!op_allowed(op) && unsupported) + *unsupported = true; return envelope.dump(); } catch (const std::exception&) { return json_str; diff --git a/src/slic3r/Utils/OrcaPrinterAgent.hpp b/src/slic3r/Utils/OrcaPrinterAgent.hpp index 432ef25aba..e247be000d 100644 --- a/src/slic3r/Utils/OrcaPrinterAgent.hpp +++ b/src/slic3r/Utils/OrcaPrinterAgent.hpp @@ -94,10 +94,10 @@ public: 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. + // reports a material system (get_capabilities protocol.features.fms) or the + // filament-slot model (protocol.features.filament_slots), `none` otherwise. + // The AMS view comes from the pushed `ams`/`vir_slot` in push_status, so + // there is no fetch to keep fresh and the mode is transport-agnostic. FilamentSyncMode get_filament_sync_mode() const override; // Test-only: drive emit_connect_sequence directly (no socket). @@ -109,15 +109,6 @@ public: // Test-only: advance the LAN connection epoch without a connect/disconnect cycle. void bump_lan_generation_for_test() { ++m_lan_generation; } - // 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 @@ -210,7 +201,6 @@ private: 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 @@ -220,34 +210,9 @@ private: // 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). + // Used by the LAN upload path. 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(); OrcaMqttConnection* get_appropriate_mqtt_connection(bool is_lan = true); diff --git a/tests/libslic3r/test_preset_bundle_loading.cpp b/tests/libslic3r/test_preset_bundle_loading.cpp index 6ec6ba9b1e..3d03bc4819 100644 --- a/tests/libslic3r/test_preset_bundle_loading.cpp +++ b/tests/libslic3r/test_preset_bundle_loading.cpp @@ -5482,3 +5482,35 @@ TEST_CASE("Config import confines zip entries, preset names and bundle ids to th CHECK_FALSE(any_filename_contains(temp_dir.path(), "bundle-escape")); } } + +// OrcaSonar and the Qidi Box report a material type but no slicer preset id. +// The Orca agent's AMS sync path fills one with filament_id_by_type, whose +// modifier stripping ("PETG Basic" -> the PETG family) this locks in. +TEST_CASE("filament_id_by_type resolves a modifier type to its generic family", "[Preset][Filament]") +{ + PresetBundle bundle; + + Preset &pla = add_inmemory_preset(bundle.filaments, "Generic PLA @System"); + pla.is_system = true; + pla.is_visible = true; + pla.is_compatible = true; + pla.filament_id = "GFL99"; + pla.config.option("filament_type")->value = "PLA"; + + Preset &petg = add_inmemory_preset(bundle.filaments, "Generic PETG @System"); + petg.is_system = true; + petg.is_visible = true; + petg.is_compatible = true; + petg.filament_id = "GFG99"; + petg.config.option("filament_type")->value = "PETG"; + + CHECK(bundle.filaments.filament_id_by_type("PETG Basic") == "GFG99"); + CHECK(bundle.filaments.filament_id_by_type("PLA") == "GFL99"); + + // The strict overload reports no match instead of binding a fallback. + std::string out = "unchanged"; + CHECK_FALSE(bundle.filaments.filament_id_by_type("Kevlar", out)); + CHECK(out == "unchanged"); + CHECK(bundle.filaments.filament_id_by_type("PETG Basic", out)); + CHECK(out == "GFG99"); +} diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index 956ae088aa..2952aac974 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -6,6 +6,7 @@ add_executable(${_TEST_NAME}_tests test_dev_mapping.cpp test_filament_bitmap_utils.cpp test_device_progress.cpp + test_device_manager.cpp test_network_versions.cpp test_action_source.cpp test_plugin_host_api.cpp diff --git a/tests/slic3rutils/test_device_manager.cpp b/tests/slic3rutils/test_device_manager.cpp new file mode 100644 index 0000000000..fc7eb99f84 --- /dev/null +++ b/tests/slic3rutils/test_device_manager.cpp @@ -0,0 +1,156 @@ +// why: match the GUI include order to avoid rpcndr.h byte/std::byte +// ambiguity in the Windows COM headers. +// why: wx/timer.h must precede DeviceManager.hpp because +// DeviceErrorDialog.hpp uses wxTimerEvent. +#ifdef WIN32 + #ifndef WIN32_LEAN_AND_MEAN + #define WIN32_LEAN_AND_MEAN + #endif + #ifndef NOMINMAX + #define NOMINMAX + #endif + #include +#endif + +#include + +#include + +#include + +#include "slic3r/GUI/DeviceManager.hpp" +#include "slic3r/GUI/DeviceCore/DevFilaSystem.h" + +#include + +using json = nlohmann::json; +using namespace Slic3r; + +// DeviceManager's push_status contract for the OrcaSonar virtual tray: an +// authoritative empty clears, a populated key re-enables, and a frame that +// omits the key leaves both the trays and the support flag alone. + +// Contract: an authoritative empty vir_slot ([] = "known, no virtual slots") +// clears the seeded virtual trays. The consumers were guarded so an empty +// vector is safe. +TEST_CASE("An empty vir_slot clears the virtual trays", "[DeviceManager]") +{ + MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1"); + REQUIRE(machine.vt_slot.size() == 1); + REQUIRE(machine.vt_slot[0].id == "255"); + + machine.parse_json("lan", R"({"print":{"command":"push_status","vir_slot":[]}})", false); + + CHECK(machine.vt_slot.empty()); + CHECK_FALSE(machine.ams_support_virtual_tray); +} + +// A frame that omits vir_slot must leave the seeded virtual tray untouched. +TEST_CASE("A missing vir_slot keeps the virtual trays", "[DeviceManager]") +{ + MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1"); + REQUIRE(machine.vt_slot.size() == 1); + + machine.parse_json("lan", R"({"print":{"command":"push_status"}})", false); + + CHECK(machine.vt_slot.size() == 1); + CHECK(machine.ams_support_virtual_tray); +} + +// Repopulating after a clear must not rely on the constructor's seed, and must +// re-enable virtual-tray support even though the clear turned the flag off. +TEST_CASE("Virtual trays repopulate after an authoritative clear", "[DeviceManager]") +{ + MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1"); + + machine.parse_json("lan", R"({"print":{"command":"push_status","vir_slot":[]}})", false); + REQUIRE(machine.vt_slot.empty()); + REQUIRE_FALSE(machine.ams_support_virtual_tray); + + machine.parse_json("lan", R"({"print":{"command":"push_status","vir_slot":[{"id":"255"},{"id":"254"}]}})", false); + + REQUIRE(machine.vt_slot.size() == 2); + CHECK(machine.vt_slot[0].id == "255"); + CHECK(machine.vt_slot[1].id == "254"); + CHECK(machine.ams_support_virtual_tray); +} + +// A deputy with no main is not an index-1 write into an empty vector. +TEST_CASE("An orphan deputy virtual tray is dropped, not indexed", "[DeviceManager]") +{ + MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1"); + + machine.parse_json("lan", R"({"print":{"command":"push_status","vir_slot":[]}})", false); + REQUIRE(machine.vt_slot.empty()); + + machine.parse_json("lan", R"({"print":{"command":"push_status","vir_slot":[{"id":"254"}]}})", false); + + CHECK(machine.vt_slot.empty()); +} + +// acks that target the virtual tray must survive an emptied vt_slot. +TEST_CASE("Virtual tray acks are safe with no virtual tray", "[DeviceManager]") +{ + MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1"); + + machine.parse_json("lan", R"({"print":{"command":"push_status","vir_slot":[]}})", false); + REQUIRE(machine.vt_slot.empty()); + + machine.parse_json("lan", R"({"print":{"command":"ams_filament_setting","ams_id":255,"tray_id":255}})", false); + machine.parse_json("lan", R"({"print":{"command":"extrusion_cali_set","tray_id":255,"k_value":0.02}})", false); + + CHECK(machine.vt_slot.empty()); +} + +// Only Bambu's own agent treats a non-zero tag_uid as an RFID lock; agent-managed +// printers send it as metadata and must keep their trays editable. +TEST_CASE("Only the BBL agent is RFID-locking", "[DeviceManager]") +{ + MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1"); + + machine.printer_agent_id = "bbl"; + CHECK(machine.is_bbl_agent()); + + machine.printer_agent_id = "orca"; + CHECK_FALSE(machine.is_bbl_agent()); + + machine.printer_agent_id = ""; + CHECK(machine.is_bbl_agent()); +} + +// An OPCP error ack carries result "fail" and a reason; a success ack (or one +// without result) must not be mistaken for a failure. +TEST_CASE("AMS filament setting acks expose OPCP failures", "[DeviceManager]") +{ + std::string reason; + + const json failure = json::parse(R"({"result":"fail","errno":-19,"reason":"unknown slot"})"); + CHECK(MachineObject::ams_filament_ack_failed(failure, reason)); + CHECK(reason == "unknown slot"); + + reason = "stale"; + const json success = json::parse(R"({"result":"success","errno":0})"); + CHECK_FALSE(MachineObject::ams_filament_ack_failed(success, reason)); + CHECK(reason.empty()); + + const json plain = json::parse(R"({"ams_id":1,"tray_id":2})"); + CHECK_FALSE(MachineObject::ams_filament_ack_failed(plain, reason)); +} + +// An ack that targets a tray but omits tray_id must not abort the frame: it +// falls back to slot 0 instead of an unguarded get on a missing key. +TEST_CASE("An AMS filament ack without a tray_id targets slot 0", "[DeviceManager]") +{ + MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1"); + + const json ams = json::parse(R"({"ams":{"tray_exist_bits":"1","ams":[ + { "id": "0", "info": "00000001", "tray": [ { "id": "0" } ] } ]}})"); + DevFilaSystemParser::ParseV1_0(ams, &machine, machine.GetFilaSystem().get(), false); + REQUIRE(machine.GetFilaSystem()->GetAmsTray("0", "0") != nullptr); + + CHECK_NOTHROW(machine.parse_json("lan", R"({"print":{"command":"ams_filament_setting","ams_id":0,"tray_color":"FF0000FF","tray_type":"PLA","tray_info_idx":"GFA00","nozzle_temp_min":190,"nozzle_temp_max":230}})", false)); + + const DevAmsTray* tray = machine.GetFilaSystem()->GetAmsTray("0", "0"); + REQUIRE(tray != nullptr); + CHECK(tray->color == "FF0000FF"); +} diff --git a/tests/slic3rutils/test_device_progress.cpp b/tests/slic3rutils/test_device_progress.cpp index be58dc6030..4d6ea99ed0 100644 --- a/tests/slic3rutils/test_device_progress.cpp +++ b/tests/slic3rutils/test_device_progress.cpp @@ -139,3 +139,5 @@ TEST_CASE("Zero progress replaces active shared progress", "[DeviceManager][Prog REQUIRE(machine.mc_print_percent == 0); CHECK(current_subtask->task_progress == 0); } + + diff --git a/tests/slic3rutils/test_orca_printer_agent.cpp b/tests/slic3rutils/test_orca_printer_agent.cpp index a2746f34ac..c60f70d8cb 100644 --- a/tests/slic3rutils/test_orca_printer_agent.cpp +++ b/tests/slic3rutils/test_orca_printer_agent.cpp @@ -127,12 +127,28 @@ TEST_CASE("filament sync follows the printer's AMS capability", "[OrcaPrinterAge /*local=*/true); CHECK(agent.get_filament_sync_mode() == Slic3r::FilamentSyncMode::none); + // filament_slots alone enables sync: a standalone printer with no material + // system still has slots (REQ-FMS-001). + agent.deliver_to_sink("dev-ams-1", + R"({"info":{"command":"get_capabilities","capabilities":{"protocol":{"features":{"fms":false,"filament_slots":true}}}}})", + /*local=*/true); + CHECK(agent.get_filament_sync_mode() == Slic3r::FilamentSyncMode::subscription); + // 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); + // A reply without a protocol block is not a capabilities answer: it is + // ignored, so a transient malformed reply cannot gate every write. The + // ams_ops fallback above still holds. + agent.deliver_to_sink("dev-ams-1", + R"({"info":{"command":"get_capabilities","capabilities":{}}})", + /*local=*/true); + CHECK(agent.get_filament_sync_mode() == Slic3r::FilamentSyncMode::subscription); + + // Disconnecting forgets the declaration rather than letting it go stale. agent.disconnect_printer(); CHECK(agent.get_filament_sync_mode() == Slic3r::FilamentSyncMode::none); } @@ -155,46 +171,21 @@ TEST_CASE("a capability reply without ams_ops gates AMS writes", "[OrcaPrinterAg CHECK(unsupported); } -// 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]") { +// A malformed get_capabilities reply must be ignored, not read as "no +// capabilities": doing so would set ops_known with an empty set and gate every +// AMS write until a good reply arrives. +TEST_CASE("a malformed capability reply does not gate AMS writes", "[OrcaPrinterAgent]") { Probe agent("/tmp"); - REQUIRE(agent.connect_printer("dev-1", "10.255.255.1", "orcasonar", "code", false) == BAMBU_NETWORK_SUCCESS); + agent.deliver_to_sink("dev-malformed", + R"({"info":{"command":"get_capabilities","capabilities":{"protocol":{"ams_ops":["change_filament"]}}}})", + /*local=*/true); + CHECK(Slic3r::ams_op_supported("dev-malformed", "change_filament")); - 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(); + agent.deliver_to_sink("dev-malformed", + R"({"info":{"command":"get_capabilities","capabilities":{}}})", + /*local=*/true); + // The malformed reply changed nothing, rather than clearing the op set. + CHECK(Slic3r::ams_op_supported("dev-malformed", "change_filament")); } TEST_CASE("post-connect sequence is subscribe then 4 requests in order", "[OrcaPrinterAgent]") { @@ -320,10 +311,11 @@ TEST_CASE("OrcaPrinterAgent rewrites Bambu ams_* payloads onto the canonical Orc 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); + // Coordinates are the resolver's own form; target (a BBL tray id) is dropped. + CHECK(out["print"]["ams_id"] == 1); + CHECK(out["print"]["slot_id"] == 1); + CHECK(!out["print"].contains("lane")); 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): @@ -331,17 +323,48 @@ TEST_CASE("OrcaPrinterAgent rewrites Bambu ams_* payloads onto the canonical Orc 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")); + CHECK(!out["print"].contains("ams_id")); 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); + // A coordinate-less body must not fabricate a flat lane from a BBL tray id; + // the server validates the address and answers -19. + out = nlohmann::json::parse(canon("dev-c1", R"({"print":{"command":"ams_change_filament","target":6}})")); + CHECK(!out["print"].contains("lane")); + CHECK(!out["print"].contains("target")); + CHECK(out["print"]["selector"] == "lane"); + // Box coordinates are forwarded unchanged: a fabricated flat lane would be + // the BBL tray id, which is not the layout lane for wide/sparse boxes. 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"]["ams_id"] == 1); + CHECK(out["print"]["slot_id"] == 2); + CHECK(!out["print"].contains("lane")); + CHECK(!out["print"].contains("tray_id")); CHECK(out["print"]["tray_type"] == "PLA"); + // Wide-box dual form: both addressings resolve to one slot server-side. + out = nlohmann::json::parse(canon("dev-c1", R"({"print":{"command":"ams_filament_setting","ams_id":1,"slot_id":5,"tray_type":"PLA"}})")); + CHECK(out["print"]["ams_id"] == 1); + CHECK(out["print"]["slot_id"] == 5); + CHECK(!out["print"].contains("lane")); + out = nlohmann::json::parse(canon("dev-c1", R"({"print":{"command":"ams_filament_setting","ams_id":2,"slot_id":1,"tray_type":"PLA"}})")); + CHECK(out["print"]["ams_id"] == 2); + CHECK(out["print"]["slot_id"] == 1); + + // A lane-only body must pass through untouched, never become lane = -5. + const std::string lane_only = R"({"print":{"command":"ams_filament_setting","lane":5,"tray_type":"PLA"}})"; + CHECK(canon("dev-c1", lane_only) == lane_only); + + // External/direct spool (Bambu 254/255): forwarded as the canonical + // ams_id address, never a fabricated lane (REQ-FMS-001). + out = nlohmann::json::parse(canon("dev-c1", R"({"print":{"command":"ams_filament_setting","ams_id":255,"slot_id":0,"tray_id":0,"tray_type":"PLA"}})")); + CHECK(out["print"]["ams_id"] == 255); + CHECK(out["print"]["slot_id"] == 0); + CHECK(!out["print"].contains("lane")); + CHECK(!out["print"].contains("tray_id")); + // 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); @@ -363,9 +386,40 @@ TEST_CASE("OrcaPrinterAgent rewrites Bambu ams_* payloads onto the canonical Orc unsupported = false; canon("dev-c2", R"({"print":{"command":"ams_change_filament","target":1}})", &unsupported); CHECK(!unsupported); + // A selector-carrying canonical body is gated on its selector's op too. + unsupported = false; + canon("dev-c2", R"({"print":{"command":"ams_change_filament","selector":"lane","lane":1}})", &unsupported); + CHECK(!unsupported); + unsupported = false; + canon("dev-c2", R"({"print":{"command":"ams_change_filament","selector":"external"}})", &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); } + +// filament_setting is advertised by filament_slots alone (OPCP §7.8), so a +// standalone printer with no ams_ops can still write slots, while the material +// writes stay gated. +TEST_CASE("a filament_slots reply admits ams_filament_setting without ams_ops", "[OrcaPrinterAgent]") { + Probe agent("/tmp"); + agent.deliver_to_sink("dev-slots", + R"({"info":{"command":"get_capabilities","capabilities":{"protocol":{"features":{"fms":false,"filament_slots":true}}}}})", + /*local=*/true); + + bool unsupported = false; + OrcaPrinterAgent::canonicalize_ams_payload( + "dev-slots", + R"({"print":{"command":"ams_filament_setting","ams_id":255,"slot_id":0,"tray_id":0,"tray_type":"PLA"}})", + &unsupported); + CHECK_FALSE(unsupported); + + unsupported = false; + OrcaPrinterAgent::canonicalize_ams_payload( + "dev-slots", + R"({"print":{"command":"ams_change_filament","target":1,"slot_id":1,"ams_id":0}})", + &unsupported); + CHECK(unsupported); +} diff --git a/tests/slic3rutils/test_printer_agent.cpp b/tests/slic3rutils/test_printer_agent.cpp index f7263ad4fb..9f53511520 100644 --- a/tests/slic3rutils/test_printer_agent.cpp +++ b/tests/slic3rutils/test_printer_agent.cpp @@ -209,6 +209,14 @@ TEST_CASE("unit: AMS capability registry reports only declared material systems" // Later replies overwrite: a removed material system must clear the flag. register_ams_capability("cap-true", false); CHECK_FALSE(has_ams_capability("cap-true")); + + // filament_slots is a separate connector flag: no record reads false, and + // a later reply clears it. + CHECK_FALSE(has_filament_slots("cap-slot-none")); + register_filament_slots("cap-slots", true); + CHECK(has_filament_slots("cap-slots")); + register_filament_slots("cap-slots", false); + CHECK_FALSE(has_filament_slots("cap-slots")); } // why: these builders preserve the Bambu firmware dialect byte-for-byte, including its trailing space.