From d3003dfa5e18251cc16f4f3ef7278978defa88f1 Mon Sep 17 00:00:00 2001 From: Lam Wei Lun Date: Thu, 24 Sep 2026 23:49:30 +0800 Subject: [PATCH] fix(orca-agent): retain virtual trays and preserve external AMS ids External spool ids 254/255 keep their identity through filament changes and RFID refresh Orca virtual trays persist across push_status snapshots and reset on reconnect AMS writes gate on the device's declared FMS and filament-slot capabilities --- src/slic3r/GUI/DeviceManager.cpp | 198 ++++++++++++------ src/slic3r/GUI/DeviceManager.hpp | 3 + src/slic3r/GUI/GUI_App.cpp | 2 + src/slic3r/GUI/StatusPanel.cpp | 3 +- src/slic3r/Utils/AmsPayload.hpp | 13 +- src/slic3r/Utils/BBLPrinterAgent.cpp | 21 +- src/slic3r/Utils/BBLPrinterAgent.hpp | 3 + src/slic3r/Utils/OrcaPrinterAgent.cpp | 130 +++++++----- src/slic3r/Utils/OrcaPrinterAgent.hpp | 3 + tests/slic3rutils/test_device_manager.cpp | 68 +++++- tests/slic3rutils/test_orca_printer_agent.cpp | 81 +++++-- tests/slic3rutils/test_printer_agent.cpp | 2 +- 12 files changed, 372 insertions(+), 155 deletions(-) diff --git a/src/slic3r/GUI/DeviceManager.cpp b/src/slic3r/GUI/DeviceManager.cpp index b2f3ba8ba7..3d0cac2f67 100644 --- a/src/slic3r/GUI/DeviceManager.cpp +++ b/src/slic3r/GUI/DeviceManager.cpp @@ -546,6 +546,21 @@ bool MachineObject::is_bbl_agent() const return printer_agent_id == BBL_PRINTER_AGENT_ID || printer_agent_id.empty(); } +bool MachineObject::is_orca_agent() const +{ + return printer_agent_id == ORCA_PRINTER_AGENT_ID; +} + +void MachineObject::reset_orca_virtual_trays_for_reconnect() +{ + if (!is_orca_agent()) + return; + m_orca_seen_virtual_trays.clear(); + vt_slot.clear(); + vt_slot.emplace_back(std::to_string(VIRTUAL_TRAY_MAIN_ID)); + ams_support_virtual_tray = true; +} + bool MachineObject::ams_filament_ack_failed(const nlohmann::json& jj, std::string& reason) { reason.clear(); @@ -1700,8 +1715,8 @@ int MachineObject::command_ams_change_filament(bool load, std::string ams_id, st if (atoi(ams_id.c_str()) < 16) { tray_id = atoi(ams_id.c_str()) * 4 + atoi(slot_id.c_str()); } - // TODO: Orca hack - if (ams_id == "254") + // Preserve the legacy alias outside Orca; OrcaSonar uses both ids for tool identity. + if (ams_id == "254" && !is_orca_agent()) ams_id = "255"; j["print"]["command"] = "ams_change_filament"; @@ -2588,11 +2603,11 @@ void MachineObject::reset() json empty_j; print_json.diff2all_base_reset(empty_j); - // 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. + // Restore the constructor seed and clear Orca's per-connection retained IDs. vt_slot.clear(); vt_slot.push_back(DevAmsTray(std::to_string(VIRTUAL_TRAY_MAIN_ID))); + m_orca_seen_virtual_trays.clear(); + ams_support_virtual_tray = true; // why: reset reuses MachineObject, so release its lazy subtask // before dropping the pointer to prevent reconnect leaks. if (subtask_) { @@ -2898,9 +2913,8 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_ try { bool restored_json = false; - // A frame is authoritative for removals only when it is a full snapshot - // (msg=0, or a LAN frame with no msg). Delta frames (msg=1) merge into - // the stored state and MUST NOT remove entries they omit (spec §7.3). + // msg=0 (or a LAN frame without msg) is parsed as a full snapshot; + // removal behavior remains field- and agent-specific. bool full_snapshot = true; json j; if (!parse_ok) @@ -3313,23 +3327,35 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_ m_nozzle_mapping_ptr->ParseAutoNozzleMapping(jj); if (jj["command"].get() == "ams_change_filament") { - if (jj.contains("errno")) { - if (jj["errno"].is_number()) { - if (jj.contains("soft_temp")) { - int soft_temp = jj["soft_temp"].get(); - if (jj["errno"].get() == -2) { - wxString text = wxString::Format(_L("The chamber temperature is too high, which may cause the filament to soften. Please wait until the chamber temperature drops below %d\u2103. You may open the front door or enable fans to cool down."), soft_temp); - GUI::wxGetApp().push_notification(this, text); - } else if (jj["errno"].get() == -4) { - wxString text = wxString::Format(_L("AMS temperature is too high, which may cause the filament to soften. Please wait until the AMS temperature drops below %d\u2103."), soft_temp); - GUI::wxGetApp().push_notification(this, text); - } - } else { - if (jj["errno"].get() == -2) { - wxString text = _L("The current chamber temperature or the target chamber temperature exceeds 45\u2103. In order to avoid extruder clogging, low temperature filament(PLA/PETG/TPU) is not allowed to be loaded."); - GUI::wxGetApp().push_notification(this, text); - } - } + const int ack_errno = jj.contains("errno") && jj["errno"].is_number() + ? jj["errno"].get() : 0; + bool temperature_failure_notified = false; + if (jj.contains("soft_temp") && jj["soft_temp"].is_number()) { + const int soft_temp = jj["soft_temp"].get(); + if (ack_errno == -2) { + wxString text = wxString::Format(_L("The chamber temperature is too high, which may cause the filament to soften. Please wait until the chamber temperature drops below %d\u2103. You may open the front door or enable fans to cool down."), soft_temp); + GUI::wxGetApp().push_notification(this, text); + temperature_failure_notified = true; + } else if (ack_errno == -4) { + wxString text = wxString::Format(_L("AMS temperature is too high, which may cause the filament to soften. Please wait until the AMS temperature drops below %d\u2103."), soft_temp); + GUI::wxGetApp().push_notification(this, text); + temperature_failure_notified = true; + } + } else if (ack_errno == -2) { + wxString text = _L("The current chamber temperature or the target chamber temperature exceeds 45\u2103. In order to avoid extruder clogging, low temperature filament(PLA/PETG/TPU) is not allowed to be loaded."); + GUI::wxGetApp().push_notification(this, text); + temperature_failure_notified = true; + } + + if (is_orca_agent() && !temperature_failure_notified) { + std::string ack_reason; + bool ack_failed = ams_filament_ack_failed(jj, ack_reason); + ack_failed = ack_failed || ack_errno != 0; + if (ack_failed) { + wxString text = _L("Failed to change filament"); + if (!ack_reason.empty()) + text += wxString::FromUTF8(": ") + wxString::FromUTF8(ack_reason); + GUI::wxGetApp().push_notification(this, text); } } } @@ -4069,60 +4095,99 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_ DevFilaSystemParser::ParseV1_0(jj, this, m_fila_system.get(), key_field_only); } - /* vitrual tray*/ + /* virtual tray */ if (!key_field_only) { try { - if (jj.contains("vir_slot") && jj["vir_slot"].is_array()) { + if (is_orca_agent()) { + auto clear_unseen_seed = [&]() { + if (m_orca_seen_virtual_trays.empty()) { + vt_slot.clear(); + ams_support_virtual_tray = false; + } else { + ams_support_virtual_tray = true; + } + }; + auto merge_orca_tray = [&](DevAmsTray vslot) { + if (vslot.id != std::to_string(VIRTUAL_TRAY_MAIN_ID) + && vslot.id != std::to_string(VIRTUAL_TRAY_DEPUTY_ID)) + return; + m_orca_seen_virtual_trays.insert(vslot.id); + auto held = std::find_if(vt_slot.begin(), vt_slot.end(), [&](const DevAmsTray& tray) { + return tray.id == vslot.id; + }); + if (held != vt_slot.end()) { + *held = std::move(vslot); + } else if (vslot.id == std::to_string(VIRTUAL_TRAY_MAIN_ID)) { + vt_slot.insert(vt_slot.begin(), std::move(vslot)); + } else { + auto main = std::find_if(vt_slot.begin(), vt_slot.end(), [](const DevAmsTray& tray) { + return tray.id == std::to_string(VIRTUAL_TRAY_MAIN_ID); + }); + if (main == vt_slot.end()) { + vt_slot.insert(vt_slot.begin(), DevAmsTray(std::to_string(VIRTUAL_TRAY_MAIN_ID))); + main = vt_slot.begin(); + } + auto deputy = std::find_if(vt_slot.begin(), vt_slot.end(), [](const DevAmsTray& tray) { + return tray.id == std::to_string(VIRTUAL_TRAY_DEPUTY_ID); + }); + if (deputy == vt_slot.end()) + vt_slot.insert(std::next(main), std::move(vslot)); + } + ams_support_virtual_tray = true; + }; + + if (jj.contains("vir_slot") && jj["vir_slot"].is_array()) { + if (jj["vir_slot"].empty()) { + if (full_snapshot) + clear_unseen_seed(); + } else { + for (const auto& entry : jj["vir_slot"]) { + if (!entry.is_object() || !entry.contains("id") || !entry["id"].is_string()) + continue; + const std::string id = entry["id"].get(); + if (id == std::to_string(VIRTUAL_TRAY_MAIN_ID) + || id == std::to_string(VIRTUAL_TRAY_DEPUTY_ID)) + merge_orca_tray(parse_vt_tray(entry.get())); + } + if (full_snapshot) + clear_unseen_seed(); + } + } else if (jj.contains("vt_tray")) { + auto main_slot = parse_vt_tray(jj["vt_tray"].get()); + main_slot.id = std::to_string(VIRTUAL_TRAY_MAIN_ID); + merge_orca_tray(std::move(main_slot)); + } else if (full_snapshot) { + clear_unseen_seed(); + } + } else if (jj.contains("vir_slot") && jj["vir_slot"].is_array()) { if (jj["vir_slot"].empty()) { - // Only a full snapshot can authoritatively clear the layout. if (full_snapshot) { 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. + } else { ams_support_virtual_tray = true; if (full_snapshot) { - // A full snapshot is authoritative: rebuild - // from it so an id absent from the list is - // removed, not left stale (spec §7.3). std::vector fresh; - for (auto it = jj["vir_slot"].begin(); it != jj["vir_slot"].end(); it++) { - auto vslot = parse_vt_tray(it.value().get()); - + for (const auto& entry : jj["vir_slot"]) { + auto vslot = parse_vt_tray(entry.get()); if (vslot.id == std::to_string(VIRTUAL_TRAY_MAIN_ID)) { - if (fresh.empty()) { + if (fresh.empty()) fresh.push_back(vslot); - } - else { + else fresh[0] = vslot; - } - } - else if (vslot.id == std::to_string(VIRTUAL_TRAY_DEPUTY_ID)) { - // vt_slot[1] is the deputy. Only the main - // branch creates index 0, so an orphan - // deputy (no main) is dropped, not indexed. - if (!fresh.empty()) { - if (fresh.size() > 1) { - fresh[1] = vslot; - } - else { - fresh.push_back(vslot); - } - } + } else if (vslot.id == std::to_string(VIRTUAL_TRAY_DEPUTY_ID) && !fresh.empty()) { + if (fresh.size() > 1) + fresh[1] = vslot; + else + fresh.push_back(vslot); } } vt_slot = std::move(fresh); - } - else { - // A delta only updates the entries it names; - // an omitted entry stays held (spec §7.3). - for (auto it = jj["vir_slot"].begin(); it != jj["vir_slot"].end(); it++) { - auto vslot = parse_vt_tray(it.value().get()); + } else { + for (const auto& entry : jj["vir_slot"]) { + auto vslot = parse_vt_tray(entry.get()); for (auto& held : vt_slot) { if (held.id == vslot.id) { held = vslot; @@ -4132,9 +4197,7 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_ } } } - - } - else if (jj.contains("vt_tray")) { + } 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); @@ -4148,9 +4211,8 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_ vt_slot.push_back(main_slot); } } - // 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. + // For Bambu and legacy agents, missing keys do not change + // the current tray state. } catch (...) { ; diff --git a/src/slic3r/GUI/DeviceManager.hpp b/src/slic3r/GUI/DeviceManager.hpp index 4a50f84c0d..5c3fa41b36 100644 --- a/src/slic3r/GUI/DeviceManager.hpp +++ b/src/slic3r/GUI/DeviceManager.hpp @@ -113,6 +113,7 @@ private: std::string dev_name; std::string dev_ip; std::string access_code; + std::unordered_set m_orca_seen_virtual_trays; // type, time stamp, delay std::vector> message_delay; @@ -164,6 +165,8 @@ public: // 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; + bool is_orca_agent() const; + void reset_orca_virtual_trays_for_reconnect(); // Orca: an OPCP failure ack (result != "success"). Fills reason when present. static bool ams_filament_ack_failed(const nlohmann::json& jj, std::string& reason); diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 79afe4f0a2..9f7b985f12 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -2173,6 +2173,7 @@ void GUI_App::init_networking_callbacks() /* request_pushing */ MachineObject* obj = m_device_manager->get_my_machine(tunnel ? dev_id.substr(7) : dev_id); if (obj) { + obj->reset_orca_virtual_trays_for_reconnect(); obj->is_tunnel_mqtt = tunnel; obj->command_request_push_all(true); obj->command_get_version(); @@ -2215,6 +2216,7 @@ void GUI_App::init_networking_callbacks() if (obj->is_lan_mode_printer()) { if (state == ConnectStatus::ConnectStatusOk) { + obj->reset_orca_virtual_trays_for_reconnect(); obj->command_request_push_all(true); obj->command_get_version(); event.SetInt(0); diff --git a/src/slic3r/GUI/StatusPanel.cpp b/src/slic3r/GUI/StatusPanel.cpp index 5c82f31aee..8a278ec311 100644 --- a/src/slic3r/GUI/StatusPanel.cpp +++ b/src/slic3r/GUI/StatusPanel.cpp @@ -4373,7 +4373,8 @@ void StatusPanel::on_ams_load_curr() } } catch (...) {} } else { - obj->command_ams_change_filament(true, "254", "0", old_temp, new_temp, extruder_id); + const std::string external_ams_id = obj->is_orca_agent() ? curr_ams_id : "254"; + obj->command_ams_change_filament(true, external_ams_id, "0", old_temp, new_temp, extruder_id); } } diff --git a/src/slic3r/Utils/AmsPayload.hpp b/src/slic3r/Utils/AmsPayload.hpp index a6c9f6f56b..d5678aed58 100644 --- a/src/slic3r/Utils/AmsPayload.hpp +++ b/src/slic3r/Utils/AmsPayload.hpp @@ -90,11 +90,9 @@ void build_ams_payload_for_device(const std::string& dev_id, const QueueOnMainFn& queue_fn, const TrayInfoResolver& vendor_resolver = {}); -// 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 -// applies to OrcaSonar printers that answered. An answer without ams_ops -// registers an empty op set, so it gates every write. +// Process-wide AMS capabilities parsed from info.get_capabilities. Before a +// reply, writes are permissive; afterward material operations require fms and +// their ams_ops token, while filament_setting uses filament_slots alone. void register_ams_ops(const std::string& dev_id, const std::vector& ops); bool ams_op_supported(const std::string& dev_id, const std::string& op); @@ -104,9 +102,8 @@ 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 has answered get_capabilities at all (any reply, even one -// declaring no material system). Lets a client re-request capabilities only -// while the topology is still unconfirmed, instead of on every filament frame. +// Whether a valid protocol capability reply has been registered. Also separates +// the permissive pre-reply write behavior from an explicit no-FMS declaration. bool ams_caps_known(const std::string& dev_id); // Whether the device exposes the filament-slot model, from the diff --git a/src/slic3r/Utils/BBLPrinterAgent.cpp b/src/slic3r/Utils/BBLPrinterAgent.cpp index 4903b3b035..6494f04550 100644 --- a/src/slic3r/Utils/BBLPrinterAgent.cpp +++ b/src/slic3r/Utils/BBLPrinterAgent.cpp @@ -150,11 +150,26 @@ void BBLPrinterAgent::set_cloud_agent(std::shared_ptr cloud) // Communication // ============================================================================ +std::string BBLPrinterAgent::ams_refresh_rfid_gcode(const std::string& slot_id) +{ + return (boost::format("M620 R%1% \n") % slot_id).str(); +} + +std::string BBLPrinterAgent::ams_calibrate_gcode(int ams_id) +{ + return (boost::format("M620 C%1% \n") % ams_id).str(); +} + +std::string BBLPrinterAgent::ams_select_tray_gcode(const std::string& tray_id) +{ + return (boost::format("M620 P%1% \n") % tray_id).str(); +} + int BBLPrinterAgent::command_ams_refresh_rfid(std::string dev_id, int ams_id, int slot_id, int sequence_id, bool lan_mode) { nlohmann::json j; if (ams_id == -1) { - const std::string gcode = (boost::format("M620 R%1% \n") % slot_id).str(); + const std::string gcode = ams_refresh_rfid_gcode(std::to_string(slot_id)); j["print"]["command"] = "gcode_line"; j["print"]["param"] = gcode; j["print"]["sequence_id"] = std::to_string(sequence_id); @@ -170,7 +185,7 @@ int BBLPrinterAgent::command_ams_refresh_rfid(std::string dev_id, int ams_id, in int BBLPrinterAgent::command_ams_calibrate(std::string dev_id, int ams_id, int sequence_id, bool lan_mode) { - const std::string gcode = (boost::format("M620 C%1% \n") % ams_id).str(); + const std::string gcode = ams_calibrate_gcode(ams_id); nlohmann::json j; j["print"]["command"] = "gcode_line"; j["print"]["param"] = gcode; @@ -180,7 +195,7 @@ int BBLPrinterAgent::command_ams_calibrate(std::string dev_id, int ams_id, int s int BBLPrinterAgent::command_ams_select_tray(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode) { - const std::string gcode = (boost::format("M620 P%1% \n") % tray_id).str(); + const std::string gcode = ams_select_tray_gcode(tray_id); nlohmann::json j; j["print"]["command"] = "gcode_line"; j["print"]["param"] = gcode; diff --git a/src/slic3r/Utils/BBLPrinterAgent.hpp b/src/slic3r/Utils/BBLPrinterAgent.hpp index 96001803a9..7e0fd83a86 100644 --- a/src/slic3r/Utils/BBLPrinterAgent.hpp +++ b/src/slic3r/Utils/BBLPrinterAgent.hpp @@ -32,6 +32,9 @@ public: int command_ams_refresh_rfid(std::string dev_id, int ams_id, int slot_id, int sequence_id, bool lan_mode) override; int command_ams_calibrate(std::string dev_id, int ams_id, int sequence_id, bool lan_mode) override; int command_ams_select_tray(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode) override; + static std::string ams_refresh_rfid_gcode(const std::string& slot_id); + static std::string ams_calibrate_gcode(int ams_id); + static std::string ams_select_tray_gcode(const std::string& tray_id); int command_axis_control(std::string dev_id, std::string axis, double unit, double input_val, int speed, bool is_core_xy, bool supports_mqtt_axis_control, int sequence_id, bool lan_mode) override; std::string default_lan_username() const override { return "bblp"; } diff --git a/src/slic3r/Utils/OrcaPrinterAgent.cpp b/src/slic3r/Utils/OrcaPrinterAgent.cpp index 5fd642a92e..b7816d55e8 100644 --- a/src/slic3r/Utils/OrcaPrinterAgent.cpp +++ b/src/slic3r/Utils/OrcaPrinterAgent.cpp @@ -41,6 +41,8 @@ const std::string OrcaPrinterAgent_VERSION = "0.0.1"; namespace { namespace fs = boost::filesystem; +constexpr int kVirtualTrayDeputyId = 254; +constexpr int kVirtualTrayMainId = 255; // 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, @@ -779,18 +781,7 @@ int OrcaPrinterAgent::send_message(std::string dev_id, std::string json_str, int int OrcaPrinterAgent::command_ams_refresh_rfid(std::string dev_id, int ams_id, int tray_id, int sequence_id, bool lan_mode) { - (void) ams_id; - // int tray_number = 0; - // if (!parse_nonnegative_command_id(tray_id, tray_number)) { - // BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: invalid RFID tray id=" << tray_id; - // return BAMBU_NETWORK_ERR_INVALID_HANDLE; - // } - - nlohmann::json j; - j["print"]["command"] = "ams_get_rfid"; - j["print"]["sequence_id"] = std::to_string(sequence_id); - j["print"]["tray_id"] = tray_id; - return route_send(lan_mode, dev_id, j.dump()); + return route_send(lan_mode, dev_id, build_ams_refresh_rfid_body(ams_id, tray_id, sequence_id)); } int OrcaPrinterAgent::command_ams_calibrate(std::string /*dev_id*/, int /*ams_id*/, int /*sequence_id*/, bool /*lan_mode*/) @@ -808,8 +799,26 @@ std::string OrcaPrinterAgent::build_ams_change_filament_body(int tray_number, in // 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"]["ams_id"] = tray_number / 4; - j["print"]["slot_id"] = tray_number % 4; + if (tray_number == kVirtualTrayMainId || tray_number == kVirtualTrayDeputyId) { + j["print"]["ams_id"] = tray_number; + j["print"]["slot_id"] = 0; + } else { + j["print"]["ams_id"] = tray_number / 4; + j["print"]["slot_id"] = tray_number % 4; + } + return j.dump(); +} + +std::string OrcaPrinterAgent::build_ams_refresh_rfid_body(int ams_id, int tray_or_slot_id, int sequence_id) +{ + const int tray_id = ams_id == -1 + ? tray_or_slot_id + : (ams_id >= 240 && ams_id <= 255 ? ams_id : ams_id * 4 + tray_or_slot_id); + + nlohmann::json j; + j["print"]["command"] = "ams_get_rfid"; + j["print"]["sequence_id"] = std::to_string(sequence_id); + j["print"]["tray_id"] = tray_id; return j.dump(); } @@ -1274,14 +1283,9 @@ int OrcaPrinterAgent::disconnect_printer() int OrcaPrinterAgent::send_message_to_printer(std::string dev_id, std::string json_str, int /*qos*/, int /*flag*/) { return route_send(/*is_lan=*/true, dev_id, json_str); } -// Rewrite Bambu-convention print.ams_* payloads onto the canonical OrcaSonar -// bodies (OPCP spec §7.8) and enforce the device's declared ams_ops. DevFilaSystem -// is built from flat lane indices, so the Bambu encodings the shared -// MachineObject command builders emit — ams_id/slot_id 4-tray pseudo-groups, -// virtual tray ids 254/255 — are decoded here, at the single funnel all print -// commands cross, and never reach the server. A command whose op token the -// device did not declare short-circuits as *unsupported (CAP_NOT_AVAILABLE), -// which publish_json already renders as the friendly unsupported dialog. +// Rewrite recognized Bambu-coordinate requests to the canonical OrcaSonar +// bodies (OPCP spec §7.8) and enforce the device's declared capabilities. +// Explicit selectors and legacy target requests retain their wire semantics. std::string OrcaPrinterAgent::canonicalize_ams_payload(const std::string& dev_id, const std::string& json_str, bool* unsupported) { if (unsupported) @@ -1299,18 +1303,34 @@ std::string OrcaPrinterAgent::canonicalize_ams_payload(const std::string& dev_id // 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 (o == "filament_setting") + return !ams_caps_known(dev_id) || has_filament_slots(dev_id); + return o.empty() || ams_op_supported(dev_id, o); }; + const bool fms_allowed = !ams_caps_known(dev_id) || has_ams_capability(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()); - // A lane can resolve to an external slot server-side (§7.8), so - // either write op admits it; other selectors gate on their own token. - const bool allowed = (sel == "lane") - ? (op_allowed("change_filament") || op_allowed("external")) + bool lane_has_coordinates = false; + bool coordinate_is_external = false; + if (sel == "lane") { + const auto ams_it = print.find("ams_id"); + const auto slot_it = print.find("slot_id"); + lane_has_coordinates = ams_it != print.end() && ams_it->is_number_integer() + && slot_it != print.end() && slot_it->is_number_integer(); + if (lane_has_coordinates) { + const int ams = ams_it->get(); + coordinate_is_external = ams >= 240 && ams <= 255; + } + } + const bool selector_op_allowed = sel == "lane" + ? (lane_has_coordinates + ? op_allowed(coordinate_is_external ? "external" : "change_filament") + : (op_allowed("change_filament") || op_allowed("external"))) : op_allowed(sel); + const bool allowed = fms_allowed && selector_op_allowed; if (!allowed && unsupported) *unsupported = true; return json_str; @@ -1325,32 +1345,27 @@ std::string OrcaPrinterAgent::canonicalize_ams_payload(const std::string& dev_id const int target = int_or("target", -1); const int slot = int_or("slot_id", -1); const int ams = int_or("ams_id", -1); + const bool has_target = target >= 0 && target <= 255 && print.contains("target"); + const bool legacy_unload = target == 255 && slot == 255; + const bool has_coordinates = ams >= 0 && slot >= 0; + + // The old request path retains macro semantics. Only translate the + // Slicer-generated Bambu form when its coordinates identify a slot. + const bool ambiguous_external_target = target == 255 && ams < 240; + if (!has_target || legacy_unload || ambiguous_external_target || !has_coordinates || ams > 255) { + const bool legacy_op_allowed = op_allowed("change_filament") + || op_allowed("external") || op_allowed("unload"); + if ((!fms_allowed || !legacy_op_allowed) && unsupported) + *unsupported = true; + return json_str; + } + print.erase("target"); print.erase("tray_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 { - // 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.erase("ams_id"); - print.erase("slot_id"); - if (ams >= 0 && slot >= 0) { - print["ams_id"] = ams; - print["slot_id"] = slot; - } - op = "change_filament"; - } + print["selector"] = "lane"; + print["ams_id"] = ams; + print["slot_id"] = slot; + op = ams >= 240 ? "external" : "change_filament"; } else if (cmd == "ams_filament_setting") { op = "filament_setting"; const int ams = int_or("ams_id", -1); @@ -1375,7 +1390,15 @@ std::string OrcaPrinterAgent::canonicalize_ams_payload(const std::string& dev_id if (!print.contains("tray_id")) { const int ams = int_or("ams_id", -1); const int slot = int_or("slot_id", -1); - if (ams >= 0 && slot >= 0) { + if (ams == -1 && slot >= 0) { + print["tray_id"] = slot; + print.erase("ams_id"); + print.erase("slot_id"); + } else if (ams >= 240 && ams <= 255 && slot >= 0) { + print["tray_id"] = ams; + print.erase("ams_id"); + print.erase("slot_id"); + } else if (ams >= 0 && ams < 240 && slot >= 0) { print["tray_id"] = ams * 4 + slot; // legacy ams+slot call shape print.erase("ams_id"); print.erase("slot_id"); @@ -1384,7 +1407,8 @@ std::string OrcaPrinterAgent::canonicalize_ams_payload(const std::string& dev_id } else if (cmd == "auto_stop_ams_dry") { op = "stop_dry"; } - if (!op_allowed(op) && unsupported) + const bool needs_fms = cmd != "ams_filament_setting" && !op.empty(); + if (((needs_fms && !fms_allowed) || !op_allowed(op)) && unsupported) *unsupported = true; return envelope.dump(); } catch (const std::exception&) { diff --git a/src/slic3r/Utils/OrcaPrinterAgent.hpp b/src/slic3r/Utils/OrcaPrinterAgent.hpp index 38b04ff024..ca6dc2a710 100644 --- a/src/slic3r/Utils/OrcaPrinterAgent.hpp +++ b/src/slic3r/Utils/OrcaPrinterAgent.hpp @@ -122,6 +122,9 @@ public: // (ams_id*4 + tray). command_ams_select_tray routes this exact body. static std::string build_ams_change_filament_body(int tray_number, int sequence_id); + // Build an RFID request from coordinates or a legacy flat tray id. + static std::string build_ams_refresh_rfid_body(int ams_id, int tray_or_slot_id, int sequence_id); + protected: // Forward one inbound printer message to on_message_fn or on_local_message_fn (marshalled onto the UI // thread via queue_on_main_fn when set). Body of every connection's MessageHandler. diff --git a/tests/slic3rutils/test_device_manager.cpp b/tests/slic3rutils/test_device_manager.cpp index ce61e8d4f0..46686aafd5 100644 --- a/tests/slic3rutils/test_device_manager.cpp +++ b/tests/slic3rutils/test_device_manager.cpp @@ -26,9 +26,8 @@ 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. +// Bambu virtual trays follow full-snapshot removals; Orca virtual trays are +// retained after first observation until reconnect. // Contract: an authoritative empty vir_slot ([] = "known, no virtual slots") // clears the seeded virtual trays. The consumers were guarded so an empty @@ -36,6 +35,7 @@ using namespace Slic3r; TEST_CASE("An empty vir_slot clears the virtual trays", "[DeviceManager]") { MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1"); + machine.printer_agent_id = "bbl"; REQUIRE(machine.vt_slot.size() == 1); REQUIRE(machine.vt_slot[0].id == "255"); @@ -49,6 +49,7 @@ TEST_CASE("An empty vir_slot clears the virtual trays", "[DeviceManager]") TEST_CASE("A missing vir_slot keeps the virtual trays", "[DeviceManager]") { MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1"); + machine.printer_agent_id = "bbl"; REQUIRE(machine.vt_slot.size() == 1); machine.parse_json("lan", R"({"print":{"command":"push_status"}})", false); @@ -62,6 +63,7 @@ TEST_CASE("A missing vir_slot keeps the virtual trays", "[DeviceManager]") TEST_CASE("Virtual trays repopulate after an authoritative clear", "[DeviceManager]") { MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1"); + machine.printer_agent_id = "bbl"; machine.parse_json("lan", R"({"print":{"command":"push_status","vir_slot":[]}})", false); REQUIRE(machine.vt_slot.empty()); @@ -79,6 +81,7 @@ TEST_CASE("Virtual trays repopulate after an authoritative clear", "[DeviceManag TEST_CASE("An orphan deputy virtual tray is dropped, not indexed", "[DeviceManager]") { MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1"); + machine.printer_agent_id = "bbl"; machine.parse_json("lan", R"({"print":{"command":"push_status","vir_slot":[]}})", false); REQUIRE(machine.vt_slot.empty()); @@ -93,6 +96,7 @@ TEST_CASE("An orphan deputy virtual tray is dropped, not indexed", "[DeviceManag TEST_CASE("A configured vir_slot is present even with no material", "[DeviceManager]") { MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1"); + machine.printer_agent_id = "orca"; machine.parse_json("lan", R"({"print":{"command":"push_status","vir_slot":[{"id":"255"},{"id":"254"}]}})", false); @@ -106,6 +110,7 @@ TEST_CASE("A configured vir_slot is present even with no material", "[DeviceMana TEST_CASE("A populated vir_slot prunes virtual trays it omits", "[DeviceManager]") { MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1"); + machine.printer_agent_id = "bbl"; machine.parse_json("lan", R"({"print":{"command":"push_status","vir_slot":[{"id":"255"},{"id":"254"}]}})", false); REQUIRE(machine.vt_slot.size() == 2); @@ -121,6 +126,7 @@ TEST_CASE("A populated vir_slot prunes virtual trays it omits", "[DeviceManager] TEST_CASE("A delta vir_slot does not prune virtual trays it omits", "[DeviceManager]") { MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1"); + machine.printer_agent_id = "bbl"; machine.parse_json("lan", R"({"print":{"command":"push_status","msg":0,"vir_slot":[{"id":"255"},{"id":"254"}]}})", false); REQUIRE(machine.vt_slot.size() == 2); @@ -138,6 +144,7 @@ TEST_CASE("A delta vir_slot does not prune virtual trays it omits", "[DeviceMana TEST_CASE("An empty delta vir_slot keeps the virtual trays", "[DeviceManager]") { MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1"); + machine.printer_agent_id = "bbl"; machine.parse_json("lan", R"({"print":{"command":"push_status","msg":0,"vir_slot":[{"id":"255"},{"id":"254"}]}})", false); REQUIRE(machine.vt_slot.size() == 2); @@ -150,6 +157,59 @@ TEST_CASE("An empty delta vir_slot keeps the virtual trays", "[DeviceManager]") CHECK(machine.ams_support_virtual_tray); } +TEST_CASE("Orca retains seen virtual trays across full snapshots", "[DeviceManager]") +{ + MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1"); + machine.printer_agent_id = "orca"; + + machine.parse_json("lan", R"({"print":{"command":"push_status","msg":0,"vir_slot":[{"id":"255"},{"id":"254"}]}})", false); + REQUIRE(machine.vt_slot.size() == 2); + + machine.parse_json("lan", R"({"print":{"command":"push_status","msg":0,"vir_slot":[{"id":"255","tag_uid":"0123456789ABCDEF"}]}})", false); + REQUIRE(machine.vt_slot.size() == 2); + CHECK(machine.vt_slot[0].tag_uid == "0123456789ABCDEF"); + CHECK(machine.vt_slot[1].id == "254"); + + machine.parse_json("lan", R"({"print":{"command":"push_status","msg":0,"vir_slot":[]}})", 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); + + machine.parse_json("lan", R"({"print":{"command":"push_status","msg":0}})", false); + CHECK(machine.vt_slot.size() == 2); +} + +TEST_CASE("Orca forgets retained virtual trays at reconnect", "[DeviceManager]") +{ + MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1"); + machine.printer_agent_id = "orca"; + machine.parse_json("lan", R"({"print":{"command":"push_status","msg":0,"vir_slot":[{"id":"255"},{"id":"254"}]}})", false); + REQUIRE(machine.vt_slot.size() == 2); + + machine.reset_orca_virtual_trays_for_reconnect(); + REQUIRE(machine.vt_slot.size() == 1); + CHECK(machine.vt_slot[0].id == "255"); + + machine.parse_json("lan", R"({"print":{"command":"push_status","msg":0,"vir_slot":[]}})", false); + CHECK(machine.vt_slot.empty()); + CHECK_FALSE(machine.ams_support_virtual_tray); +} + +TEST_CASE("Orca stores a deputy virtual tray at its stable index", "[DeviceManager]") +{ + MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1"); + machine.printer_agent_id = "orca"; + + machine.parse_json("lan", R"({"print":{"command":"push_status","msg":0,"vir_slot":[{"id":"254"}]}})", false); + + REQUIRE(machine.vt_slot.size() == 2); + CHECK(machine.vt_slot[0].id == "255"); + CHECK_FALSE(machine.vt_slot[0].is_exists); + CHECK(machine.vt_slot[1].id == "254"); + CHECK(machine.vt_slot[1].is_exists); +} + TEST_CASE("Capability flags parse without a DeviceManager", "[DeviceManager]") { MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1"); @@ -196,9 +256,11 @@ TEST_CASE("Only the BBL agent is RFID-locking", "[DeviceManager]") machine.printer_agent_id = "bbl"; CHECK(machine.is_bbl_agent()); + CHECK_FALSE(machine.is_orca_agent()); machine.printer_agent_id = "orca"; CHECK_FALSE(machine.is_bbl_agent()); + CHECK(machine.is_orca_agent()); machine.printer_agent_id = ""; CHECK(machine.is_bbl_agent()); diff --git a/tests/slic3rutils/test_orca_printer_agent.cpp b/tests/slic3rutils/test_orca_printer_agent.cpp index eca3260211..b9c9d1b5cb 100644 --- a/tests/slic3rutils/test_orca_printer_agent.cpp +++ b/tests/slic3rutils/test_orca_printer_agent.cpp @@ -324,22 +324,23 @@ TEST_CASE("OrcaPrinterAgent rewrites Bambu ams_* payloads onto the canonical Orc CHECK(!out["print"].contains("target")); CHECK(out["print"]["tar_temp"] == 220); - // External-spool selection ("254" arrives hacked to 255 with slot_id=0): - // must become the selector op, never a lane. + // External slots retain their tool identity through the coordinate address. 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"); - - // 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"]["selector"] == "lane"); + CHECK(out["print"]["ams_id"] == 255); + CHECK(out["print"]["slot_id"] == 0); CHECK(!out["print"].contains("lane")); CHECK(!out["print"].contains("target")); + out = nlohmann::json::parse(canon("dev-c1", R"({"print":{"command":"ams_change_filament","ams_id":254,"target":254,"slot_id":0}})")); CHECK(out["print"]["selector"] == "lane"); + CHECK(out["print"]["ams_id"] == 254); + + const std::string legacy_unload = R"({"print":{"command":"ams_change_filament","ams_id":0,"target":255,"slot_id":255}})"; + CHECK(canon("dev-c1", legacy_unload) == legacy_unload); + + // Target-only requests keep the legacy macro path. + const std::string target_only = R"({"print":{"command":"ams_change_filament","target":6}})"; + CHECK(canon("dev-c1", target_only) == target_only); // 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. @@ -378,14 +379,18 @@ TEST_CASE("OrcaPrinterAgent rewrites Bambu ams_* payloads onto the canonical Orc const std::string canonical = R"({"print":{"command":"ams_change_filament","selector":"lane","lane":3}})"; CHECK(canon("dev-c1", canonical) == canonical); + const std::string combined = R"({"print":{"command":"ams_change_filament","target":3,"selector":"lane","lane":3}})"; + CHECK(canon("dev-c1", combined) == combined); CHECK(canon("dev-c1", R"({"print":{"command":"pause"}})") == R"({"print":{"command":"pause"}})"); CHECK(canon("dev-c1", "not json at all") == "not json at all"); - // Declared ams_ops gate at the client: only change_filament is supported. + // A legacy unload still uses the legacy macro path, which is covered by + // change_filament; selector unload requires its own token. Slic3r::register_ams_ops("dev-c2", {"change_filament"}); + Slic3r::register_ams_capability("dev-c2", true); bool unsupported = false; canon("dev-c2", R"({"print":{"command":"ams_change_filament","target":255,"slot_id":255}})", &unsupported); - CHECK(unsupported); + CHECK_FALSE(unsupported); unsupported = false; canon("dev-c2", R"({"print":{"command":"ams_control","param":"pause"}})", &unsupported); CHECK(unsupported); @@ -399,14 +404,24 @@ TEST_CASE("OrcaPrinterAgent rewrites Bambu ams_* payloads onto the canonical Orc unsupported = false; canon("dev-c2", R"({"print":{"command":"ams_change_filament","selector":"external"}})", &unsupported); CHECK(unsupported); + unsupported = false; + canon("dev-c2", R"({"print":{"command":"ams_change_filament","target":254,"ams_id":254,"slot_id":0}})", &unsupported); + CHECK(unsupported); // A lane can resolve to an external slot server-side (§7.8), so a device // that declares only external still admits a canonical lane write. Slic3r::register_ams_ops("dev-c4", {"external"}); + Slic3r::register_ams_capability("dev-c4", true); unsupported = false; canon("dev-c4", R"({"print":{"command":"ams_change_filament","selector":"lane","lane":1}})", &unsupported); CHECK(!unsupported); unsupported = false; + canon("dev-c4", R"({"print":{"command":"ams_change_filament","selector":"lane","ams_id":0,"slot_id":0}})", &unsupported); + CHECK(unsupported); + unsupported = false; + canon("dev-c4", R"({"print":{"command":"ams_change_filament","selector":"lane","ams_id":254,"slot_id":0}})", &unsupported); + CHECK_FALSE(unsupported); + unsupported = false; canon("dev-c4", R"({"print":{"command":"ams_change_filament","selector":"external"}})", &unsupported); CHECK(!unsupported); // Other selectors still gate on their own token. @@ -420,10 +435,9 @@ TEST_CASE("OrcaPrinterAgent rewrites Bambu ams_* payloads onto the canonical Orc 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]") { +// filament_setting is advertised by filament_slots alone, independently of fms +// and ams_ops (OPCP §7.8). +TEST_CASE("filament slot writes remain independent of FMS", "[OrcaPrinterAgent]") { Probe agent("/tmp"); agent.deliver_to_sink("dev-slots", R"({"info":{"command":"get_capabilities","capabilities":{"protocol":{"features":{"fms":false,"filament_slots":true}}}}})", @@ -436,12 +450,25 @@ TEST_CASE("a filament_slots reply admits ams_filament_setting without ams_ops", &unsupported); CHECK_FALSE(unsupported); + agent.deliver_to_sink("dev-slots", + R"({"info":{"command":"get_capabilities","capabilities":{"protocol":{"features":{"fms":false,"filament_slots":true},"ams_ops":["change_filament"]}}}}})", + /*local=*/true); 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); + + agent.deliver_to_sink("dev-no-slots", + R"({"info":{"command":"get_capabilities","capabilities":{"protocol":{"features":{"fms":true,"filament_slots":false},"ams_ops":["filament_setting"]}}}})", + /*local=*/true); + unsupported = false; + OrcaPrinterAgent::canonicalize_ams_payload( + "dev-no-slots", + R"({"print":{"command":"ams_filament_setting","ams_id":0,"slot_id":0,"tray_type":"PLA"}})", + &unsupported); + CHECK(unsupported); } // A box wider than 4 slots is shown as several 4-tray units, so the BBL tray id @@ -460,6 +487,24 @@ TEST_CASE("an AMS tray selection sends the tray's ams_id and slot_id", "[OrcaPri body = nlohmann::json::parse(OrcaPrinterAgent::build_ams_change_filament_body(3, 124)); CHECK(body["print"]["ams_id"] == 0); CHECK(body["print"]["slot_id"] == 3); + + body = nlohmann::json::parse(OrcaPrinterAgent::build_ams_change_filament_body(254, 125)); + CHECK(body["print"]["ams_id"] == 254); + CHECK(body["print"]["slot_id"] == 0); + body = nlohmann::json::parse(OrcaPrinterAgent::build_ams_change_filament_body(255, 126)); + CHECK(body["print"]["ams_id"] == 255); + CHECK(body["print"]["slot_id"] == 0); +} + +TEST_CASE("RFID refresh maps coordinates and preserves flat tray ids", "[OrcaPrinterAgent]") { + auto body = nlohmann::json::parse(OrcaPrinterAgent::build_ams_refresh_rfid_body(1, 2, 127)); + CHECK(body["print"]["tray_id"] == 6); + + body = nlohmann::json::parse(OrcaPrinterAgent::build_ams_refresh_rfid_body(-1, 9, 128)); + CHECK(body["print"]["tray_id"] == 9); + + body = nlohmann::json::parse(OrcaPrinterAgent::build_ams_refresh_rfid_body(254, 0, 129)); + CHECK(body["print"]["tray_id"] == 254); } // A filament frame can arrive after the get_capabilities reply was missed (the diff --git a/tests/slic3rutils/test_printer_agent.cpp b/tests/slic3rutils/test_printer_agent.cpp index 9f53511520..8946b6a082 100644 --- a/tests/slic3rutils/test_printer_agent.cpp +++ b/tests/slic3rutils/test_printer_agent.cpp @@ -232,7 +232,7 @@ TEST_CASE("unit: default AMS commands report not supported", "[unit][moonraker]" { MoonrakerPrinterAgent agent(""); - CHECK(agent.command_ams_refresh_rfid("dev", "123", 1, false) == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED); + CHECK(agent.command_ams_refresh_rfid("dev", -1, 123, 1, false) == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED); CHECK(agent.command_ams_calibrate("dev", 1, 2, false) == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED); CHECK(agent.command_ams_select_tray("dev", "123", 3, false) == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED); }