From 5a4fcafb5b79d8eade82133c9af18bee76c55888 Mon Sep 17 00:00:00 2001 From: Lam Wei Lun Date: Mon, 21 Sep 2026 18:43:08 +0800 Subject: [PATCH] Bunch of dedupes --- src/slic3r/Utils/AmsPayload.cpp | 99 ++++++++- src/slic3r/Utils/AmsPayload.hpp | 27 ++- src/slic3r/Utils/MoonrakerPrinterAgent.cpp | 89 ++------ src/slic3r/Utils/OrcaPrinterAgent.cpp | 210 ++++++------------ src/slic3r/Utils/OrcaPrinterAgent.hpp | 14 +- tests/slic3rutils/test_orca_printer_agent.cpp | 24 +- tests/slic3rutils/test_printer_agent.cpp | 13 ++ 7 files changed, 232 insertions(+), 244 deletions(-) diff --git a/src/slic3r/Utils/AmsPayload.cpp b/src/slic3r/Utils/AmsPayload.cpp index ce5853b781..61658b291c 100644 --- a/src/slic3r/Utils/AmsPayload.cpp +++ b/src/slic3r/Utils/AmsPayload.cpp @@ -1,5 +1,6 @@ #include "AmsPayload.hpp" +#include "Http.hpp" #include "libslic3r/Preset.hpp" #include "libslic3r/PresetBundle.hpp" #include "slic3r/GUI/GUI_App.hpp" @@ -20,6 +21,7 @@ #include #include #include +#include namespace Slic3r { @@ -203,6 +205,69 @@ bool parse_moonraker_lane_data(const nlohmann::json& body, return true; } +LaneDataFetch read_moonraker_lane_data(const std::string& origin, + const std::string& api_key, + std::vector& trays, + int& max_lane_index) +{ + trays.clear(); + max_lane_index = 0; + std::string base = origin; + while (!base.empty() && base.back() == '/') + base.pop_back(); + if (base.empty()) + return LaneDataFetch::unknown; + + unsigned http_status = 0; + std::string response_body; + std::string http_error; + auto http = Http::get(base + "/server/database/item?namespace=lane_data"); + if (!api_key.empty()) + http.header("X-Api-Key", api_key); + http.timeout_connect(5) + .timeout_max(10) + .on_complete([&](std::string body, unsigned status) { + http_status = status; + if (status == 200) + response_body = std::move(body); + else + http_error = "HTTP error: " + std::to_string(status); + }) + .on_error([&](std::string, std::string err, unsigned status) { + // Http routes every >=400 response through here, so a 404 arrives as + // a real HTTP status, not a transport failure (REQ-STS-007 §7.7). + http_status = status; + http_error = err; + if (status > 0) + http_error += " (HTTP " + std::to_string(status) + ")"; + }) + .perform_sync(); + + if (http_status == 404) { + BOOST_LOG_TRIVIAL(info) << "AmsPayload: lane_data not served yet (unknown topology)"; + return LaneDataFetch::unknown; + } + if (http_status != 200) { + BOOST_LOG_TRIVIAL(info) << "AmsPayload: lane_data fetch failed: " << http_error; + return LaneDataFetch::error; + } + + auto json = nlohmann::json::parse(response_body, nullptr, false, true); + if (json.is_discarded()) { + BOOST_LOG_TRIVIAL(warning) << "AmsPayload: invalid lane_data JSON"; + return LaneDataFetch::error; + } + const bool empty_value = json.is_object() && json.contains("result") && json["result"].is_object() && + json["result"].contains("value") && json["result"]["value"].is_object() && + json["result"]["value"].empty(); + if (empty_value) + return LaneDataFetch::none; + + if (!parse_moonraker_lane_data(json, trays, max_lane_index)) + return LaneDataFetch::error; + return LaneDataFetch::synced; +} + void resolve_tray_info_idx(std::vector& trays) { auto* bundle = GUI::wxGetApp().preset_bundle; @@ -315,10 +380,18 @@ nlohmann::json build_bbl_ams_json(const std::vector& trays, // Last ams_count rendered per device: clear_ams_payload_for_device walks the // same unit set to mark them all absent. -static std::mutex g_ams_state_mutex; -static std::map g_ams_last_count; -static std::map> g_ams_ops; -static std::map g_ams_capability; +static std::mutex g_ams_state_mutex; +static std::map g_ams_last_count; + +// One device's declaration from its get_capabilities reply. ops_known separates +// "no reply yet" (never gate) from "answered without ops" (gate every write). +struct AmsDeviceCaps +{ + std::vector ops; + bool ops_known = false; + bool has_ams = false; +}; +static std::map g_ams_caps; static void remember_ams_count(const std::string& dev_id, int ams_count) { @@ -333,16 +406,18 @@ void register_ams_ops(const std::string& dev_id, const std::vector& if (dev_id.empty()) return; std::lock_guard lock(g_ams_state_mutex); - g_ams_ops[dev_id] = ops; + AmsDeviceCaps& caps = g_ams_caps[dev_id]; + caps.ops = ops; + caps.ops_known = true; } bool ams_op_supported(const std::string& dev_id, const std::string& op) { std::lock_guard lock(g_ams_state_mutex); - auto it = g_ams_ops.find(dev_id); - if (it == g_ams_ops.end()) - return true; // no OrcaSonar capabilities seen: never gate - return std::find(it->second.begin(), it->second.end(), op) != it->second.end(); + auto it = g_ams_caps.find(dev_id); + if (it == g_ams_caps.end() || !it->second.ops_known) + return true; // no capability reply yet: never gate + return std::find(it->second.ops.begin(), it->second.ops.end(), op) != it->second.ops.end(); } void register_ams_capability(const std::string& dev_id, bool has_ams) @@ -350,14 +425,14 @@ void register_ams_capability(const std::string& dev_id, bool has_ams) if (dev_id.empty()) return; std::lock_guard lock(g_ams_state_mutex); - g_ams_capability[dev_id] = has_ams; + g_ams_caps[dev_id].has_ams = has_ams; } bool has_ams_capability(const std::string& dev_id) { std::lock_guard lock(g_ams_state_mutex); - auto it = g_ams_capability.find(dev_id); - return it != g_ams_capability.end() && it->second; + auto it = g_ams_caps.find(dev_id); + return it != g_ams_caps.end() && it->second.has_ams; } void build_ams_payload_for_device(const std::string& dev_id, diff --git a/src/slic3r/Utils/AmsPayload.hpp b/src/slic3r/Utils/AmsPayload.hpp index 2ef99c3289..9d4411121d 100644 --- a/src/slic3r/Utils/AmsPayload.hpp +++ b/src/slic3r/Utils/AmsPayload.hpp @@ -39,6 +39,26 @@ bool parse_moonraker_lane_data(const nlohmann::json& body, std::vector& trays, int& max_lane_index); +// Outcome of one lane_data read. synced/none/unknown mirror OrcaSonar's +// REQ-STS-007 tri-state; error covers transport failure and unparsable bodies. +enum class LaneDataFetch { synced, none, unknown, error }; + +// Read and parse the Moonraker `lane_data` namespace from origin (OrcaSonar's +// façade or a real Moonraker — both emit the same shape). A non-empty api_key is +// sent as X-Api-Key. trays/max_lane_index are only populated on synced. The +// tri-state is preserved: 404 is unknown, an empty value object is none. +LaneDataFetch read_moonraker_lane_data(const std::string& origin, + const std::string& api_key, + std::vector& trays, + int& max_lane_index); + +// AMS units a flat lane range renders into (4 lanes per unit, rounding up); +// 0 when there are no lanes. max_lane_index is the highest index, or -1. +inline int ams_count_for_lanes(int max_lane_index) +{ + return max_lane_index < 0 ? 0 : (max_lane_index + 4) / 4; +} + // Fill each tray's tray_info_idx from the loaded preset bundle (falling back to // the generic family map). Reads GUI preset state, so it MUST run on the main // thread. Ids already set by a vendor-aware resolver are left untouched. @@ -76,9 +96,10 @@ void build_ams_payload_for_device(const std::string& dev_id, void clear_ams_payload_for_device(const std::string& dev_id, const QueueOnMainFn& queue_fn); // Process-wide canonical AMS write capability (OrcaSonar REQ-STS-008), parsed -// from the info.get_capabilities reply. Devices with no record (Bambu, cloud -// profiles) report every op supported: gating only ever applies to OrcaSonar -// printers that answered. +// 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. 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); diff --git a/src/slic3r/Utils/MoonrakerPrinterAgent.cpp b/src/slic3r/Utils/MoonrakerPrinterAgent.cpp index d0c07ae3c9..b0af222de8 100644 --- a/src/slic3r/Utils/MoonrakerPrinterAgent.cpp +++ b/src/slic3r/Utils/MoonrakerPrinterAgent.cpp @@ -564,29 +564,20 @@ bool MoonrakerPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSync std::vector trays; int max_lane_index = 0; - // Try Moonraker filament data (more generic, supports any filament changer - // software that reports lane data to Moonraker like AFC and recent Happy - // Hare as of Feb 15, 2026) - if (fetch_moonraker_filament_data(trays, max_lane_index)) { - BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::fetch_filament_info: Detected Moonraker filament system with " - << (max_lane_index + 1) << " lanes"; - int ams_count = (max_lane_index + 4) / 4; - build_ams_payload(ams_count, max_lane_index, trays); - return true; + // Try Moonraker lane data first (generic; covers AFC and recent Happy Hare), + // then the Happy Hare object query. + const bool moonraker_lanes = fetch_moonraker_filament_data(trays, max_lane_index); + const bool happy_hare = !moonraker_lanes && fetch_hh_filament_info(trays, max_lane_index); + if (!moonraker_lanes && !happy_hare) { + // No MMU detected - normal for printers without one, not an error. + BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::fetch_filament_info: No MMU system detected (neither HH nor Moonraker)"; + return false; } - - // Attempt Happy Hare first (more widely adopted, supports more filament changers) - if (fetch_hh_filament_info(trays, max_lane_index)) { - BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::fetch_filament_info: Detected Happy Hare MMU with " - << (max_lane_index + 1) << " gates"; - int ams_count = (max_lane_index + 4) / 4; - build_ams_payload(ams_count, max_lane_index, trays); - return true; - } - - // No MMU detected - this is normal for printers without MMU, not an error - BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::fetch_filament_info: No MMU system detected (neither HH nor Moonraker)"; - return false; + BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::fetch_filament_info: Detected " + << (moonraker_lanes ? "Moonraker filament system" : "Happy Hare MMU") << " with " + << (max_lane_index + 1) << (moonraker_lanes ? " lanes" : " gates"); + build_ams_payload(ams_count_for_lanes(max_lane_index), max_lane_index, trays); + return true; } CameraStreamMode MoonrakerPrinterAgent::get_camera_stream_mode() const @@ -667,54 +658,12 @@ int MoonrakerPrinterAgent::safe_array_int(const nlohmann::json& arr, int idx) // Fetch filament info from moonraker database bool MoonrakerPrinterAgent::fetch_moonraker_filament_data(std::vector& trays, int& max_lane_index) { - // Fetch lane data from Moonraker database - std::string url = join_url(device_info.base_url, "/server/database/item?namespace=lane_data"); - - std::string response_body; - bool success = false; - std::string http_error; - - auto http = Http::get(url); - if (!device_info.api_key.empty()) { - http.header("X-Api-Key", device_info.api_key); - } - http.timeout_connect(5) - .timeout_max(10) - .on_complete([&](std::string body, unsigned status) { - if (status == 200) { - response_body = body; - success = true; - } else { - http_error = "HTTP error: " + std::to_string(status); - } - }) - .on_error([&](std::string body, std::string err, unsigned status) { - http_error = err; - if (status > 0) { - http_error += " (HTTP " + std::to_string(status) + ")"; - } - }) - .perform_sync(); - - if (!success) { - BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent::fetch_moonraker_filament_data: Failed to fetch lane data: " << http_error; - return false; - } - - auto json = nlohmann::json::parse(response_body, nullptr, false, true); - if (json.is_discarded()) { - BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent::fetch_moonraker_filament_data: Invalid JSON response"; - return false; - } - - // Expected structure: { "result": { "namespace": "lane_data", "value": { "lane1": {...}, ... } } } - if (!parse_moonraker_lane_data(json, trays, max_lane_index)) { - return false; - } - // tray_info_idx is resolved later, on the main thread, inside - // build_ams_payload_for_device. - - return true; + // Shared with OrcaPrinterAgent's lane_data read; only the synced outcome + // matters here, since a missing namespace or an empty one both fall through + // to the Happy Hare query. tray_info_idx is resolved later, on the main + // thread, inside build_ams_payload_for_device. + return read_moonraker_lane_data(device_info.base_url, device_info.api_key, trays, max_lane_index) == + LaneDataFetch::synced; } // Fetch filament info from Happy Hare MMU diff --git a/src/slic3r/Utils/OrcaPrinterAgent.cpp b/src/slic3r/Utils/OrcaPrinterAgent.cpp index 8466e58b42..00edf7b902 100644 --- a/src/slic3r/Utils/OrcaPrinterAgent.cpp +++ b/src/slic3r/Utils/OrcaPrinterAgent.cpp @@ -595,42 +595,6 @@ std::string OrcaPrinterAgent::merge_capabilities(const std::string& dev_id, cons std::lock_guard l(nozzle_diameter_cache_mutex); nozzle_diameter_cache[dev_id] = nozzle_dia; } - // The capabilities reply itself is forwarded unchanged. Its declared - // ams_ops (OPCP §7.8) gate every AMS control, and protocol.features.fms - // declares whether a material system actually exists. - { - const auto caps_it = info_it->find("capabilities"); - if (caps_it != info_it->end() && caps_it->is_object()) { - const auto proto_it = caps_it->find("protocol"); - if (proto_it != caps_it->end() && proto_it->is_object()) { - const auto ops_it = proto_it->find("ams_ops"); - std::vector ops; - if (ops_it != proto_it->end() && ops_it->is_array()) { - for (const auto& op : *ops_it) - if (op.is_string()) - ops.push_back(op.get()); - register_ams_ops(dev_id, ops); - } - - // features.fms is the authoritative "has a material system" - // flag (topology.material_units non-empty). Payloads without - // it fall back to a non-empty ams_ops. - bool has_ams = false; - bool fms_known = false; - const auto features_it = proto_it->find("features"); - if (features_it != proto_it->end() && features_it->is_object()) { - const auto fms_it = features_it->find("fms"); - if (fms_it != features_it->end() && fms_it->is_boolean()) { - has_ams = fms_it->get(); - fms_known = true; - } - } - if (!fms_known) - has_ams = !ops.empty(); - register_ams_capability(dev_id, has_ams); - } - } - } } else { const auto print_it = envelope.find("print"); if (print_it != envelope.end() && print_it->is_object() && print_it->value("command", "") == "push_status" && @@ -654,6 +618,57 @@ std::string OrcaPrinterAgent::merge_capabilities(const std::string& dev_id, cons return modified ? envelope.dump() : payload; } +// One get_capabilities reply's AMS declaration. Not a dialect shim: it feeds +// get_filament_sync_mode() and the ams_ops write gate, so it must outlive +// merge_capabilities. +void OrcaPrinterAgent::register_ams_capabilities(const std::string& dev_id, const std::string& payload) +{ + if (dev_id.empty() || payload.find("get_capabilities") == std::string::npos) + return; + + const nlohmann::json envelope = nlohmann::json::parse(payload, nullptr, false); + if (!envelope.is_object()) + return; + const auto info_it = envelope.find("info"); + if (info_it == envelope.end() || !info_it->is_object() || info_it->value("command", "") != "get_capabilities") + return; + const auto caps_it = info_it->find("capabilities"); + if (caps_it == info_it->end() || !caps_it->is_object()) + return; + const auto proto_it = caps_it->find("protocol"); + if (proto_it == caps_it->end() || !proto_it->is_object()) + return; + + // ams_ops is the write-op union the resolved drivers implement. Register it + // on every reply — empty when the key is absent — so "answered without ops" + // gates every AMS write (OPCP §7.8) and a later reply clears stale ops. + std::vector ops; + const auto ops_it = proto_it->find("ams_ops"); + if (ops_it != proto_it->end() && ops_it->is_array()) { + for (const auto& op : *ops_it) + if (op.is_string()) + ops.push_back(op.get()); + } + register_ams_ops(dev_id, ops); + + // features.fms is the authoritative "has a material system" flag + // (topology.material_units non-empty). Payloads without it fall back to a + // non-empty ams_ops. + bool has_ams = false; + bool fms_known = false; + const auto features_it = proto_it->find("features"); + if (features_it != proto_it->end() && features_it->is_object()) { + const auto fms_it = features_it->find("fms"); + if (fms_it != features_it->end() && fms_it->is_boolean()) { + has_ams = fms_it->get(); + fms_known = true; + } + } + if (!fms_known) + has_ams = !ops.empty(); + register_ams_capability(dev_id, has_ams); +} + void OrcaPrinterAgent::deliver_to_sink(const std::string& dev_id, const std::string& payload, bool local) { // Subscription doorbell, on the raw payload before the UI marshal so the @@ -662,6 +677,7 @@ void OrcaPrinterAgent::deliver_to_sink(const std::string& dev_id, const std::str request_filament_refresh(dev_id); parse_ipcam_info(dev_id, payload); + register_ams_capabilities(dev_id, payload); std::string merged_payload = merge_capabilities(dev_id, payload); OnMessageFn fn; @@ -776,14 +792,6 @@ int OrcaPrinterAgent::command_ams_refresh_rfid(std::string dev_id, std::string t return route_send(lan_mode, dev_id, j.dump()); } -int OrcaPrinterAgent::command_ams_calibrate(std::string /*dev_id*/, int /*ams_id*/, int /*sequence_id*/, bool /*lan_mode*/) -{ - // OrcaSonar has no ams_calibrate command. Do not send the Bambu M620 C - // dialect through the vendor-neutral OrcaSonar gcode_line command. - BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: AMS calibration is not part of the OrcaSonar API"; - return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED; -} - int OrcaPrinterAgent::command_ams_select_tray(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode) { int tray_number = 0; @@ -801,46 +809,6 @@ int OrcaPrinterAgent::command_ams_select_tray(std::string dev_id, std::string tr return route_send(lan_mode, dev_id, j.dump()); } -int OrcaPrinterAgent::command_start_camera(std::string /*dev_id*/) -{ - // OrcaSonar exposes camera.ipcam_* controls, not the legacy start_camera - // operation used by the Bambu agent. - BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: camera start is not part of the OrcaSonar API"; - return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED; -} - -int OrcaPrinterAgent::command_xyz_abs(std::string dev_id, int sequence_id, bool lan_mode) -{ - nlohmann::json j; - j["print"]["command"] = "gcode_line"; - j["print"]["param"] = "G90\n"; - j["print"]["sequence_id"] = std::to_string(sequence_id); - return route_send(lan_mode, dev_id, j.dump()); -} - -int OrcaPrinterAgent::command_auto_leveling(std::string dev_id, int sequence_id, bool lan_mode) -{ - nlohmann::json j; - j["print"]["command"] = "gcode_line"; - j["print"]["param"] = "G29\n"; - j["print"]["sequence_id"] = std::to_string(sequence_id); - return route_send(lan_mode, dev_id, j.dump()); -} - -int OrcaPrinterAgent::command_go_home(std::string dev_id, bool is_printing, bool supports_mqtt_homing, int sequence_id, bool lan_mode) -{ - nlohmann::json j; - j["print"]["sequence_id"] = std::to_string(sequence_id); - if (supports_mqtt_homing) { - j["print"]["command"] = "back_to_center"; - } else { - // Preserve the existing safety behavior: never home Z/Y during a print. - j["print"]["command"] = "gcode_line"; - j["print"]["param"] = is_printing ? "G28 X\n" : "G28\n"; - } - return route_send(lan_mode, dev_id, j.dump()); -} - int OrcaPrinterAgent::command_set_bed(std::string dev_id, int temp, bool /*supports_mqtt_bed_ctrl*/, int sequence_id, bool lan_mode) { nlohmann::json j; @@ -908,13 +876,6 @@ FilamentSyncMode OrcaPrinterAgent::get_filament_sync_mode() const return FilamentSyncMode::none; } -bool OrcaPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode) -{ - if (sync_mode != get_filament_sync_mode()) - return false; - return fetch_lane_data(dev_id) == LaneDataState::synced; -} - // Read the lane_data projection once and apply the REQ-STS-007 tri-state to // DevFilaSystem. Only LaneDataState::error arms the retry latch: 404 means // "not knowable yet" (pre-bootstrap or acknowledged-unknown topology) and {} @@ -939,69 +900,28 @@ OrcaPrinterAgent::LaneDataState OrcaPrinterAgent::fetch_lane_data(const std::str // Moonraker-compatible façade. Called on the refresh worker (subscription // mode), so the payload mutation is marshalled onto the main thread through // queue_fn; a GUI-thread caller reads DevFilaSystem inline. - const std::string url = origin + "/server/database/item?namespace=lane_data"; - - std::string response_body; - unsigned http_status = 0; - std::string http_error; - auto http = Http::get(url); - if (!api_key.empty()) - http.header("X-Api-Key", api_key); - http.timeout_connect(5) - .timeout_max(10) - .on_complete([&](std::string body, unsigned status) { - http_status = status; - if (status == 200) { - response_body = std::move(body); - } else { - http_error = "HTTP error: " + std::to_string(status); - } - }) - .on_error([&](std::string, std::string err, unsigned status) { - http_status = status; - http_error = err; - if (status > 0) - http_error += " (HTTP " + std::to_string(status) + ")"; - }) - .perform_sync(); - - // Http routes every >=400 response through on_error(), so a 404 arrives here - // with an empty err and the status in http_status. It is a real HTTP - // response, not a transport failure: the namespace is not served / topology - // unknown (REQ-STS-007 §7.7). Never latch a retry for it. - if (http_status == 404) { - BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::fetch_lane_data: lane_data not served yet (unknown topology)"; - return LaneDataState::unknown; - } - if (http_status != 200) { - BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::fetch_lane_data: lane_data fetch failed: " << http_error; - return LaneDataState::error; - } - - auto json = nlohmann::json::parse(response_body, nullptr, false, true); - if (json.is_discarded()) { - BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent::fetch_lane_data: invalid lane_data JSON"; - return LaneDataState::error; - } - const bool empty_value = json.is_object() && json.contains("result") && json["result"].is_object() && - json["result"].contains("value") && json["result"]["value"].is_object() && json["result"]["value"].empty(); - if (empty_value) { + std::vector trays; + int max_lane_index = 0; + switch (read_moonraker_lane_data(origin, api_key, trays, max_lane_index)) { + case LaneDataFetch::synced: + break; + case LaneDataFetch::none: // Authoritative empty: flush stale trays so a removed AMS does not // linger in the device panel. clear_ams_payload_for_device(dev_id, queue_fn); return LaneDataState::none; + case LaneDataFetch::unknown: + // 404: not knowable yet (pre-bootstrap or acknowledged-unknown topology). + // Never latched; the next doorbell or reconnect retries. + return LaneDataState::unknown; + case LaneDataFetch::error: + return LaneDataState::error; } - std::vector trays; - int max_lane_index = 0; - if (!parse_moonraker_lane_data(json, trays, max_lane_index)) - return LaneDataState::error; - - const int ams_count = (max_lane_index + 4) / 4; // printer_type stays unset: push_status already carries the OrcaSonar printer // type, and overwriting it here would clear it. build_ams_payload_for_device // marshals the DevFilaSystem mutation through queue_fn when set. - build_ams_payload_for_device(dev_id, std::nullopt, ams_count, max_lane_index, trays, queue_fn); + build_ams_payload_for_device(dev_id, std::nullopt, ams_count_for_lanes(max_lane_index), max_lane_index, trays, queue_fn); return LaneDataState::synced; } diff --git a/src/slic3r/Utils/OrcaPrinterAgent.hpp b/src/slic3r/Utils/OrcaPrinterAgent.hpp index 2e98e99f4e..432ef25aba 100644 --- a/src/slic3r/Utils/OrcaPrinterAgent.hpp +++ b/src/slic3r/Utils/OrcaPrinterAgent.hpp @@ -80,12 +80,7 @@ public: int set_queue_on_main_fn(QueueOnMainFn fn) override; int command_ams_refresh_rfid(std::string dev_id, std::string tray_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; - int command_start_camera(std::string dev_id) override; - int command_xyz_abs(std::string dev_id, int sequence_id, bool lan_mode) override; - int command_auto_leveling(std::string dev_id, int sequence_id, bool lan_mode) override; - int command_go_home(std::string dev_id, bool is_printing, bool supports_mqtt_homing, int sequence_id, bool lan_mode) override; int command_set_bed(std::string dev_id, int temp, bool supports_mqtt_bed_ctrl, int sequence_id, bool lan_mode) override; int command_set_nozzle(std::string dev_id, int temp, int sequence_id, bool lan_mode) override; int command_axis_control(std::string dev_id, @@ -104,7 +99,6 @@ public: // topology_state doorbell that keep DevFilaSystem fresh are LAN-only, and // cloud printers get their AMS view from the mirrored push_status. FilamentSyncMode get_filament_sync_mode() const override; - bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override; // Test-only: drive emit_connect_sequence directly (no socket). void run_connect_sequence_for_test(const std::string& dev_id) @@ -114,8 +108,6 @@ public: // Test-only: advance the LAN connection epoch without a connect/disconnect cycle. void bump_lan_generation_for_test() { ++m_lan_generation; } - // Test-only: the same for the (independent) cloud selection epoch. - void bump_cloud_generation_for_test() { ++m_cloud_generation; } // Test-only: observe the subscription doorbell policy without spawning the refresh worker. bool filament_doorbell_needed_for_test(const std::string& dev_id, const std::string& payload) @@ -149,6 +141,12 @@ protected: // per-rule detail. Returns the payload unchanged when no rule applies. std::string merge_capabilities(const std::string& dev_id, const std::string& payload); + // Register one get_capabilities reply's AMS declaration: ams_ops (empty when + // the reply omits it, so "answered without ops" gates every write) and + // protocol.features.fms. Kept separate from merge_capabilities so dropping + // that shim cannot silently drop the capability registry. + void register_ams_capabilities(const std::string& dev_id, const std::string& payload); + // Report the asynchronous LAN connection state using the same callback contract as // the other printer agents. The transport result cannot be returned by // connect_printer(), which only starts the worker. diff --git a/tests/slic3rutils/test_orca_printer_agent.cpp b/tests/slic3rutils/test_orca_printer_agent.cpp index 15378ae799..a2746f34ac 100644 --- a/tests/slic3rutils/test_orca_printer_agent.cpp +++ b/tests/slic3rutils/test_orca_printer_agent.cpp @@ -133,16 +133,28 @@ TEST_CASE("filament sync follows the printer's AMS capability", "[OrcaPrinterAge /*local=*/true); CHECK(agent.get_filament_sync_mode() == Slic3r::FilamentSyncMode::subscription); - // The pull contract (Sidebar's blocking path) is gone: a pull-mode fetch is - // refused by the mode guard, and a subscription fetch for a device that is - // not the active printer is refused before any HTTP. - CHECK_FALSE(agent.fetch_filament_info("dev-ams-1", Slic3r::FilamentSyncMode::pull)); - CHECK_FALSE(agent.fetch_filament_info("dev-ams-2", Slic3r::FilamentSyncMode::subscription)); - agent.disconnect_printer(); CHECK(agent.get_filament_sync_mode() == Slic3r::FilamentSyncMode::none); } +// OrcaSonar's contract: an absent ams_ops key means "no AMS controls" (app.go +// omits it when no driver declares a write op; Qidi is the shipped example). +// Answering get_capabilities without it must gate every AMS write client-side, +// not fall back to the base default's "no record -> never gate". +TEST_CASE("a capability reply without ams_ops gates AMS writes", "[OrcaPrinterAgent]") { + Probe agent("/tmp"); + agent.deliver_to_sink("dev-noops", + R"({"info":{"command":"get_capabilities","capabilities":{"protocol":{"features":{"fms":true}}}}})", + /*local=*/true); + + bool unsupported = false; + OrcaPrinterAgent::canonicalize_ams_payload( + "dev-noops", + R"({"print":{"command":"ams_change_filament","target":0,"slot_id":0,"ams_id":0}})", + &unsupported); + CHECK(unsupported); +} + // The subscription refresh is self-triggered: a pushed frame whose print block // carries a CHANGED topology_state.material_hash (spec REQ-STS-007 §7.7) is // the doorbell; repeat hashes, temperature-only frames and hash-less blocks diff --git a/tests/slic3rutils/test_printer_agent.cpp b/tests/slic3rutils/test_printer_agent.cpp index 655534e26f..f7263ad4fb 100644 --- a/tests/slic3rutils/test_printer_agent.cpp +++ b/tests/slic3rutils/test_printer_agent.cpp @@ -184,6 +184,19 @@ TEST_CASE("unit: AMS payload sets exist bits beyond 31 lanes", "[unit][moonraker CHECK_FALSE(u8[1].contains("tray_slot_placeholder")); // slot 33 present } +// why: a flat lane range renders into 4-slot AMS units; both agents and the +// builder share this one formula so they cannot drift. +TEST_CASE("unit: AMS unit count rounds lane ranges up to 4-slot units", "[unit][moonraker]") +{ + CHECK(ams_count_for_lanes(-1) == 0); + CHECK(ams_count_for_lanes(0) == 1); + CHECK(ams_count_for_lanes(3) == 1); + CHECK(ams_count_for_lanes(4) == 2); + CHECK(ams_count_for_lanes(6) == 2); + CHECK(ams_count_for_lanes(7) == 2); + CHECK(ams_count_for_lanes(8) == 3); +} + // why: the sync mode keys off the printer's declared material system; a device // with no capability record must never read as AMS-capable. TEST_CASE("unit: AMS capability registry reports only declared material systems", "[unit][moonraker]")