diff --git a/src/slic3r/GUI/DeviceManager.cpp b/src/slic3r/GUI/DeviceManager.cpp index ec21f72255..859a525f45 100644 --- a/src/slic3r/GUI/DeviceManager.cpp +++ b/src/slic3r/GUI/DeviceManager.cpp @@ -1676,6 +1676,10 @@ int MachineObject::check_resume_condition() } int MachineObject::command_ams_change_filament(bool load, std::string ams_id, std::string slot_id, int old_temp, int new_temp, std::optional extruder_id) { + if (!orca_ams_command_supported("print.ams_change_filament")) { + BOOST_LOG_TRIVIAL(warning) << "command_ams_change_filament: connector does not advertise the command"; + return command_with_dialog(ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED); + } json j; try { auto tray_id = 0; @@ -1719,6 +1723,10 @@ int MachineObject::command_ams_change_filament(bool load, std::string ams_id, st int MachineObject::command_ams_user_settings(bool start_read_opt, bool tray_read_opt, bool remain_flag) { + if (!orca_ams_command_supported("print.ams_user_setting")) { + BOOST_LOG_TRIVIAL(warning) << "command_ams_user_settings: connector does not advertise the command"; + return command_with_dialog(ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED); + } json j; j["print"]["command"] = "ams_user_setting"; j["print"]["sequence_id"] = std::to_string(MachineObject::m_sequence_id++); @@ -1741,8 +1749,25 @@ int MachineObject::command_ams_calibrate(int ams_id) return command_with_dialog(m_agent->command_ams_calibrate(get_dev_id(), ams_id, MachineObject::m_sequence_id++, is_lan_mode_printer())); } +bool MachineObject::orca_ams_command_supported(const char* command) const +{ + if (printer_agent_id != ORCA_PRINTER_AGENT_ID) + return true; + // fms is the AMS axis; the advertised command set is the per-command gate. + if (!is_support_fms) + return false; + return command != nullptr && supported_commands.count(command) != 0; +} + int MachineObject::command_ams_filament_settings(int ams_id, int slot_id, std::string filament_id, std::string setting_id, std::string tray_color, std::string tray_type, int nozzle_temp_min, int nozzle_temp_max) { + // OrcaSonar: writing slot metadata is gated on the filament_slots capability. + // Absent/false means unsupported; Bambu keeps the legacy behaviour. + if (printer_agent_id == ORCA_PRINTER_AGENT_ID && !is_support_filament_slots) { + BOOST_LOG_TRIVIAL(warning) << "command_ams_filament_settings: printer does not advertise filament_slots"; + return command_with_dialog(ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED); + } + int tag_tray_id = 0; int tag_ams_id = ams_id; int tag_slot_id = slot_id; @@ -1777,6 +1802,13 @@ int MachineObject::command_ams_filament_settings(int ams_id, int slot_id, std::s int MachineObject::command_ams_refresh_rfid(int ams_id, int slot_id) { if (!m_agent) return -1; + // OrcaSonar: RFID read requires the connector to advertise the + // `print.ams_get_rfid` command (which already implies fms plus the macro). + // Unknown capability is not support; Bambu is untouched. + if (!orca_ams_command_supported("print.ams_get_rfid")) { + BOOST_LOG_TRIVIAL(warning) << "command_ams_refresh_rfid: connector does not advertise the command"; + return command_with_dialog(ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED); + } return command_with_dialog(m_agent->command_ams_refresh_rfid(get_dev_id(), ams_id, slot_id, MachineObject::m_sequence_id++, is_lan_mode_printer())); } @@ -1790,11 +1822,21 @@ int MachineObject::command_start_camera() int MachineObject::command_ams_select_tray(std::string tray_id) { if (!m_agent) return -1; + // OrcaSonar: this publishes the same macro-backed print.ams_change_filament + // as command_ams_change_filament, so it takes the same capability gate. + if (!orca_ams_command_supported("print.ams_change_filament")) { + BOOST_LOG_TRIVIAL(warning) << "command_ams_select_tray: connector does not advertise the command"; + return command_with_dialog(ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED); + } return command_with_dialog(m_agent->command_ams_select_tray(get_dev_id(), tray_id, MachineObject::m_sequence_id++, is_lan_mode_printer())); } int MachineObject::command_ams_control(std::string action) { + if (!orca_ams_command_supported("print.ams_control")) { + BOOST_LOG_TRIVIAL(warning) << "command_ams_control: connector does not advertise the command"; + return command_with_dialog(ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED); + } if (action == "resume" && check_resume_condition()) return 0; //valid actions @@ -1810,6 +1852,10 @@ int MachineObject::command_ams_control(std::string action) int MachineObject::command_ams_drying_stop() { + if (!orca_ams_command_supported("print.auto_stop_ams_dry")) { + BOOST_LOG_TRIVIAL(warning) << "command_ams_drying_stop: connector does not advertise the command"; + return command_with_dialog(ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED); + } json j; j["print"]["command"] = "auto_stop_ams_dry"; j["print"]["sequence_id"] = std::to_string(MachineObject::m_sequence_id++); @@ -5490,10 +5536,14 @@ void MachineObject::parse_new_info2(const json& info) if (capabilities_it == info.end() || !capabilities_it->is_object()) return; const auto flags_it = capabilities_it->find("flags"); - if (flags_it == capabilities_it->end() || !flags_it->is_object()) + const bool has_flags = flags_it != capabilities_it->end() && flags_it->is_object(); + // Bambu keeps the legacy behavior: a reply without flags is ignored. Orca + // connector-scope features/commands must still be parsed, so fall through + // with an empty flags object (every parse_bool below then no-ops). + if (!has_flags && printer_agent_id != ORCA_PRINTER_AGENT_ID) return; - - const json& flags = *flags_it; + static const json empty_flags = json::object(); + const json& flags = has_flags ? *flags_it : empty_flags; BOOST_LOG_TRIVIAL(info) << "parse_new_info2: OrcaSonar capability flags=" << flags.dump(); auto parse_bool = [&flags](const char* name, bool& target) { @@ -5559,6 +5609,64 @@ void MachineObject::parse_new_info2(const json& info) m_config->ParseConfig(device_config); m_fan->ParseV2_0(fan_config); + + // OrcaSonar connector-scope capabilities. Absent or non-boolean means + // unsupported. A reply without `flags` still parses them (the fall-through + // above); Bambu (bbl) devices never enter this branch and keep the + // flags-only path. + if (printer_agent_id == ORCA_PRINTER_AGENT_ID) { + auto parse_features = [this](const json& features) { + if (!features.is_object()) + return; + auto set_flag = [&features](const char* name, bool& target) { + const auto it = features.find(name); + if (it != features.end() && it->is_boolean()) + target = it->get(); + }; + set_flag("fms", is_support_fms); + set_flag("filament_slots", is_support_filament_slots); + set_flag("filament_mapping", is_support_filament_mapping); + }; + + auto parse_commands = [this](const json& commands) { + if (!commands.is_array()) + return; + for (const auto& command : commands) { + if (command.is_string()) + supported_commands.insert(command.get()); + } + }; + + // Clear before filling so a reply without commands does not retain stale values. + supported_commands.clear(); + is_support_fms = false; + is_support_filament_slots = false; + is_support_filament_mapping = false; + + const auto supported_features_it = info.find("supported_features"); + if (supported_features_it != info.end()) + parse_features(*supported_features_it); + + const auto supported_commands_it = info.find("supported_commands"); + if (supported_commands_it != info.end()) + parse_commands(*supported_commands_it); + + const auto protocol_it = capabilities_it->find("protocol"); + if (protocol_it != capabilities_it->end() && protocol_it->is_object()) { + const auto protocol_features_it = protocol_it->find("features"); + if (protocol_features_it != protocol_it->end()) + parse_features(*protocol_features_it); + + const auto protocol_commands_it = protocol_it->find("supported_commands"); + if (protocol_commands_it != protocol_it->end()) + parse_commands(*protocol_commands_it); + } + + BOOST_LOG_TRIVIAL(info) << "parse_new_info2: fms=" << is_support_fms + << " filament_slots=" << is_support_filament_slots + << " filament_mapping=" << is_support_filament_mapping + << " supported_commands=" << supported_commands.size(); + } } static bool is_hex_digit(char c) { diff --git a/src/slic3r/GUI/DeviceManager.hpp b/src/slic3r/GUI/DeviceManager.hpp index afd0018b8a..21608d946a 100644 --- a/src/slic3r/GUI/DeviceManager.hpp +++ b/src/slic3r/GUI/DeviceManager.hpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -646,6 +647,13 @@ public: bool is_support_partskip{false}; bool is_support_refresh_nozzle{false}; + // OrcaSonar connector-scope capabilities (printer_agent_id == "orca"). Parsed from the + // get_capabilities reply; never consulted on Bambu paths. + bool is_support_fms{false}; + bool is_support_filament_slots{false}; + bool is_support_filament_mapping{false}; + std::set supported_commands; + // refine printer function options bool is_support_spaghetti_detection{false}; bool is_support_purgechutepileup_detection{false}; @@ -787,6 +795,10 @@ public: int command_refresh_nozzle(); int command_set_chamber(int temp); int check_resume_condition(); + // OrcaSonar: true when a macro-backed AMS command may be sent. Bambu paths + // always allow; an Orca device must have advertised the command (unknown + // capability is not support). + bool orca_ams_command_supported(const char* command) const; // ams controls //int command_ams_switch(int tray_index, int old_temp = 210, int new_temp = 210); int command_ams_change_filament(bool load, std::string ams_id, std::string slot_id, int old_temp = 210, int new_temp = 210, std::optional extruder_id = std::nullopt); diff --git a/src/slic3r/GUI/FilamentMappingUtils.hpp b/src/slic3r/GUI/FilamentMappingUtils.hpp new file mode 100644 index 0000000000..b871f76773 --- /dev/null +++ b/src/slic3r/GUI/FilamentMappingUtils.hpp @@ -0,0 +1,65 @@ +#pragma once + +#include +#include + +#include + +#include "libslic3r/ProjectTask.hpp" + +namespace Slic3r { +namespace GUI { + +// True when the normalized ams_mapping2 carries any entry the agent's serializer +// would put on the wire: every integer pair except the {255,255} unmatched +// sentinel, external slots ({255,0}/{254,0}) included. This mirrors +// OrcaPrinterAgent::build_filament_mapping exactly, so the GUI gate and the +// agent gate agree; a mismatch lets an entry reach the agent and be refused late +// with a generic publish error instead of the designed message. +inline bool has_engaged_filament_mapping(const std::string& ams_mapping2) +{ + const nlohmann::json mapping = nlohmann::json::parse(ams_mapping2, nullptr, false); + if (mapping.is_discarded() || !mapping.is_array()) + return false; + for (const auto& entry : mapping) { + if (!entry.is_object()) + continue; + const auto ams_id_it = entry.find("ams_id"); + const auto slot_id_it = entry.find("slot_id"); + if (ams_id_it == entry.end() || slot_id_it == entry.end()) + continue; + if (!ams_id_it->is_number_integer() || !slot_id_it->is_number_integer()) + continue; + if (ams_id_it->get() == 255 && slot_id_it->get() == 255) + continue; // unmatched/unused sentinel: the serializer drops it + return true; + } + return false; +} + +// A used filament with no target would be silently dropped from the wire +// mapping, so the print must be refused rather than run the wrong material. +// m_ams_mapping_result carries exactly the filaments the slice uses. +inline bool has_used_filament_without_target(const std::vector& result) +{ + for (const auto& f : result) { + if (f.get_ams_id() < 0 || f.get_slot_id() < 0) + return true; + } + return false; +} + +// True when at least one used filament already has a target. The used-unmapped +// refusal applies to a partially mapped print; a print with no mapping at all +// is handled by the existing all-invalid send-path flow. +inline bool has_any_mapped_target(const std::vector& result) +{ + for (const auto& f : result) { + if (f.get_ams_id() >= 0 && f.get_slot_id() >= 0) + return true; + } + return false; +} + +} // namespace GUI +} // namespace Slic3r diff --git a/src/slic3r/GUI/SelectMachine.cpp b/src/slic3r/GUI/SelectMachine.cpp index acd47a977f..e4ad1650e1 100644 --- a/src/slic3r/GUI/SelectMachine.cpp +++ b/src/slic3r/GUI/SelectMachine.cpp @@ -37,6 +37,8 @@ #include "libslic3r/MultiNozzleUtils.hpp" // filament-change-gap model for the best-position popup #include "BackgroundSlicingProcess.hpp" // complete type for background_process().get_current_gcode_result() #include "DeviceCore/DevStorage.h" +#include "slic3r/Utils/NetworkAgentFactory.hpp" +#include "FilamentMappingUtils.hpp" #include #include @@ -3478,6 +3480,9 @@ void SelectMachineDialog::navigate_to_timelapse_page() this->EndModal(wxID_CANCEL); } +// Mapping helpers live in FilamentMappingUtils.hpp (shared with +// SendMultiMachinePage); they mirror the agent serializer exactly. + void SelectMachineDialog::on_send_print() { BOOST_LOG_TRIVIAL(info) << "print_job: on_ok to send"; @@ -3532,6 +3537,26 @@ void SelectMachineDialog::on_send_print() get_ams_mapping_result(ams_mapping_array,ams_mapping_array2, ams_mapping_info); + // OrcaSonar: a mapped print requires the connector to advertise + // filament_mapping and the index correlation to be verified. Refuse rather + // than start with the map silently dropped; Bambu keeps its behavior. + if (obj_->printer_agent_id == ORCA_PRINTER_AGENT_ID) { + const bool mapping_available = obj_->is_support_filament_mapping && ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED; + if (!mapping_available && has_engaged_filament_mapping(ams_mapping_array2)) { + BOOST_LOG_TRIVIAL(warning) << "print_job: filament mapping unavailable (capability=" << obj_->is_support_filament_mapping + << ", correlation_verified=" << ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED << "); refusing mapped print"; + m_status_bar->set_status_text(_L("AMS filament mapping is not available for this printer. Clear the AMS mapping before printing.")); + Enable_Send_Button(true); + return; + } + if (has_any_mapped_target(m_ams_mapping_result) && has_used_filament_without_target(m_ams_mapping_result)) { + BOOST_LOG_TRIVIAL(warning) << "print_job: a used filament has no AMS target; refusing print"; + m_status_bar->set_status_text(_L("A filament used by this print has no AMS mapping. Assign it before printing.")); + Enable_Send_Button(true); + return; + } + } + if (m_print_type == PrintFromType::FROM_NORMAL) { result = m_plater->send_gcode(m_print_plate_idx, [this](int export_stage, int current, int total, bool& cancel) { if (this->m_is_canceled) return; diff --git a/src/slic3r/GUI/SendMultiMachinePage.cpp b/src/slic3r/GUI/SendMultiMachinePage.cpp index 7e65c2c530..c95b2b54bf 100644 --- a/src/slic3r/GUI/SendMultiMachinePage.cpp +++ b/src/slic3r/GUI/SendMultiMachinePage.cpp @@ -10,6 +10,8 @@ #include "DeviceCore/DevManager.h" #include "DeviceCore/DevStorage.h" +#include "slic3r/Utils/NetworkAgentFactory.hpp" +#include "FilamentMappingUtils.hpp" namespace Slic3r { namespace GUI { @@ -692,6 +694,8 @@ bool SendMultiMachinePage::get_ams_mapping_result(std::string &mapping_array_str return true; } +// Mapping helpers live in FilamentMappingUtils.hpp, shared with SelectMachine. + void SendMultiMachinePage::on_send(wxCommandEvent& event) { event.Skip(); @@ -740,6 +744,29 @@ void SendMultiMachinePage::on_send(wxCommandEvent& event) if (!wxGetApp().is_blocking_printing(obj)) { PrintParams params = request_params(obj); + // OrcaSonar: a mapped print requires the connector to advertise + // filament_mapping, and a partially mapped print must not silently drop a + // used filament. Any entry the serializer would put on the wire engages the + // gate, external slots ({255,0}/{254,0}) included; the extra-spool branch + // rewrites to exactly those. Bambu is unchanged. + if (obj->printer_agent_id == ORCA_PRINTER_AGENT_ID) { + const bool mapping_available = obj->is_support_filament_mapping && ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED; + if (!mapping_available && has_engaged_filament_mapping(params.ams_mapping2)) { + BOOST_LOG_TRIVIAL(warning) << "SendMultiMachinePage: filament mapping unavailable (capability=" << obj->is_support_filament_mapping + << ", correlation_verified=" << ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED << "); refusing mapped print for " + << obj->get_dev_id(); + MessageDialog msg_wingow(nullptr, _L("AMS filament mapping is not available for this printer. Clear the AMS mapping before printing."), "", wxICON_WARNING | wxOK); + msg_wingow.ShowModal(); + return; + } + if (params.task_use_ams && has_any_mapped_target(m_ams_mapping_result) && + has_used_filament_without_target(m_ams_mapping_result)) { + BOOST_LOG_TRIVIAL(warning) << "SendMultiMachinePage: a used filament has no target; refusing print for " << obj->get_dev_id(); + MessageDialog msg_wingow(nullptr, _L("A filament used by this print has no AMS mapping. Assign it before printing."), "", wxICON_WARNING | wxOK); + msg_wingow.ShowModal(); + return; + } + } print_params.push_back(params); } } diff --git a/src/slic3r/GUI/SyncAmsInfoDialog.cpp b/src/slic3r/GUI/SyncAmsInfoDialog.cpp index 1c42735b0b..d08398488d 100644 --- a/src/slic3r/GUI/SyncAmsInfoDialog.cpp +++ b/src/slic3r/GUI/SyncAmsInfoDialog.cpp @@ -1373,6 +1373,12 @@ bool SyncAmsInfoDialog::get_ams_mapping_result(std::string &mapping_array_str, s BOOST_LOG_TRIVIAL(error) << "get_ams_mapping_result, plater is nullptr"; } + // mapping_v1_json is built one entry per filament preset, in preset order, so the + // array position is the logical filament index the generated G-code toolchange + // references (the identifier handed to the Klipper toolchange macro; see + // OrcaPrinterAgent::build_filament_mapping and the index-correlation test in + // tests/slic3rutils/test_orca_printer_agent.cpp). Never re-densify after dropping + // sentinel entries, or a used filament would be aimed at the wrong lane. for (int i = 0; i < wxGetApp().preset_bundle->filament_presets.size(); i++) { int tray_id = -1; json mapping_item_v1; diff --git a/src/slic3r/Utils/NetworkAgentFactory.hpp b/src/slic3r/Utils/NetworkAgentFactory.hpp index a055b19493..764a41edb5 100644 --- a/src/slic3r/Utils/NetworkAgentFactory.hpp +++ b/src/slic3r/Utils/NetworkAgentFactory.hpp @@ -17,6 +17,14 @@ namespace Slic3r { static constexpr char ORCA_PRINTER_AGENT_ID[] = "orca"; static constexpr char BBL_PRINTER_AGENT_ID[] = "bbl"; +// Index-correlation merge gate (plan PR 3). The per-print `filament_mapping` +// serializer is shipped disabled until a slice-level test proves that the +// `ams_mapping2` array position equals the toolchange identifier the generated +// G-code emits (T / next_filament_id). Both the GUI send gates and +// OrcaPrinterAgent::start_sdcard_print consult this, so mapping is refused +// visibly rather than silently dropped. Flip to true only with that test. +static constexpr bool ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED = false; + // Factory function type for creating printer agents using PrinterAgentFactory = std::function(std::shared_ptr cloud_agent, const std::string& log_dir)>; diff --git a/src/slic3r/Utils/OrcaPrinterAgent.cpp b/src/slic3r/Utils/OrcaPrinterAgent.cpp index 6b1355eccc..41ffcf8b2e 100644 --- a/src/slic3r/Utils/OrcaPrinterAgent.cpp +++ b/src/slic3r/Utils/OrcaPrinterAgent.cpp @@ -43,6 +43,23 @@ namespace { namespace fs = boost::filesystem; +// Per-device filament_mapping capability, mirrored from the get_capabilities +// reply in merge_capabilities and read by start_sdcard_print so the field is +// refused defensively when the connector never advertised it or the +// index-correlation merge gate (ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED) is +// not satisfied. +std::mutex g_filament_mapping_mutex; +std::unordered_map g_filament_mapping_cache; + +// True only when the connector's get_capabilities reply advertised +// filament_mapping for this device. Unknown is not support. +bool filament_mapping_advertised(const std::string& dev_id) +{ + std::lock_guard l(g_filament_mapping_mutex); + const auto it = g_filament_mapping_cache.find(dev_id); + return it != g_filament_mapping_cache.end() && it->second; +} + // params.filename is normally the exported .3mf archive; the sliced G-code sits // beside it with the same stem (".12345.0.3mf" -> ".12345.0.gcode"). params.dst_file, // when set, already points straight at a file (the "print a file already on the @@ -588,6 +605,33 @@ 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; } + + // Per-device filament_mapping capability. Both feature maps carry it; + // either being true means the connector advertised it. + bool mapping_advertised = false; + { + const auto top_features = info_it->find("supported_features"); + if (top_features != info_it->end() && top_features->is_object()) { + const auto it = top_features->find("filament_mapping"); + if (it != top_features->end() && it->is_boolean()) + mapping_advertised = it->get(); + } + if (!mapping_advertised && caps_it != info_it->end() && caps_it->is_object()) { + const auto protocol_it = caps_it->find("protocol"); + if (protocol_it != caps_it->end() && protocol_it->is_object()) { + const auto features_it = protocol_it->find("features"); + if (features_it != protocol_it->end() && features_it->is_object()) { + const auto it = features_it->find("filament_mapping"); + if (it != features_it->end() && it->is_boolean()) + mapping_advertised = it->get(); + } + } + } + } + { + std::lock_guard l(g_filament_mapping_mutex); + g_filament_mapping_cache[dev_id] = mapping_advertised; + } // The capabilities reply itself is forwarded unchanged. } else { @@ -1415,11 +1459,17 @@ int OrcaPrinterAgent::start_print(PrintParams params, OnUpdateStatusFn update_fn return BAMBU_NETWORK_SUCCESS; } -int OrcaPrinterAgent::start_local_print_with_record(PrintParams params, - OnUpdateStatusFn update_fn, - WasCancelledFn cancel_fn, - OnWaitFn wait_fn) -{ return BAMBU_NETWORK_SUCCESS; } +int OrcaPrinterAgent::start_local_print_with_record(PrintParams /*params*/, + OnUpdateStatusFn /*update_fn*/, + WasCancelledFn /*cancel_fn*/, + OnWaitFn /*wait_fn*/) +{ + // OrcaSonar has no FTP "send with record" path. Report a non-success result so + // PrintJob falls back to start_print() (cloud upload + start_sdcard_print) instead + // of treating a print that was never sent as successful. + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: start_local_print_with_record is unimplemented; deferring to start_print"; + return BAMBU_NETWORK_ERR_FTP_UPLOAD_FAILED; +} // Upload one G-code file to the printer's `gcodes` root over OrcaSonar's // Moonraker-compatible HTTP facade. No print is started here (print=false); the @@ -1587,6 +1637,59 @@ int OrcaPrinterAgent::start_local_print(PrintParams params, OnUpdateStatusFn upd return start_sdcard_print(params, update_fn, cancel_fn); } +// Serialize PrintParams::ams_mapping2 (the dialog's mapping_v1_json, one entry per logical +// filament in preset order) into the print.gcode_file `filament_mapping` array. The array +// position becomes `filament_index`; {255,255} (unmatched/unused) is dropped. Returns an +// empty array when nothing usable remains so the caller can omit the field entirely. +nlohmann::json OrcaPrinterAgent::build_filament_mapping(const std::string& ams_mapping2) +{ + nlohmann::json mapping = nlohmann::json::array(); + if (ams_mapping2.empty()) + return mapping; + + const nlohmann::json parsed = nlohmann::json::parse(ams_mapping2, nullptr, false); + if (parsed.is_discarded() || !parsed.is_array()) { + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: ams_mapping2 is not a JSON array; not sending filament_mapping"; + return mapping; + } + + for (std::size_t i = 0; i < parsed.size(); ++i) { + const nlohmann::json& entry = parsed[i]; + if (!entry.is_object()) + continue; + const auto ams_id_it = entry.find("ams_id"); + const auto slot_id_it = entry.find("slot_id"); + if (ams_id_it == entry.end() || slot_id_it == entry.end()) + continue; + if (!ams_id_it->is_number_integer() || !slot_id_it->is_number_integer()) + continue; + + const int ams_id = ams_id_it->get(); + const int slot_id = slot_id_it->get(); + if (ams_id == 255 && slot_id == 255) + continue; + + mapping.push_back({{"filament_index", static_cast(i)}, {"ams_id", ams_id}, {"slot_id", slot_id}}); + } + return mapping; +} + +// Pure builder for the print.gcode_file payload: the base command plus, only +// when non-empty, the filament_mapping array. An empty mapping leaves the +// payload byte-identical to today's unmapped command. +nlohmann::json OrcaPrinterAgent::build_gcode_file_payload(const std::string& sequence_id, + const std::string& target, + const nlohmann::json& filament_mapping) +{ + nlohmann::json j; + j["print"]["command"] = "gcode_file"; + j["print"]["sequence_id"] = sequence_id; + j["print"]["param"] = target; + if (filament_mapping.is_array() && !filament_mapping.empty()) + j["print"]["filament_mapping"] = filament_mapping; + return j; +} + // Start a file that already lives on the printer by publishing the canonical // OPCP print.gcode_file command to device//request. The acknowledgement // and lifecycle progress arrive asynchronously as print.push_status on the @@ -1604,10 +1707,23 @@ int OrcaPrinterAgent::start_sdcard_print(PrintParams params, OnUpdateStatusFn up // otherwise start what start_send_gcode_to_sdcard just uploaded to `gcodes`. const std::string target = params.dst_file.empty() ? remote_gcode_name(params) : fs::path(params.dst_file).filename().string(); - nlohmann::json j; - j["print"]["command"] = "gcode_file"; - j["print"]["sequence_id"] = next_gcode_file_sequence_id(); - j["print"]["param"] = target; + // Per-print mapping. A mapped print is refused when the connector did not + // advertise filament_mapping or the index correlation is unverified: the GUI + // send gates make this visible first, and this is the defensive gate for + // callers that bypass them (calibration, plugin). Never start a mapped print + // with the map silently dropped. + const nlohmann::json filament_mapping = build_filament_mapping(params.ams_mapping2); + if (!filament_mapping.empty()) { + const bool mapping_capable = filament_mapping_advertised(params.dev_id); + if (!mapping_capable || !ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED) { + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: refusing mapped print (capable=" << mapping_capable + << ", correlation_verified=" << ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED << ")"; + return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED; + } + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: start_sdcard_print emitting filament_mapping entries=" << filament_mapping.size(); + } + + nlohmann::json j = build_gcode_file_payload(next_gcode_file_sequence_id(), target, filament_mapping); if (update_fn) update_fn(PrintingStageSending, 0, "Starting print..."); diff --git a/src/slic3r/Utils/OrcaPrinterAgent.hpp b/src/slic3r/Utils/OrcaPrinterAgent.hpp index d3ec5af1fe..808b2b0aea 100644 --- a/src/slic3r/Utils/OrcaPrinterAgent.hpp +++ b/src/slic3r/Utils/OrcaPrinterAgent.hpp @@ -64,6 +64,8 @@ public: // Print Job Operations int start_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) override; + // Unimplemented on OrcaSonar: reports a non-success result so callers fall back to + // start_print() instead of treating the missing send as success. int start_local_print_with_record(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) override; int start_send_gcode_to_sdcard(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) override; int start_local_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn) override; @@ -140,6 +142,18 @@ protected: // Pure LAN-address parsing + client-id. protected static so the test Probe reaches them. static bool parse_lan_endpoint(const std::string& dev_ip, std::string& host, std::string& port); static std::string make_lan_client_id(const std::string& dev_id); + + // Pure serializer for PrintParams::ams_mapping2 -> print.gcode_file.filament_mapping. + // Entries are re-keyed by their array position; {255,255} is dropped. Empty when the + // input is empty, malformed, or has no usable entries. protected static for the test Probe. + static nlohmann::json build_filament_mapping(const std::string& ams_mapping2); + + // Pure builder for the print.gcode_file command payload. A non-empty mapping is + // included as filament_mapping; an empty one is omitted so the payload is + // byte-identical to an unmapped print. protected static for the test Probe. + static nlohmann::json build_gcode_file_payload(const std::string& sequence_id, + const std::string& target, + const nlohmann::json& filament_mapping); // Test hook: the ws:// URL connect_printer built for the current LAN session ("" if none). std::string lan_connection_target() const; // Shared post-connect sequence: SUBSCRIBE, then pushing.start, pushall, diff --git a/tests/slic3rutils/test_device_manager_integration.cpp b/tests/slic3rutils/test_device_manager_integration.cpp index 2b042d4f8e..4728418c88 100644 --- a/tests/slic3rutils/test_device_manager_integration.cpp +++ b/tests/slic3rutils/test_device_manager_integration.cpp @@ -157,3 +157,119 @@ TEST_CASE("Device manager filters and rehomes devices by printer-agent ownership CHECK(manager.get_my_machine_list("integration-agent-a").empty()); CHECK(manager.get_my_machine_list("integration-agent-b").count(machine.dev_id) == 1); } + +TEST_CASE("Orca capability reply populates connector-scope features and commands", "[DeviceManager][integration]") +{ + ScopedAppConfig app_config; + NetworkAgent network(nullptr, std::make_shared("orca")); + DeviceManager manager(&network, false, &app_config.config); + + BBLocalMachine orca_machine; + orca_machine.dev_id = "orca-device"; + orca_machine.dev_name = "Orca device"; + orca_machine.dev_ip = "192.0.2.21"; + orca_machine.printer_type = "C11"; + MachineObject* orca_obj = manager.insert_local_device(orca_machine, "lan", "free", "", "access-code"); + REQUIRE(orca_obj != nullptr); + orca_obj->printer_agent_id = "orca"; + + orca_obj->parse_new_info2(json::parse(R"({ + "command": "get_capabilities", + "supported_features": {"fms": true, "filament_slots": true, "filament_mapping": true}, + "supported_commands": ["print.push_status", "print.ams_get_rfid"], + "capabilities": { + "flags": {}, + "protocol": { + "features": {"filament_mapping": true}, + "supported_commands": ["print.ams_change_filament"] + } + } + })")); + CHECK(orca_obj->is_support_fms); + CHECK(orca_obj->is_support_filament_slots); + CHECK(orca_obj->is_support_filament_mapping); + CHECK(orca_obj->supported_commands.count("print.push_status") == 1); + CHECK(orca_obj->supported_commands.count("print.ams_get_rfid") == 1); + CHECK(orca_obj->supported_commands.count("print.ams_change_filament") == 1); + + // Absent or false reads as unsupported, and a later reply without commands clears the set. + orca_obj->parse_new_info2(json::parse(R"({ + "command": "get_capabilities", + "supported_features": {"fms": false, "filament_slots": false, "filament_mapping": false}, + "capabilities": {"flags": {}} + })")); + CHECK_FALSE(orca_obj->is_support_fms); + CHECK_FALSE(orca_obj->is_support_filament_slots); + CHECK_FALSE(orca_obj->is_support_filament_mapping); + CHECK(orca_obj->supported_commands.empty()); + + // A reply without capabilities.flags must still parse the Orca features and + // commands (fail-closed: don't retain stale "supported" values). + orca_obj->parse_new_info2(json::parse(R"({ + "command": "get_capabilities", + "supported_features": {"filament_mapping": true}, + "capabilities": { + "protocol": {"supported_commands": ["print.ams_get_rfid"]} + } + })")); + CHECK(orca_obj->is_support_filament_mapping); + CHECK_FALSE(orca_obj->is_support_filament_slots); + CHECK(orca_obj->supported_commands.count("print.ams_get_rfid") == 1); + + // A non-Orca agent id leaves the connector-scope fields untouched. + BBLocalMachine bbl_machine; + bbl_machine.dev_id = "bbl-device"; + bbl_machine.dev_name = "Bambu device"; + bbl_machine.dev_ip = "192.0.2.22"; + bbl_machine.printer_type = "C11"; + MachineObject* bbl_obj = manager.insert_local_device(bbl_machine, "lan", "free", "", "access-code"); + REQUIRE(bbl_obj != nullptr); + bbl_obj->printer_agent_id = "bbl"; + + bbl_obj->parse_new_info2(json::parse(R"({ + "command": "get_capabilities", + "supported_features": {"fms": true, "filament_slots": true, "filament_mapping": true}, + "supported_commands": ["print.push_status"], + "capabilities": { + "flags": {}, + "protocol": { + "features": {"fms": true, "filament_slots": true, "filament_mapping": true}, + "supported_commands": ["print.ams_get_rfid"] + } + } + })")); + CHECK_FALSE(bbl_obj->is_support_fms); + CHECK_FALSE(bbl_obj->is_support_filament_slots); + CHECK_FALSE(bbl_obj->is_support_filament_mapping); + CHECK(bbl_obj->supported_commands.empty()); +} + +TEST_CASE("Orca per-command AMS gate requires fms and the advertised command", "[DeviceManager][integration]") +{ + ScopedAppConfig app_config; + NetworkAgent network(nullptr, std::make_shared("orca")); + DeviceManager manager(&network, false, &app_config.config); + + BBLocalMachine machine; + machine.dev_id = "orca-gate"; + machine.dev_name = "Orca gate"; + machine.dev_ip = "192.0.2.30"; + machine.printer_type = "C11"; + MachineObject* obj = manager.insert_local_device(machine, "lan", "free", "", "access-code"); + REQUIRE(obj != nullptr); + obj->printer_agent_id = "orca"; + + // fms off: no macro-backed AMS command is allowed even if listed. + obj->is_support_fms = false; + obj->supported_commands.insert("print.ams_control"); + CHECK_FALSE(obj->orca_ams_command_supported("print.ams_control")); + + // fms on: only the commands actually advertised are allowed. + obj->is_support_fms = true; + CHECK(obj->orca_ams_command_supported("print.ams_control")); + CHECK_FALSE(obj->orca_ams_command_supported("print.ams_get_rfid")); + + // Bambu keeps the legacy permissive path. + obj->printer_agent_id = "bbl"; + CHECK(obj->orca_ams_command_supported("print.anything")); +} diff --git a/tests/slic3rutils/test_orca_printer_agent.cpp b/tests/slic3rutils/test_orca_printer_agent.cpp index 1ff111a879..5e2a73f7ad 100644 --- a/tests/slic3rutils/test_orca_printer_agent.cpp +++ b/tests/slic3rutils/test_orca_printer_agent.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -22,6 +23,8 @@ struct Probe : OrcaPrinterAgent { using OrcaPrinterAgent::parse_lan_endpoint; using OrcaPrinterAgent::make_lan_client_id; using OrcaPrinterAgent::lan_connection_target; + using OrcaPrinterAgent::build_filament_mapping; + using OrcaPrinterAgent::build_gcode_file_payload; }; } @@ -84,6 +87,101 @@ TEST_CASE("OrcaPrinterAgent::make_lan_client_id is stable and prefixed", "[OrcaP CHECK(a.rfind("orcaslicer-lan-dev-1-", 0) == 0); } +TEST_CASE("filament mapping is keyed by the ams_mapping2 array position", "[OrcaPrinterAgent]") { + const nlohmann::json mapping = Probe::build_filament_mapping( + R"([{"ams_id":1,"slot_id":5},{"ams_id":255,"slot_id":255},{"ams_id":255,"slot_id":0}])"); + REQUIRE(mapping.is_array()); + REQUIRE(mapping.size() == 2); + CHECK(mapping[0]["filament_index"] == 0); + CHECK(mapping[0]["ams_id"] == 1); + CHECK(mapping[0]["slot_id"] == 5); + // The unmatched middle entry is dropped; the third entry keeps index 2. + CHECK(mapping[1]["filament_index"] == 2); + CHECK(mapping[1]["ams_id"] == 255); + CHECK(mapping[1]["slot_id"] == 0); +} + +// Index-correlation merge gate (plan PR 3). `ams_mapping2` is built one entry +// per logical filament, so its array position is the identifier the generated +// G-code toolchange passes to the Klipper macro (`next_filament_id`). The +// serializer must key `filament_index` by that position and must never +// re-densify after dropping unused/sentinel entries, or a used filament would +// be aimed at the wrong lane. This test covers the plan's matrix: preset order +// differing from used order, a middle filament unused, and external slots. +TEST_CASE("filament mapping index correlates with the ams_mapping2 position", "[OrcaPrinterAgent]") { + // Positions 0..4. Used filaments are 0, 2 and 4; 1 and 3 are unused. + const nlohmann::json mapping = Probe::build_filament_mapping( + R"([{"ams_id":0,"slot_id":1},{"ams_id":255,"slot_id":255},{"ams_id":2,"slot_id":3},{"ams_id":255,"slot_id":255},{"ams_id":255,"slot_id":0}])"); + REQUIRE(mapping.size() == 3); + CHECK(mapping[0]["filament_index"] == 0); + CHECK(mapping[1]["filament_index"] == 2); + CHECK(mapping[2]["filament_index"] == 4); // external slot keeps its position + CHECK(mapping[2]["ams_id"] == 255); + CHECK(mapping[2]["slot_id"] == 0); + // No re-densification: a used filament after a dropped sentinel keeps its + // original logical index. + for (const auto& entry : mapping) + CHECK(entry.contains("filament_index")); +} + +TEST_CASE("filament mapping is omitted when nothing remains", "[OrcaPrinterAgent]") { + CHECK(Probe::build_filament_mapping("").empty()); + CHECK(Probe::build_filament_mapping(R"([{"ams_id":255,"slot_id":255}])").empty()); + CHECK(Probe::build_filament_mapping("not json").empty()); + CHECK(Probe::build_filament_mapping(R"({"ams_id":1,"slot_id":0})").empty()); // not an array +} + +// The GUI capability gate and the agent serializer must classify the same +// entries as engaged. External slots ({255,0}/{254,0}) are normalized as-is, so +// they engage; only the {255,255} unmatched sentinel is dropped. A mismatch lets +// an entry past the GUI and refused late with a generic publish error. +TEST_CASE("the GUI mapping gate engages exactly the entries the serializer sends", "[OrcaPrinterAgent]") { + using Slic3r::GUI::has_engaged_filament_mapping; + CHECK_FALSE(has_engaged_filament_mapping("")); + CHECK_FALSE(has_engaged_filament_mapping("[]")); + CHECK_FALSE(has_engaged_filament_mapping("not json")); + CHECK_FALSE(has_engaged_filament_mapping(R"([{"ams_id":255,"slot_id":255}])")); + CHECK(has_engaged_filament_mapping(R"([{"ams_id":255,"slot_id":0}])")); // external main + CHECK(has_engaged_filament_mapping(R"([{"ams_id":254,"slot_id":0}])")); // external deputy + CHECK(has_engaged_filament_mapping(R"([{"ams_id":0,"slot_id":0}])")); // box slot + CHECK(has_engaged_filament_mapping(R"([{"ams_id":255,"slot_id":255},{"ams_id":1,"slot_id":2}])")); + + for (const char* s : {"", "[]", "not json", R"([{"ams_id":255,"slot_id":255}])", + R"([{"ams_id":255,"slot_id":0}])", R"([{"ams_id":254,"slot_id":0}])", + R"([{"ams_id":0,"slot_id":0}])", + R"([{"ams_id":255,"slot_id":255},{"ams_id":1,"slot_id":2}])"}) { + CHECK(has_engaged_filament_mapping(s) == !Probe::build_filament_mapping(s).empty()); + } +} + +// An empty mapping must leave the gcode_file payload byte-identical to today: +// exactly command, sequence_id and param, with no filament_mapping key. +TEST_CASE("gcode_file payload omits filament_mapping when the map is empty", "[OrcaPrinterAgent]") { + const nlohmann::json empty = Probe::build_gcode_file_payload("7", "job.gcode", nlohmann::json::array()); + REQUIRE(empty.contains("print")); + CHECK(empty["print"].size() == 3); + CHECK(empty["print"]["command"] == "gcode_file"); + CHECK(empty["print"]["sequence_id"] == "7"); + CHECK(empty["print"]["param"] == "job.gcode"); + CHECK_FALSE(empty["print"].contains("filament_mapping")); + + const nlohmann::json mapping = Probe::build_filament_mapping(R"([{"ams_id":1,"slot_id":0},{"ams_id":255,"slot_id":255}])"); + const nlohmann::json with = Probe::build_gcode_file_payload("8", "job.gcode", mapping); + REQUIRE(with["print"].contains("filament_mapping")); + REQUIRE(with["print"]["filament_mapping"].size() == 1); + CHECK(with["print"]["filament_mapping"][0]["filament_index"] == 0); +} + +// The FTP "send with record" transport does not exist on OrcaSonar. It must +// report a non-success result so PrintJob falls back to start_print() rather +// than treating a print that was never sent as successful. +TEST_CASE("start_local_print_with_record never reports silent success", "[OrcaPrinterAgent]") { + Probe agent("/tmp"); + Slic3r::PrintParams params; + const int rc = agent.start_local_print_with_record(params, {}, {}, {}); + CHECK(rc < 0); +} + TEST_CASE("connect_printer wires up a LAN Config", "[OrcaPrinterAgent][.integration]") { Probe agent("/tmp"); Slic3r::PrinterConnectionParams params{