From aa934409a7070aad10824476c370faae31dda7b6 Mon Sep 17 00:00:00 2001 From: Lam Wei Lun Date: Thu, 24 Sep 2026 12:34:51 +0800 Subject: [PATCH] feat(orca-agent): integrate OrcaSonar AMS synchronization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Translate slicer AMS commands to OrcaSonar’s canonical write format - Gate AMS operations using printer capabilities - Refresh capabilities when filament topology becomes available - Apply virtual-slot removals only from full status snapshots - Add synchronization tests and document the integration --- docs/HLSD/printer-agent.md | 39 ++++++++ src/slic3r/GUI/DeviceCore/DevFilaSystem.h | 3 +- src/slic3r/GUI/DeviceCore/DevManager.cpp | 6 +- src/slic3r/GUI/DeviceManager.cpp | 91 +++++++++++++------ src/slic3r/Utils/AmsPayload.cpp | 7 ++ src/slic3r/Utils/AmsPayload.hpp | 5 + src/slic3r/Utils/OrcaPrinterAgent.cpp | 59 +++++++++++- src/slic3r/Utils/OrcaPrinterAgent.hpp | 17 ++++ .../libslic3r/test_preset_bundle_loading.cpp | 4 +- tests/slic3rutils/test_device_manager.cpp | 86 ++++++++++++++++++ tests/slic3rutils/test_orca_printer_agent.cpp | 83 +++++++++++++++++ 11 files changed, 364 insertions(+), 36 deletions(-) diff --git a/docs/HLSD/printer-agent.md b/docs/HLSD/printer-agent.md index 7062fab966..14c0dabd5f 100644 --- a/docs/HLSD/printer-agent.md +++ b/docs/HLSD/printer-agent.md @@ -152,6 +152,41 @@ The methods remain on the common interface so an agent that supports them can ov `sequence_id` remains part of the command contract because `DeviceManager` creates and tracks it as the command ID. +## Filament and AMS synchronization + +The `OrcaPrinterAgent` bridges OrcaSonar's filament/AMS state. Its capability reply +carries two independent signals: `protocol.features.fms` means a material system resolves, +and `protocol.features.filament_slots` means the connector owns a filament-slot model that +persists without any material hardware. Either one enables filament synchronization, so a +printer with slots but no AMS still exposes the slot workflow. The agent also caches +`protocol.ams_ops`, the canonical material write operations the resolved drivers implement, +and uses it to gate AMS writes; `print.ams_filament_setting` is exempt because it persists +connector state and is advertised by `filament_slots` alone. A write whose operation is not +declared is refused with `CAP_NOT_AVAILABLE` before it reaches the wire. + +Printer state arrives as the pushed `ams`/`vir_slot` projection inside `push_status`; the +Orca agent does not poll the Moonraker `lane_data` namespace, which exists for +Moonraker-channel consumers. Slot presence is the user's declaration, not sensed material: +`tray_exist_bits` and the presence of a `vir_slot` entry mark a slot as present even with an +empty `tray_type`, which is the `is_exists` state the filament UI reads. A full status frame +(`msg=0`, or a LAN frame with no `msg`) is authoritative for removals, so a virtual tray id +absent from a populated `vir_slot` is dropped; a delta frame (`msg=1`) only updates the +entries it names and leaves omitted entries held. + +Writes follow the same edge-translation rule as the rest of the agent. The shared +`MachineObject` command builders emit Bambu-shaped `print.ams_*` payloads, and the agent's +single send funnel decodes them into OrcaSonar's canonical bodies — `selector` with a flat +lane or `ams_id`/`slot_id` coordinates for `ams_change_filament`, coordinates for +`ams_filament_setting` — before publishing. The server resolves and rejects with its own +`errno` backstop; the agent only withholds operations the device did not declare. + +A filament frame can arrive before the capability reply that decides synchronization mode, +for example when the topology bootstraps after connect or Klipper restarts. While a device's +capabilities are still unknown, the agent re-requests `get_capabilities` and a full status on +a filament frame, throttled per device, and stops once an answer arrives. Dedicated +capability state is cleared when a cloud device is deselected, while an independently active +LAN session for the same device id keeps its declaration. + ## Device ownership and stale responses Printer-agent ownership is represented by `printer_agent_id` on device records and `MachineObject` @@ -233,6 +268,10 @@ inside the Bambu agent and the Orca agent's v1 sink adapter can be removed as a cloud camera contract - [`NetworkAgent`](../../src/slic3r/Utils/NetworkAgent.hpp) — façade and dispatch between active agents - [`NetworkAgentFactory`](../../src/slic3r/Utils/NetworkAgentFactory.hpp) — built-in and Python agent registry +- [`OrcaPrinterAgent`](../../src/slic3r/Utils/OrcaPrinterAgent.hpp) — OrcaSonar transport, capability + discovery, and Bambu-to-canonical command translation +- [`AmsPayload`](../../src/slic3r/Utils/AmsPayload.hpp) — shared filament payload rendering and the + per-device AMS capability cache - [`DeviceManager`](../../src/slic3r/GUI/DeviceCore/DevManager.cpp) — device ownership, filtering, and stale-response checks - [`PrinterAgentPluginCapability`](../../src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.cpp) diff --git a/src/slic3r/GUI/DeviceCore/DevFilaSystem.h b/src/slic3r/GUI/DeviceCore/DevFilaSystem.h index 1b8fadccc4..f3593eaaf4 100644 --- a/src/slic3r/GUI/DeviceCore/DevFilaSystem.h +++ b/src/slic3r/GUI/DeviceCore/DevFilaSystem.h @@ -45,7 +45,8 @@ struct DevFilamentDryingPreset; * - color: Hex color string without '#' prefix (e.g., "FF0000") * - cols: Multi-color component list for gradient/multi-color filaments * - ctype: Color type indicator - * - is_exists: Whether filament is currently loaded in the tray + * - is_exists: Whether the slot is present/configured (the user's layout wins); + * not whether filament is loaded, so a configured empty slot stays present */ class DevAmsTray { diff --git a/src/slic3r/GUI/DeviceCore/DevManager.cpp b/src/slic3r/GUI/DeviceCore/DevManager.cpp index f129700c5e..c730ddaf37 100644 --- a/src/slic3r/GUI/DeviceCore/DevManager.cpp +++ b/src/slic3r/GUI/DeviceCore/DevManager.cpp @@ -67,7 +67,9 @@ namespace Slic3r AppConfig* DeviceManager::get_app_config() const { - return m_app_config ? m_app_config : GUI::wxGetApp().app_config; + if (m_app_config) + return m_app_config; + return wxTheApp ? GUI::wxGetApp().app_config : nullptr; } void DeviceManager::load_local_machines_from_config() @@ -459,7 +461,7 @@ namespace Slic3r void DeviceManager::update_local_machine(const MachineObject& m) { - update_local_machine(m, GUI::wxGetApp().app_config); + update_local_machine(m, wxTheApp ? GUI::wxGetApp().app_config : nullptr); } int DeviceManager::query_bind_status(std::string& msg, const std::string& provider) diff --git a/src/slic3r/GUI/DeviceManager.cpp b/src/slic3r/GUI/DeviceManager.cpp index d8fd838f96..c032ff6a6f 100644 --- a/src/slic3r/GUI/DeviceManager.cpp +++ b/src/slic3r/GUI/DeviceManager.cpp @@ -477,7 +477,8 @@ void MachineObject::set_access_code(std::string code, bool only_refresh) { this->access_code = code; if (only_refresh) { - AppConfig* config = m_manager ? m_manager->get_app_config() : GUI::wxGetApp().app_config; + AppConfig* config = m_manager ? m_manager->get_app_config() + : (wxTheApp ? GUI::wxGetApp().app_config : nullptr); if (config) { if (is_lan_mode_printer()) { // why: LAN codes are scoped via BBLocalMachine::access_code, keyed by dev_id and @@ -2843,6 +2844,10 @@ 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). + bool full_snapshot = true; json j; if (!parse_ok) j_pre = json::parse(payload); @@ -2859,11 +2864,12 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_ BOOST_LOG_TRIVIAL(trace) << "static: get push_all msg, dev_id=" << dev_id; m_push_count++; m_full_msg_count++; - + full_snapshot = true; if (!printer_type.empty()) print_json.load_compatible_settings(printer_type, ""); print_json.diff2all_base_reset(j_pre); } else if (j_pre["print"]["msg"].get() == 1) { //diff message + full_snapshot = false; if (print_json.diff2all(j_pre, j) == 0) { restored_json = true; } else { @@ -3091,7 +3097,7 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_ } if (!key_field_only) { - if (!m_manager->IsMultiMachineEnabled() && !is_support_agora) { + if ((!m_manager || !m_manager->IsMultiMachineEnabled()) && !is_support_agora) { if (jj.contains("support_tunnel_mqtt")) { if (jj["support_tunnel_mqtt"].is_boolean()) { is_support_tunnel_mqtt = jj["support_tunnel_mqtt"].get(); @@ -4015,39 +4021,60 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_ 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; + // 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. 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 (auto it = jj["vir_slot"].begin(); it != jj["vir_slot"].end(); it++) { - auto vslot = parse_vt_tray(it.value().get()); - - if (vslot.id == std::to_string(VIRTUAL_TRAY_MAIN_ID)) { - auto it = std::next(vt_slot.begin(), 0); - if (it != vt_slot.end()) { - vt_slot[0] = vslot; - } - else { - vt_slot.push_back(vslot); + if (vslot.id == std::to_string(VIRTUAL_TRAY_MAIN_ID)) { + if (fresh.empty()) { + fresh.push_back(vslot); + } + 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); + } + } + } } + vt_slot = std::move(fresh); } - 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 (vt_slot.size() > 1) { - vt_slot[1] = vslot; - } - else if (vt_slot.size() == 1) { - vt_slot.push_back(vslot); + 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()); + for (auto& held : vt_slot) { + if (held.id == vslot.id) { + held = vslot; + break; + } + } } } } @@ -4137,6 +4164,7 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_ vt_slot[0].setting_id = jj["tray_info_idx"].get(); //vt_tray.type = jj["tray_type"].get(); vt_slot[0].m_fila_type = setting_id_to_type(vt_slot[0].setting_id, jj["tray_type"].get()); + vt_slot[0].is_exists = true; // delay update vt_slot[0].set_hold_count(); } else { @@ -4682,7 +4710,9 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_ if (diff.count() > 10.0f) { BOOST_LOG_TRIVIAL(trace) << "parse_json timeout = " << diff.count(); } - DeviceManager::update_local_machine(*this, m_manager ? m_manager->get_app_config() : GUI::wxGetApp().app_config); + AppConfig* app_config = m_manager ? m_manager->get_app_config() + : (wxTheApp ? GUI::wxGetApp().app_config : nullptr); + DeviceManager::update_local_machine(*this, app_config); return 0; } @@ -5084,6 +5114,9 @@ bool MachineObject::is_firmware_info_valid() DevAmsTray MachineObject::parse_vt_tray(json vtray) { auto vt_tray = DevAmsTray(std::to_string(VIRTUAL_TRAY_MAIN_ID)); + // OrcaSonar emits a virtual slot only when the layout has it: the user's + // configuration wins, so it is present even with no material loaded. + vt_tray.is_exists = true; if (vtray.contains("id")) vt_tray.id = vtray["id"].get(); @@ -5530,7 +5563,7 @@ void MachineObject::parse_new_info2(const json& info) parse_bool("support_build_plate_marker_detect", is_support_build_plate_marker_detect); parse_bool("support_nozzle_blob_detect", is_support_nozzle_blob_detection); - if (!m_manager->IsMultiMachineEnabled() && !is_support_agora) + if ((!m_manager || !m_manager->IsMultiMachineEnabled()) && !is_support_agora) parse_bool("support_tunnel_mqtt", is_support_tunnel_mqtt); const auto bed_leveling_it = flags.find("support_bed_leveling"); diff --git a/src/slic3r/Utils/AmsPayload.cpp b/src/slic3r/Utils/AmsPayload.cpp index 7c5b9563f5..2043f6332b 100644 --- a/src/slic3r/Utils/AmsPayload.cpp +++ b/src/slic3r/Utils/AmsPayload.cpp @@ -425,6 +425,13 @@ bool has_ams_capability(const std::string& dev_id) return it != g_ams_caps.end() && it->second.has_ams; } +bool ams_caps_known(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.ops_known; +} + void register_filament_slots(const std::string& dev_id, bool has_slots) { if (dev_id.empty()) diff --git a/src/slic3r/Utils/AmsPayload.hpp b/src/slic3r/Utils/AmsPayload.hpp index e4abc54bdc..a6c9f6f56b 100644 --- a/src/slic3r/Utils/AmsPayload.hpp +++ b/src/slic3r/Utils/AmsPayload.hpp @@ -104,6 +104,11 @@ 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. +bool ams_caps_known(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 diff --git a/src/slic3r/Utils/OrcaPrinterAgent.cpp b/src/slic3r/Utils/OrcaPrinterAgent.cpp index 5a3a5bb597..c7933f71ca 100644 --- a/src/slic3r/Utils/OrcaPrinterAgent.cpp +++ b/src/slic3r/Utils/OrcaPrinterAgent.cpp @@ -112,6 +112,18 @@ std::string next_gcode_file_sequence_id() return std::to_string(counter.fetch_add(1, std::memory_order_relaxed)); } +// pushall is replay-cached per (namespace, command, sequence_id), so a refresh +// must not reuse the connect-band ids (20001..20004). Seed from the wall clock +// like next_gcode_file_sequence_id and bump once per call within a run. +std::string next_filament_refresh_sequence_id() +{ + static std::atomic counter{[] { + const auto now = std::chrono::system_clock::now().time_since_epoch(); + return static_cast(std::chrono::duration_cast(now).count()) + 100000; + }()}; + return std::to_string(counter.fetch_add(1, std::memory_order_relaxed)); +} + static constexpr const char* ORCASONAR_FALLBACK = "orcasonar"; bool fetch_orcasonar_body(const std::string& url, std::string& body) @@ -677,6 +689,7 @@ void OrcaPrinterAgent::deliver_to_sink(const std::string& dev_id, const std::str { parse_ipcam_info(dev_id, payload); register_ams_capabilities(dev_id, payload); + maybe_refresh_filament_capabilities(dev_id, payload, local); std::string merged_payload = merge_capabilities(dev_id, payload); OnMessageFn fn; @@ -1061,6 +1074,38 @@ void OrcaPrinterAgent::emit_connect_sequence(const std::string& dev_id, request(build_get_capabilities(seq(4))); } +void OrcaPrinterAgent::request_filament_capabilities(const std::string& dev_id, bool local) +{ + if (dev_id.empty()) + return; + OrcaMqttConnection* conn = get_appropriate_mqtt_connection(local); + if (!conn) + return; + conn->send_request(dev_id, build_get_capabilities(next_filament_refresh_sequence_id())); + conn->send_request(dev_id, build_pushall(next_filament_refresh_sequence_id())); +} + +void OrcaPrinterAgent::maybe_refresh_filament_capabilities(const std::string& dev_id, const std::string& payload, bool local) +{ + if (dev_id.empty() || ams_caps_known(dev_id)) + return; + if (payload.find("push_status") == std::string::npos) + return; + // Only filament state is a reason to ask: ams_exist_bits/tray_exist_bits or + // the vir_slot array. A status without either cannot resolve topology. + if (payload.find("\"vir_slot\"") == std::string::npos && payload.find("\"ams\":") == std::string::npos) + return; + { + std::lock_guard l(state_mutex); + const auto now = std::chrono::steady_clock::now(); + const auto it = m_filament_caps_refresh_at.find(dev_id); + if (it != m_filament_caps_refresh_at.end() && now - it->second < std::chrono::seconds(5)) + return; + m_filament_caps_refresh_at[dev_id] = now; + } + request_filament_capabilities(dev_id, local); +} + void OrcaPrinterAgent::on_connected(const std::string& dev_id, OrcaMqttConnection* conn, uint64_t generation) { // Called from both connect paths with whichever epoch that path captured; the two @@ -1251,8 +1296,12 @@ std::string OrcaPrinterAgent::canonicalize_ams_payload(const std::string& dev_id // 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) + // 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")) + : op_allowed(sel); + if (!allowed && unsupported) *unsupported = true; return json_str; } @@ -1465,11 +1514,13 @@ int OrcaPrinterAgent::set_user_selected_machine(std::string dev_id) { auto* cloud = get_orca_cloud_agent(); std::string previous; + std::string lan_dev_id; CurrentConn previous_connection; CurrentConn current_connection; { std::lock_guard lock(state_mutex); previous_connection = m_current_connection; + lan_dev_id = m_lan_dev_id; // An empty cloud selection must not clear an independently active LAN // selection. Conversely, selecting a cloud machine with the same id // while LAN is active is still a transport switch and must proceed. @@ -1498,6 +1549,10 @@ int OrcaPrinterAgent::set_user_selected_machine(std::string dev_id) BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::set_user_selected_machine: previous=" << previous << " new=" << dev_id << " cloud=" << (cloud ? "set" : "") << " transport=" << connection_type_name(previous_connection) << "->" << connection_type_name(current_connection); + // Forget the deselected cloud device's declaration so a later session starts + // from "no reply yet". A LAN feed for the same id keeps its capabilities. + if (!previous.empty() && previous != dev_id && previous != lan_dev_id) + clear_ams_caps(previous); if (!cloud) { BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent::set_user_selected_machine: no Orca cloud agent"; return BAMBU_NETWORK_SUCCESS; diff --git a/src/slic3r/Utils/OrcaPrinterAgent.hpp b/src/slic3r/Utils/OrcaPrinterAgent.hpp index 91e15f00b5..ee7e6b07e0 100644 --- a/src/slic3r/Utils/OrcaPrinterAgent.hpp +++ b/src/slic3r/Utils/OrcaPrinterAgent.hpp @@ -6,12 +6,14 @@ #include "OrcaCloudServiceAgent.hpp" #include "OrcaMqttConnection.hpp" #include +#include #include #include #include #include #include #include +#include namespace Slic3r { @@ -165,6 +167,17 @@ protected: virtual void emit_connect_sequence(const std::string& dev_id, std::function subscribe, std::function request); + + // Re-ask a device for its capabilities and a full status. Sent when a + // filament frame arrives before the capabilities reply (the topology + // bootstrapped after connect, or Klipper restarted), so a session that + // latched FilamentSyncMode::none can still reach subscription mode. + virtual void request_filament_capabilities(const std::string& dev_id, bool local); + + // deliver_to_sink hook: re-request capabilities on a filament frame while + // the device's topology is still unconfirmed, throttled per device. + void maybe_refresh_filament_capabilities(const std::string& dev_id, const std::string& payload, bool local); + static std::string seq(int n); // decimal string in the OrcaSlicer 20000..29999 band static std::string build_pushing_start(const std::string& sequence_id); static std::string build_pushing_stop(const std::string& sequence_id); @@ -211,6 +224,10 @@ private: CameraStreamMode m_camera_stream_mode = CameraStreamMode::none; // guarded by state_mutex std::string m_camera_url; // guarded by state_mutex + // Last capability re-request per device; bounds the refresh to one per + // device while a filament frame keeps arriving without a reply. + std::unordered_map m_filament_caps_refresh_at; // guarded by state_mutex + // The Moonraker-façade X-Api-Key, bootstrapped from /access/api_key (trusted // clients only) and cached per connection generation; falls back to the // access code when the endpoint is unavailable (untrusted/hardened config). diff --git a/tests/libslic3r/test_preset_bundle_loading.cpp b/tests/libslic3r/test_preset_bundle_loading.cpp index 3d03bc4819..2b6952aa92 100644 --- a/tests/libslic3r/test_preset_bundle_loading.cpp +++ b/tests/libslic3r/test_preset_bundle_loading.cpp @@ -5495,14 +5495,14 @@ TEST_CASE("filament_id_by_type resolves a modifier type to its generic family", pla.is_visible = true; pla.is_compatible = true; pla.filament_id = "GFL99"; - pla.config.option("filament_type")->value = "PLA"; + pla.config.option("filament_type")->values = {"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"; + petg.config.option("filament_type")->values = {"PETG"}; CHECK(bundle.filaments.filament_id_by_type("PETG Basic") == "GFG99"); CHECK(bundle.filaments.filament_id_by_type("PLA") == "GFL99"); diff --git a/tests/slic3rutils/test_device_manager.cpp b/tests/slic3rutils/test_device_manager.cpp index fc7eb99f84..ce61e8d4f0 100644 --- a/tests/slic3rutils/test_device_manager.cpp +++ b/tests/slic3rutils/test_device_manager.cpp @@ -88,6 +88,92 @@ TEST_CASE("An orphan deputy virtual tray is dropped, not indexed", "[DeviceManag CHECK(machine.vt_slot.empty()); } +// OrcaSonar emits a virtual slot only when the layout has it, so it counts as +// present even with no filament: the user's configuration wins. +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.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].is_exists); + CHECK(machine.vt_slot[1].is_exists); +} + +// A full vir_slot snapshot is authoritative: an id it omits is removed, so a +// tool-count shrink does not leave a stale deputy tray. +TEST_CASE("A populated vir_slot prunes virtual trays it omits", "[DeviceManager]") +{ + MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1"); + + machine.parse_json("lan", R"({"print":{"command":"push_status","vir_slot":[{"id":"255"},{"id":"254"}]}})", false); + REQUIRE(machine.vt_slot.size() == 2); + + machine.parse_json("lan", R"({"print":{"command":"push_status","vir_slot":[{"id":"255"}]}})", false); + + REQUIRE(machine.vt_slot.size() == 1); + CHECK(machine.vt_slot[0].id == "255"); +} + +// A delta frame (msg=1) is not authoritative for removals: an entry it omits +// stays held, and an entry it names is updated in place (spec §7.3). +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.parse_json("lan", R"({"print":{"command":"push_status","msg":0,"vir_slot":[{"id":"255"},{"id":"254"}]}})", false); + REQUIRE(machine.vt_slot.size() == 2); + + // Delta names only the deputy: the main must survive, the deputy updates. + machine.parse_json("lan", R"({"print":{"command":"push_status","msg":1,"vir_slot":[{"id":"254","tag_uid":"ABCDEF0123456789"}]}})", false); + + REQUIRE(machine.vt_slot.size() == 2); + CHECK(machine.vt_slot[0].id == "255"); + CHECK(machine.vt_slot[1].id == "254"); + CHECK(machine.vt_slot[1].tag_uid == "ABCDEF0123456789"); +} + +// An empty delta is not authoritative and must preserve the known layout. +TEST_CASE("An empty delta vir_slot keeps the virtual trays", "[DeviceManager]") +{ + MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1"); + + 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":1,"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); +} + +TEST_CASE("Capability flags parse without a DeviceManager", "[DeviceManager]") +{ + MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1"); + + machine.parse_json("lan", + R"({"info":{"command":"get_capabilities","capabilities":{"flags":{"support_tunnel_mqtt":true}}}})", false); + + CHECK(machine.is_support_tunnel_mqtt); +} + +// A filament setting landing on the virtual tray also marks it present. +TEST_CASE("A virtual tray setting marks the tray present", "[DeviceManager]") +{ + MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1"); + + machine.parse_json("lan", R"({"print":{"command":"push_status","vir_slot":[{"id":"255"}]}})", false); + REQUIRE(machine.vt_slot.size() == 1); + machine.vt_slot[0].is_exists = false; + + machine.parse_json("lan", R"({"print":{"command":"ams_filament_setting","ams_id":255,"tray_id":255,"tray_color":"FF0000FF","tray_type":"PLA","tray_info_idx":"GFA00","nozzle_temp_min":190,"nozzle_temp_max":230}})", false); + + CHECK(machine.vt_slot[0].is_exists); +} + // acks that target the virtual tray must survive an emptied vt_slot. TEST_CASE("Virtual tray acks are safe with no virtual tray", "[DeviceManager]") { diff --git a/tests/slic3rutils/test_orca_printer_agent.cpp b/tests/slic3rutils/test_orca_printer_agent.cpp index 68f8d329e0..6d731b09d6 100644 --- a/tests/slic3rutils/test_orca_printer_agent.cpp +++ b/tests/slic3rutils/test_orca_printer_agent.cpp @@ -394,6 +394,20 @@ TEST_CASE("OrcaPrinterAgent rewrites Bambu ams_* payloads onto the canonical Orc canon("dev-c2", R"({"print":{"command":"ams_change_filament","selector":"external"}})", &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"}); + 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":"external"}})", &unsupported); + CHECK(!unsupported); + // Other selectors still gate on their own token. + unsupported = false; + canon("dev-c4", R"({"print":{"command":"ams_change_filament","selector":"unload"}})", &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); @@ -441,3 +455,72 @@ TEST_CASE("an AMS tray selection sends the tray's ams_id and slot_id", "[OrcaPri CHECK(body["print"]["ams_id"] == 0); CHECK(body["print"]["slot_id"] == 3); } + +// A filament frame can arrive after the get_capabilities reply was missed (the +// topology resolved late, or Klipper restarted). While the device is still +// unconfirmed, one filament frame must re-ask, throttled, and stop once a reply +// arrives so the session can leave FilamentSyncMode::none. +TEST_CASE("a filament frame re-requests capabilities while the topology is unconfirmed", "[OrcaPrinterAgent]") { + struct RefreshProbe : OrcaPrinterAgent { + using OrcaPrinterAgent::OrcaPrinterAgent; + using OrcaPrinterAgent::deliver_to_sink; + std::vector refreshes; + void request_filament_capabilities(const std::string& dev_id, bool /*local*/) override { + refreshes.push_back(dev_id); + } + } agent("/tmp"); + agent.set_on_message_fn([](std::string, std::string) {}); + + const std::string frame = R"({"print":{"command":"push_status","vir_slot":[{"id":"255"}]}})"; + agent.deliver_to_sink("dev-refresh-1", frame, /*local=*/true); + REQUIRE(agent.refreshes.size() == 1); + + // Throttled: a second frame immediately after does not ask again. + agent.deliver_to_sink("dev-refresh-1", frame, /*local=*/true); + CHECK(agent.refreshes.size() == 1); + + // A capability reply confirms the topology: no further re-request. + agent.deliver_to_sink("dev-refresh-1", + R"({"info":{"command":"get_capabilities","capabilities":{"protocol":{"features":{"filament_slots":true}}}}})", + /*local=*/true); + agent.deliver_to_sink("dev-refresh-1", frame, /*local=*/true); + CHECK(agent.refreshes.size() == 1); + + // A status without filament state never triggers a request. + agent.deliver_to_sink("dev-refresh-2", R"({"print":{"command":"push_status","mc_percent":10}})", /*local=*/true); + CHECK(agent.refreshes.size() == 1); +} + +// Deselecting a cloud device forgets its declaration, so a later session starts +// from "no reply yet" instead of a stale one. With no record an undeclared op is +// admitted again, which is how a freshly selected device starts. +TEST_CASE("deselecting a cloud device forgets its capabilities", "[OrcaPrinterAgent]") { + OrcaPrinterAgent agent("/tmp"); + Slic3r::register_ams_ops("dev-cloud-clear", {"change_filament"}); + Slic3r::register_filament_slots("dev-cloud-clear", true); + CHECK_FALSE(Slic3r::ams_op_supported("dev-cloud-clear", "external")); + + agent.set_user_selected_machine("dev-cloud-clear"); + agent.set_user_selected_machine(""); + + CHECK(Slic3r::ams_op_supported("dev-cloud-clear", "external")); + CHECK_FALSE(Slic3r::has_filament_slots("dev-cloud-clear")); +} + +// A LAN feed for the same device id must keep its declaration when the cloud +// selection is cleared: clearing it would strand an independently live session. +// why hidden: spawns the LAN connect worker, like the other connect tests. +TEST_CASE("deselecting a cloud device keeps a live LAN declaration", "[OrcaPrinterAgent][.integration]") { + OrcaPrinterAgent agent("/tmp"); + Slic3r::register_ams_ops("dev-lan-keep", {"change_filament"}); + Slic3r::register_filament_slots("dev-lan-keep", true); + + REQUIRE(agent.connect_printer("dev-lan-keep", "10.255.255.1", "orcasonar", "code", false) == BAMBU_NETWORK_SUCCESS); + agent.set_user_selected_machine("dev-lan-keep"); + agent.set_user_selected_machine(""); + + CHECK_FALSE(Slic3r::ams_op_supported("dev-lan-keep", "external")); + CHECK(Slic3r::has_filament_slots("dev-lan-keep")); + + agent.disconnect_printer(); +}