From f6086984393c74a493ce1a64f555a5ff90472a0e Mon Sep 17 00:00:00 2001 From: Lam Wei Lun Date: Tue, 6 Oct 2026 18:25:16 +0800 Subject: [PATCH] Initial Commit for switching files operations for OrcaPrinterAgent to using MQTT --- src/slic3r/Utils/OrcaPrinterAgent.cpp | 926 +++++++++++++++--- src/slic3r/Utils/OrcaPrinterAgent.hpp | 82 +- tests/slic3rutils/test_orca_printer_agent.cpp | 402 ++++++++ 3 files changed, 1259 insertions(+), 151 deletions(-) diff --git a/src/slic3r/Utils/OrcaPrinterAgent.cpp b/src/slic3r/Utils/OrcaPrinterAgent.cpp index 4e2a82a6b4..4f58444b90 100644 --- a/src/slic3r/Utils/OrcaPrinterAgent.cpp +++ b/src/slic3r/Utils/OrcaPrinterAgent.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -36,7 +37,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -60,6 +63,7 @@ namespace fs = boost::filesystem; struct OrcaDeviceCapabilities { + bool known = false; // a get_capabilities reply has been seen for this device bool fms = false; bool filament_slots = false; bool filament_mapping = false; @@ -168,6 +172,162 @@ std::string next_gcode_file_sequence_id() return std::to_string(counter.fetch_add(1, std::memory_order_relaxed)); } +// ============================================================================ +// Pending OPCP files.* requests. +// +// A detached file worker registers a slot keyed by sequence_id, publishes the +// request, then waits on the slot. The MQTT inbound handler fills and wakes it. +// Process-wide (like the capability cache) so the worker never captures the +// agent; sequence ids are unique per request. +// ============================================================================ +struct PendingFileReply +{ + std::string dev_id; + bool done = false; + bool failed = false; + std::string body; + std::condition_variable cv; +}; + +std::mutex g_file_replies_mutex; +std::map> g_file_replies; + +std::shared_ptr register_file_reply(const std::string& sequence_id, const std::string& dev_id) +{ + auto slot = std::make_shared(); + slot->dev_id = dev_id; + std::lock_guard lock(g_file_replies_mutex); + g_file_replies[sequence_id] = slot; + return slot; +} + +void unregister_file_reply(const std::string& sequence_id, const std::shared_ptr& slot) +{ + std::lock_guard lock(g_file_replies_mutex); + const auto it = g_file_replies.find(sequence_id); + if (it != g_file_replies.end() && it->second == slot) + g_file_replies.erase(it); +} + +bool wait_file_reply(const std::shared_ptr& slot, std::chrono::milliseconds timeout) +{ + std::unique_lock lock(g_file_replies_mutex); + return slot->cv.wait_for(lock, timeout, [&slot] { return slot->done; }); +} + +void mark_file_replies_failed(const std::string& dev_id) +{ + std::vector> slots; + { + std::lock_guard lock(g_file_replies_mutex); + for (auto it = g_file_replies.begin(); it != g_file_replies.end();) { + if (it->second->dev_id == dev_id) { + it->second->done = true; + it->second->failed = true; + slots.push_back(it->second); + it = g_file_replies.erase(it); + } else { + ++it; + } + } + } + for (const auto& slot : slots) + slot->cv.notify_all(); +} + +// Register, publish and wait for one files.* request. Returns true with `body` +// set when a reply arrived before `timeout`. +bool send_and_wait_files(const std::function& send, + const std::string& dev_id, + const std::string& sequence_id, + const std::string& payload, + std::chrono::milliseconds timeout, + std::string& body) +{ + const auto slot = register_file_reply(sequence_id, dev_id); + if (send(payload) != BAMBU_NETWORK_SUCCESS) { + unregister_file_reply(sequence_id, slot); + return false; + } + if (!wait_file_reply(slot, timeout)) { + unregister_file_reply(sequence_id, slot); + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: files request timed out seq=" << sequence_id << " dev_id=" << dev_id; + return false; + } + if (slot->failed) { + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: files request aborted seq=" << sequence_id << " dev_id=" << dev_id; + return false; + } + body = std::move(slot->body); + return true; +} + +// True when a files.* reply reports success (or carries no result/errno at all). +bool opcp_files_reply_ok(const std::string& body) +{ + const nlohmann::json envelope = nlohmann::json::parse(body, nullptr, false); + if (envelope.is_discarded() || !envelope.is_object()) + return false; + const auto files_it = envelope.find("files"); + if (files_it == envelope.end() || !files_it->is_object()) + return false; + const auto result_it = files_it->find("result"); + if (result_it != files_it->end()) { + if (result_it->is_string() && result_it->get() != "success") + return false; + if (result_it->is_number() && result_it->get() != 0) + return false; + } + const auto errno_it = files_it->find("errno"); + if (errno_it != files_it->end() && errno_it->is_number() && errno_it->get() != 0) + return false; + return true; +} + +// Decode standard base64 (the OPCP thumbnail payload) to raw bytes. Trims +// surrounding whitespace and rejects anything Beast's decoded_size() contract +// cannot size (length not a multiple of four) or that is not the alphabet. +std::string decode_base64(const std::string& input) +{ + const auto first = input.find_first_not_of(" \t\r\n"); + if (first == std::string::npos) + return {}; + const auto last = input.find_last_not_of(" \t\r\n"); + const std::string trimmed = input.substr(first, last - first + 1); + + // Unpadded input overruns the decoded_size() allocation, so reject it. + if (trimmed.size() % 4 != 0) + return {}; + + bool padding_seen = false; + for (std::size_t i = 0; i < trimmed.size(); ++i) { + const char c = trimmed[i]; + if (c == '=') { + if (i + 2 < trimmed.size()) // '=' may only close the final group + return {}; + padding_seen = true; + continue; + } + if (padding_seen) // a data character after padding + return {}; + const bool alphabet = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == '+' || c == '/'; + if (!alphabet) + return {}; + } + + std::string out; + out.resize(boost::beast::detail::base64::decoded_size(trimmed.size())); + const auto decoded = boost::beast::detail::base64::decode(out.data(), trimmed.data(), trimmed.size()); + out.resize(decoded.first); + return out; +} + +// Bounds for the directory walk: depth, directories visited, entries returned. +constexpr int kFileWalkMaxDepth = 8; +constexpr std::size_t kFileWalkMaxDirs = 512; +constexpr std::size_t kFileWalkMaxEntries = 4096; + static constexpr const char* ORCASONAR_FALLBACK = "orcasonar"; bool fetch_orcasonar_body(const std::string& url, std::string& body) @@ -702,6 +862,7 @@ std::string OrcaPrinterAgent::merge_capabilities(const std::string& dev_id, cons parse_commands(*protocol_commands); } } + device_capabilities.known = true; { std::lock_guard lock(g_capabilities_mutex); g_capabilities[dev_id] = std::move(device_capabilities); @@ -736,12 +897,64 @@ void OrcaPrinterAgent::forget_device_capabilities(const std::string& dev_id) { if (dev_id.empty()) return; + fail_pending_file_requests(dev_id); std::lock_guard lock(g_capabilities_mutex); g_capabilities.erase(dev_id); } +void OrcaPrinterAgent::fail_pending_file_requests(const std::string& dev_id) +{ + if (dev_id.empty()) + return; + mark_file_replies_failed(dev_id); +} + +bool OrcaPrinterAgent::handle_files_reply(const std::string& dev_id, const std::string& payload) +{ + if (payload.find("\"files\"") == std::string::npos) + return false; + + const nlohmann::json envelope = nlohmann::json::parse(payload, nullptr, false); + if (envelope.is_discarded() || !envelope.is_object()) + return false; + const auto files_it = envelope.find("files"); + if (files_it == envelope.end() || !files_it->is_object()) + return false; + + // Any files payload is ours; it must not reach MachineObject::parse_json. + const auto sequence_it = files_it->find("sequence_id"); + if (sequence_it == files_it->end() || !sequence_it->is_string()) { + BOOST_LOG_TRIVIAL(debug) << "OrcaPrinterAgent: dropping files report without sequence_id dev_id=" << dev_id; + return true; + } + const std::string sequence_id = sequence_it->get(); + + std::shared_ptr slot; + { + std::lock_guard lock(g_file_replies_mutex); + const auto it = g_file_replies.find(sequence_id); + if (it != g_file_replies.end() && it->second->dev_id == dev_id) { + slot = it->second; + slot->done = true; + slot->body = payload; + g_file_replies.erase(it); + } + } + if (!slot) { + BOOST_LOG_TRIVIAL(debug) << "OrcaPrinterAgent: dropping unmatched files report seq=" << sequence_id + << " dev_id=" << dev_id; + return true; + } + slot->cv.notify_all(); + return true; +} + void OrcaPrinterAgent::deliver_to_sink(const std::string& dev_id, const std::string& payload, bool local) { + // files.* replies belong to the pending-request mechanism, not the GUI parser. + if (handle_files_reply(dev_id, payload)) + return; + parse_ipcam_info(dev_id, payload); std::string merged_payload = merge_capabilities(dev_id, payload); @@ -847,7 +1060,7 @@ bool OrcaPrinterAgent::supports_feature(const std::string& dev_id, const std::st } int OrcaPrinterAgent::prepare_outgoing_request(const std::string& dev_id, const std::string& payload, - std::string& command, std::string& prepared) const + std::string& command, std::string& prepared) { command = ""; prepared = payload; @@ -869,7 +1082,7 @@ int OrcaPrinterAgent::prepare_outgoing_request(const std::string& dev_id, const break; } - if (!supports_command(dev_id, command)) + if (!command_supported(capabilities_for(dev_id), command)) return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED; if (command == "print.ams_filament_setting" && print && print->contains("setting_id")) { @@ -1218,7 +1431,7 @@ int OrcaPrinterAgent::connect_printer(const PrinterConnectionParams& params) m_camera_stream_mode = CameraStreamMode::none; m_camera_url.clear(); m_current_connection = LAN; - lan_mqtt_connection = std::make_unique(); + lan_mqtt_connection = std::make_shared(); conn = lan_mqtt_connection.get(); } BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: selected LAN printer dev_id=" << params.dev_id @@ -1279,7 +1492,7 @@ int OrcaPrinterAgent::disconnect_printer() { BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: disconnect_printer requested"; ++m_lan_generation; // fence stale worker callbacks - std::unique_ptr doomed; + std::shared_ptr doomed; std::string prev_dev; CurrentConn previous_connection; CurrentConn current_connection; @@ -1322,6 +1535,11 @@ int OrcaPrinterAgent::send_message_to_printer(std::string dev_id, std::string js { return route_send(/*is_lan=*/true, dev_id, json_str); } int OrcaPrinterAgent::route_send(bool is_lan, const std::string& dev_id, const std::string& json_str) +{ + return route_send_via(is_lan, get_appropriate_mqtt_connection(is_lan), dev_id, json_str); +} + +int OrcaPrinterAgent::route_send_via(bool is_lan, OrcaMqttConnection* conn, const std::string& dev_id, const std::string& json_str) { std::string command; std::string prepared; @@ -1334,7 +1552,6 @@ int OrcaPrinterAgent::route_send(bool is_lan, const std::string& dev_id, const s BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: refusing unsupported command " << command << " for dev_id=" << dev_id; return prepare_rc; } - OrcaMqttConnection* conn = get_appropriate_mqtt_connection(is_lan); if (!conn) return BAMBU_NETWORK_ERR_INVALID_HANDLE; const bool queued = conn->send_request(dev_id, prepared); @@ -1802,12 +2019,293 @@ std::vector OrcaPrinterAgent::parse_file_list(const std::strin return files; } +// ============================================================================ +// OPCP printer-file transport. +// ============================================================================ + +OrcaPrinterAgent::FileTransport OrcaPrinterAgent::preferred_file_transport(const std::string& dev_id) +{ + const OrcaDeviceCapabilities capabilities = capabilities_for(dev_id); + if (!capabilities.known) + return FileTransport::probe; + if (capabilities.supported_commands.count("files.list") != 0) + return FileTransport::opcp; + return FileTransport::http; +} + +std::string OrcaPrinterAgent::build_files_list_request(const std::string& sequence_id, const std::string& path) +{ + nlohmann::json request; + request["files"]["command"] = "list"; + request["files"]["sequence_id"] = sequence_id; + request["files"]["root"] = "gcodes"; + request["files"]["path"] = path; + return request.dump(); +} + +std::string OrcaPrinterAgent::build_files_metadata_request(const std::string& sequence_id, const std::string& path) +{ + nlohmann::json request; + request["files"]["command"] = "metadata"; + request["files"]["sequence_id"] = sequence_id; + request["files"]["root"] = "gcodes"; + request["files"]["path"] = path; + return request.dump(); +} + +std::string OrcaPrinterAgent::build_files_delete_request(const std::string& sequence_id, const std::string& path) +{ + nlohmann::json request; + request["files"]["command"] = "delete"; + request["files"]["sequence_id"] = sequence_id; + request["files"]["root"] = "gcodes"; + request["files"]["path"] = path; + return request.dump(); +} + +std::string OrcaPrinterAgent::build_files_thumbnail_request(const std::string& sequence_id, const std::string& path) +{ + nlohmann::json request; + request["files"]["command"] = "thumbnail"; + request["files"]["sequence_id"] = sequence_id; + request["files"]["root"] = "gcodes"; + request["files"]["path"] = path; + request["files"]["max_dimension"] = 0; + return request.dump(); +} + +bool OrcaPrinterAgent::file_reply_requires_http_fallback(const std::string& body) +{ + return !opcp_files_reply_ok(body); +} + +// Pure normalization of one OPCP files.list reply. Directories are returned +// separately so the walker can recurse; `success` is false for a failed reply +// (result != success or errno != 0), which makes the caller fall back to HTTP. +OrcaPrinterAgent::OpcpFileListPage OrcaPrinterAgent::parse_opcp_file_list(const std::string& body) +{ + OpcpFileListPage page; + + const nlohmann::json envelope = nlohmann::json::parse(body, nullptr, false); + if (envelope.is_discarded() || !envelope.is_object()) + return page; + const auto files_it = envelope.find("files"); + if (files_it == envelope.end() || !files_it->is_object()) + return page; + + page.success = opcp_files_reply_ok(body); + + const auto truncated_it = files_it->find("truncated"); + if (truncated_it != files_it->end() && truncated_it->is_boolean()) + page.truncated = truncated_it->get(); + + const auto entries_it = files_it->find("entries"); + if (entries_it == files_it->end() || !entries_it->is_array()) + return page; + + for (const auto& item : *entries_it) { + if (!item.is_object()) + continue; + const auto path_it = item.find("path"); + if (path_it == item.end() || !path_it->is_string()) + continue; + const std::string path = path_it->get(); + if (path.empty()) + continue; + + if (item.value("is_dir", false)) { + page.dirs.push_back(path); + continue; + } + + PrinterFileEntry entry; + entry.path = path; + const auto name_it = item.find("name"); + entry.name = (name_it != item.end() && name_it->is_string()) ? name_it->get() + : fs::path(path).filename().string(); + + const auto size_it = item.find("size"); + if (size_it != item.end() && size_it->is_number()) + entry.size = size_it->get(); + + const auto modified_it = item.find("modified"); + if (modified_it != item.end() && modified_it->is_number()) + entry.modified = static_cast(modified_it->get()); + + page.files.push_back(std::move(entry)); + } + return page; +} + +PrinterFileMetadata OrcaPrinterAgent::parse_opcp_file_metadata(const std::string& body) +{ + PrinterFileMetadata meta; + + const nlohmann::json envelope = nlohmann::json::parse(body, nullptr, false); + if (envelope.is_discarded() || !envelope.is_object()) + return meta; + const auto files_it = envelope.find("files"); + if (files_it == envelope.end() || !files_it->is_object()) + return meta; + + const nlohmann::json* fields = &*files_it; + const auto result_it = files_it->find("result"); + if (result_it != files_it->end() && result_it->is_object()) + fields = &*result_it; // tolerate a nested result object too + + const auto time_it = fields->find("estimated_time"); + if (time_it != fields->end() && time_it->is_number()) + meta.estimated_time = static_cast(time_it->get()); + + const auto total_it = fields->find("filament_total"); + if (total_it != fields->end() && total_it->is_number()) + meta.filament_total = total_it->get(); + + const auto weight_it = fields->find("filament_weight_total"); + if (weight_it != fields->end() && weight_it->is_number()) + meta.filament_weight = weight_it->get(); + + return meta; +} + +// Decode the base64 PNG payload. A reply without `data` means the file has no +// embedded thumbnail: success with an empty image. +std::string OrcaPrinterAgent::parse_opcp_thumbnail(const std::string& body) +{ + const nlohmann::json envelope = nlohmann::json::parse(body, nullptr, false); + if (envelope.is_discarded() || !envelope.is_object()) + return {}; + const auto files_it = envelope.find("files"); + if (files_it == envelope.end() || !files_it->is_object()) + return {}; + const auto data_it = files_it->find("data"); + if (data_it == files_it->end() || !data_it->is_string()) + return {}; + return decode_base64(data_it->get()); +} + +bool OrcaPrinterAgent::collect_opcp_file_entries(const std::function& send, + const std::string& dev_id, + std::chrono::milliseconds first_timeout, + std::chrono::milliseconds page_timeout, + std::vector& out, + bool& truncated) +{ + truncated = false; + + struct PendingDir { std::string path; int depth; }; + std::deque queue; + queue.push_back({"", 0}); + std::size_t dirs_seen = 0; + bool first = true; + + while (!queue.empty()) { + if (dirs_seen >= kFileWalkMaxDirs) { + truncated = true; + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: file walk hit the directory cap dev_id=" << dev_id; + break; + } + const PendingDir current = queue.front(); + queue.pop_front(); + ++dirs_seen; + + const std::string sequence_id = next_gcode_file_sequence_id(); + const std::string payload = build_files_list_request(sequence_id, current.path); + const auto timeout = first ? first_timeout : page_timeout; + first = false; + + std::string body; + if (!send_and_wait_files(send, dev_id, sequence_id, payload, timeout, body)) { + if (dirs_seen == 1) { + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: OPCP file listing unavailable dev_id=" << dev_id; + return false; // root failed: the caller may fall back to HTTP + } + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: OPCP file listing stopped at path=" << current.path; + truncated = true; + break; + } + + const OpcpFileListPage page = parse_opcp_file_list(body); + if (!page.success) { + if (dirs_seen == 1) { + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: OPCP file listing rejected dev_id=" << dev_id; + return false; + } + truncated = true; + break; + } + + for (const PrinterFileEntry& entry : page.files) { + if (out.size() >= kFileWalkMaxEntries) { + truncated = true; + break; + } + out.push_back(entry); + } + if (page.truncated) + truncated = true; + if (out.size() >= kFileWalkMaxEntries) { + truncated = true; + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: file walk hit the entry cap dev_id=" << dev_id; + break; + } + + if (current.depth + 1 <= kFileWalkMaxDepth) { + for (const std::string& dir : page.dirs) + queue.push_back({dir, current.depth + 1}); + } else if (!page.dirs.empty()) { + truncated = true; + } + } + return true; +} + +void OrcaPrinterAgent::list_printer_files_http(const std::string& origin, bool use_ssl, const std::string& ca_file, + QueueOnMainFn queue, PrinterFileListFn callback) +{ + std::string body; + int result = BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED; + + auto http = Http::get(origin + "/server/files/list?root=gcodes"); + http.tls_verify(use_ssl); + if (!ca_file.empty()) + http.ca_file(ca_file); + http.timeout_connect(5) + .timeout_max(15) + .on_complete([&](std::string b, unsigned status) { + if (status == 200) { + body = std::move(b); + result = BAMBU_NETWORK_SUCCESS; + } + }) + .on_error([&](std::string, std::string err, unsigned status) { + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: file list request failed status=" << status << " err=" << err; + }) + .perform_sync(); + + std::vector files; + if (result == BAMBU_NETWORK_SUCCESS) { + files = parse_file_list(body); + // Empty is a valid listing; an unparseable body is not. + if (nlohmann::json::parse(body, nullptr, false).is_discarded()) + result = BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED; + } + + if (!callback) + return; + if (queue) + queue([callback, result, files = std::move(files)]() mutable { callback(result, std::move(files)); }); + else + callback(result, std::move(files)); +} + int OrcaPrinterAgent::list_printer_files(const std::string& dev_id, PrinterFileListFn callback) { std::string origin; bool use_ssl = false; std::string ca_file; QueueOnMainFn queue; + std::shared_ptr conn; bool live = false; { std::lock_guard lock(state_mutex); @@ -1817,6 +2315,7 @@ int OrcaPrinterAgent::list_printer_files(const std::string& dev_id, PrinterFileL use_ssl = m_lan_use_ssl; ca_file = m_lan_ca_file; queue = queue_on_main_fn; + conn = lan_mqtt_connection; } } if (!live) { @@ -1824,43 +2323,47 @@ int OrcaPrinterAgent::list_printer_files(const std::string& dev_id, PrinterFileL callback(ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED, {}); return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED; } - if (origin.empty()) { - if (callback) - callback(BAMBU_NETWORK_ERR_INVALID_HANDLE, {}); - return BAMBU_NETWORK_ERR_INVALID_HANDLE; + + const FileTransport transport = preferred_file_transport(dev_id); + if (transport == FileTransport::http) { + if (origin.empty()) { + if (callback) + callback(BAMBU_NETWORK_ERR_INVALID_HANDLE, {}); + return BAMBU_NETWORK_ERR_INVALID_HANDLE; + } + std::thread([origin, use_ssl, ca_file, queue, callback = std::move(callback)]() mutable { + list_printer_files_http(origin, use_ssl, ca_file, queue, std::move(callback)); + }).detach(); + return BAMBU_NETWORK_SUCCESS; } - // perform_sync blocks, so the request runs off the UI thread. The worker captures - // only values (never `this`). A trusted LAN facade needs no API key. - std::thread([dev_id, origin, use_ssl, ca_file, queue, callback = std::move(callback)]() mutable { - std::string body; - int result = BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED; - - auto http = Http::get(origin + "/server/files/list?root=gcodes"); - http.tls_verify(use_ssl); - if (!ca_file.empty()) - http.ca_file(ca_file); - http.timeout_connect(5) - .timeout_max(15) - .on_complete([&](std::string b, unsigned status) { - if (status == 200) { - body = std::move(b); - result = BAMBU_NETWORK_SUCCESS; - } - }) - .on_error([&](std::string, std::string err, unsigned status) { - BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: file list request failed status=" << status << " err=" << err; - }) - .perform_sync(); + const auto first_timeout = std::chrono::milliseconds( + transport == FileTransport::probe ? m_file_probe_timeout_ms.load() : m_file_request_timeout_ms.load()); + const auto page_timeout = std::chrono::milliseconds(m_file_request_timeout_ms.load()); + // perform_sync/wait block, so the walk runs off the UI thread. The worker captures + // only values (never `this`); the shared connection outlives a disconnect. + std::thread([dev_id, origin, use_ssl, ca_file, queue, conn, first_timeout, page_timeout, + callback = std::move(callback)]() mutable { std::vector files; - if (result == BAMBU_NETWORK_SUCCESS) { - files = parse_file_list(body); - // Empty is a valid listing; an unparseable body is not. - if (nlohmann::json::parse(body, nullptr, false).is_discarded()) - result = BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED; + bool truncated = false; + bool root_ok = false; + if (conn) { + auto send = [conn, dev_id](const std::string& payload) { + return OrcaPrinterAgent::route_send_via(true, conn.get(), dev_id, payload); + }; + root_ok = collect_opcp_file_entries(send, dev_id, first_timeout, page_timeout, files, truncated); } + if (!root_ok && !origin.empty()) { + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: OPCP file list failed; falling back to HTTP dev_id=" << dev_id; + list_printer_files_http(origin, use_ssl, ca_file, queue, std::move(callback)); + return; + } + if (truncated) + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: file listing truncated dev_id=" << dev_id; + + const int result = root_ok ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED; if (!callback) return; if (queue) @@ -1953,15 +2456,81 @@ std::string OrcaPrinterAgent::encode_file_path(const std::string& path) return encoded; } -// Fetch one file's embedded thumbnail: first resolve its path via the thumbnails -// listing, then download the image bytes. Mirrors list_printer_files for the -// connection snapshot and off-thread marshalling; the worker captures no `this`. +// HTTP fallback: resolve the thumbnail path via the thumbnails listing, then +// download the image bytes. Static so the detached worker captures no `this`. +void OrcaPrinterAgent::get_printer_file_thumbnail_http(const std::string& path, const std::string& origin, bool use_ssl, + const std::string& ca_file, QueueOnMainFn queue, + PrinterFileThumbnailFn callback) +{ + std::string image; + int result = BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED; + + std::string thumb_body; + bool listed = false; + auto http = Http::get(origin + "/server/files/thumbnails?filename=" + Http::url_encode(path)); + http.tls_verify(use_ssl); + if (!ca_file.empty()) + http.ca_file(ca_file); + http.timeout_connect(5) + .timeout_max(15) + .on_complete([&](std::string b, unsigned status) { + if (status == 200) { + thumb_body = std::move(b); + listed = true; + } + }) + .on_error([&](std::string, std::string err, unsigned status) { + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: thumbnail list request failed status=" << status << " err=" << err; + }) + .perform_sync(); + + if (listed) { + if (nlohmann::json::parse(thumb_body, nullptr, false).is_discarded()) { + result = BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED; + } else { + const std::string thumb_path = parse_thumbnail_path(thumb_body); + if (thumb_path.empty()) { + // No embedded thumbnail: a success the caller caches. + result = BAMBU_NETWORK_SUCCESS; + } else { + // The returned path is relative to the gcodes root, served at + // /server/files/gcodes; an explicit gcodes/ prefix is served at /server/files. + const std::string root = thumb_path.rfind("gcodes/", 0) == 0 ? "/server/files/" : "/server/files/gcodes/"; + auto image_http = Http::get(origin + root + encode_file_path(thumb_path)); + image_http.tls_verify(use_ssl); + if (!ca_file.empty()) + image_http.ca_file(ca_file); + image_http.timeout_connect(5) + .timeout_max(15) + .on_complete([&](std::string b, unsigned status) { + if (status == 200) { + image = std::move(b); + result = BAMBU_NETWORK_SUCCESS; + } + }) + .on_error([&](std::string, std::string err, unsigned status) { + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: thumbnail fetch failed status=" << status << " err=" << err; + }) + .perform_sync(); + } + } + } + + if (!callback) + return; + if (queue) + queue([callback, result, image = std::move(image)]() mutable { callback(result, std::move(image)); }); + else + callback(result, std::move(image)); +} + int OrcaPrinterAgent::get_printer_file_thumbnail(const std::string& dev_id, const std::string& path, PrinterFileThumbnailFn callback) { std::string origin; bool use_ssl = false; std::string ca_file; QueueOnMainFn queue; + std::shared_ptr conn; bool live = false; { std::lock_guard lock(state_mutex); @@ -1971,6 +2540,7 @@ int OrcaPrinterAgent::get_printer_file_thumbnail(const std::string& dev_id, cons use_ssl = m_lan_use_ssl; ca_file = m_lan_ca_file; queue = queue_on_main_fn; + conn = lan_mqtt_connection; } } if (!live) { @@ -1978,67 +2548,44 @@ int OrcaPrinterAgent::get_printer_file_thumbnail(const std::string& dev_id, cons callback(ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED, {}); return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED; } - if (origin.empty()) { - if (callback) - callback(BAMBU_NETWORK_ERR_INVALID_HANDLE, {}); - return BAMBU_NETWORK_ERR_INVALID_HANDLE; + + const FileTransport transport = preferred_file_transport(dev_id); + if (transport == FileTransport::http) { + if (origin.empty()) { + if (callback) + callback(BAMBU_NETWORK_ERR_INVALID_HANDLE, {}); + return BAMBU_NETWORK_ERR_INVALID_HANDLE; + } + std::thread([path, origin, use_ssl, ca_file, queue, callback = std::move(callback)]() mutable { + get_printer_file_thumbnail_http(path, origin, use_ssl, ca_file, queue, std::move(callback)); + }).detach(); + return BAMBU_NETWORK_SUCCESS; } - std::thread([path, origin, use_ssl, ca_file, queue, callback = std::move(callback)]() mutable { + const auto timeout = std::chrono::milliseconds( + transport == FileTransport::probe ? m_file_probe_timeout_ms.load() : m_file_request_timeout_ms.load()); + std::thread([dev_id, path, origin, use_ssl, ca_file, queue, conn, timeout, callback = std::move(callback)]() mutable { + bool ok = false; std::string image; - int result = BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED; - - std::string thumb_body; - bool listed = false; - auto http = Http::get(origin + "/server/files/thumbnails?filename=" + Http::url_encode(path)); - http.tls_verify(use_ssl); - if (!ca_file.empty()) - http.ca_file(ca_file); - http.timeout_connect(5) - .timeout_max(15) - .on_complete([&](std::string b, unsigned status) { - if (status == 200) { - thumb_body = std::move(b); - listed = true; - } - }) - .on_error([&](std::string, std::string err, unsigned status) { - BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: thumbnail list request failed status=" << status << " err=" << err; - }) - .perform_sync(); - - if (listed) { - if (nlohmann::json::parse(thumb_body, nullptr, false).is_discarded()) { - result = BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED; - } else { - const std::string thumb_path = parse_thumbnail_path(thumb_body); - if (thumb_path.empty()) { - // No embedded thumbnail: a success the caller caches. - result = BAMBU_NETWORK_SUCCESS; - } else { - // The returned path is relative to the gcodes root, served at - // /server/files/gcodes; an explicit gcodes/ prefix is served at /server/files. - const std::string root = thumb_path.rfind("gcodes/", 0) == 0 ? "/server/files/" : "/server/files/gcodes/"; - auto image_http = Http::get(origin + root + encode_file_path(thumb_path)); - image_http.tls_verify(use_ssl); - if (!ca_file.empty()) - image_http.ca_file(ca_file); - image_http.timeout_connect(5) - .timeout_max(15) - .on_complete([&](std::string b, unsigned status) { - if (status == 200) { - image = std::move(b); - result = BAMBU_NETWORK_SUCCESS; - } - }) - .on_error([&](std::string, std::string err, unsigned status) { - BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: thumbnail fetch failed status=" << status << " err=" << err; - }) - .perform_sync(); - } + if (conn) { + auto send = [conn, dev_id](const std::string& payload) { + return OrcaPrinterAgent::route_send_via(true, conn.get(), dev_id, payload); + }; + const std::string sequence_id = next_gcode_file_sequence_id(); + const std::string payload = OrcaPrinterAgent::build_files_thumbnail_request(sequence_id, path); + std::string body; + if (send_and_wait_files(send, dev_id, sequence_id, payload, timeout, body)) { + ok = !OrcaPrinterAgent::file_reply_requires_http_fallback(body); + if (ok) + image = OrcaPrinterAgent::parse_opcp_thumbnail(body); } } + if (!ok && !origin.empty()) { + get_printer_file_thumbnail_http(path, origin, use_ssl, ca_file, queue, std::move(callback)); + return; + } + const int result = ok ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED; if (!callback) return; if (queue) @@ -2050,15 +2597,43 @@ int OrcaPrinterAgent::get_printer_file_thumbnail(const std::string& dev_id, cons return BAMBU_NETWORK_SUCCESS; } -// Delete one G-code file via Moonraker's HTTP DELETE endpoint. Mirrors -// list_printer_files for the connection snapshot and off-thread marshalling; the -// worker captures no `this`. +// HTTP fallback: delete via Moonraker's HTTP DELETE endpoint. Static so the +// detached worker captures no `this`. +void OrcaPrinterAgent::delete_printer_file_http(const std::string& path, const std::string& origin, bool use_ssl, + const std::string& ca_file, QueueOnMainFn queue, PrinterFileDeleteFn callback) +{ + int result = BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED; + + auto http = Http::del(origin + "/server/files/gcodes/" + encode_file_path(path)); + http.tls_verify(use_ssl); + if (!ca_file.empty()) + http.ca_file(ca_file); + http.timeout_connect(5) + .timeout_max(15) + .on_complete([&](std::string, unsigned status) { + if (status == 200) + result = BAMBU_NETWORK_SUCCESS; + }) + .on_error([&](std::string, std::string err, unsigned status) { + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: file delete request failed status=" << status << " err=" << err; + }) + .perform_sync(); + + if (!callback) + return; + if (queue) + queue([callback, result]() mutable { callback(result); }); + else + callback(result); +} + int OrcaPrinterAgent::delete_printer_file(const std::string& dev_id, const std::string& path, PrinterFileDeleteFn callback) { std::string origin; bool use_ssl = false; std::string ca_file; QueueOnMainFn queue; + std::shared_ptr conn; bool live = false; { std::lock_guard lock(state_mutex); @@ -2068,6 +2643,7 @@ int OrcaPrinterAgent::delete_printer_file(const std::string& dev_id, const std:: use_ssl = m_lan_use_ssl; ca_file = m_lan_ca_file; queue = queue_on_main_fn; + conn = lan_mqtt_connection; } } if (!live) { @@ -2075,30 +2651,40 @@ int OrcaPrinterAgent::delete_printer_file(const std::string& dev_id, const std:: callback(ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED); return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED; } - if (origin.empty()) { - if (callback) - callback(BAMBU_NETWORK_ERR_INVALID_HANDLE); - return BAMBU_NETWORK_ERR_INVALID_HANDLE; + + const FileTransport transport = preferred_file_transport(dev_id); + if (transport == FileTransport::http) { + if (origin.empty()) { + if (callback) + callback(BAMBU_NETWORK_ERR_INVALID_HANDLE); + return BAMBU_NETWORK_ERR_INVALID_HANDLE; + } + std::thread([path, origin, use_ssl, ca_file, queue, callback = std::move(callback)]() mutable { + delete_printer_file_http(path, origin, use_ssl, ca_file, queue, std::move(callback)); + }).detach(); + return BAMBU_NETWORK_SUCCESS; } - std::thread([path, origin, use_ssl, ca_file, queue, callback = std::move(callback)]() mutable { - int result = BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED; - - auto http = Http::del(origin + "/server/files/gcodes/" + encode_file_path(path)); - http.tls_verify(use_ssl); - if (!ca_file.empty()) - http.ca_file(ca_file); - http.timeout_connect(5) - .timeout_max(15) - .on_complete([&](std::string, unsigned status) { - if (status == 200) - result = BAMBU_NETWORK_SUCCESS; - }) - .on_error([&](std::string, std::string err, unsigned status) { - BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: file delete request failed status=" << status << " err=" << err; - }) - .perform_sync(); + const auto timeout = std::chrono::milliseconds( + transport == FileTransport::probe ? m_file_probe_timeout_ms.load() : m_file_request_timeout_ms.load()); + std::thread([dev_id, path, origin, use_ssl, ca_file, queue, conn, timeout, callback = std::move(callback)]() mutable { + bool ok = false; + if (conn) { + auto send = [conn, dev_id](const std::string& payload) { + return OrcaPrinterAgent::route_send_via(true, conn.get(), dev_id, payload); + }; + const std::string sequence_id = next_gcode_file_sequence_id(); + const std::string payload = OrcaPrinterAgent::build_files_delete_request(sequence_id, path); + std::string body; + if (send_and_wait_files(send, dev_id, sequence_id, payload, timeout, body)) + ok = !OrcaPrinterAgent::file_reply_requires_http_fallback(body); + } + if (!ok && !origin.empty()) { + delete_printer_file_http(path, origin, use_ssl, ca_file, queue, std::move(callback)); + return; + } + const int result = ok ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED; if (!callback) return; if (queue) @@ -2110,15 +2696,51 @@ int OrcaPrinterAgent::delete_printer_file(const std::string& dev_id, const std:: return BAMBU_NETWORK_SUCCESS; } -// Fetch one file's Moonraker metadata (print time and filament usage). Mirrors -// list_printer_files for the connection snapshot and off-thread marshalling; the -// worker captures no `this`. +// HTTP fallback: fetch Moonraker metadata. Static so the detached worker +// captures no `this`. +void OrcaPrinterAgent::get_printer_file_metadata_http(const std::string& path, const std::string& origin, bool use_ssl, + const std::string& ca_file, QueueOnMainFn queue, + PrinterFileMetadataFn callback) +{ + PrinterFileMetadata meta; + int result = BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED; + + auto http = Http::get(origin + "/server/files/metadata?filename=" + Http::url_encode(path)); + http.tls_verify(use_ssl); + if (!ca_file.empty()) + http.ca_file(ca_file); + http.timeout_connect(5) + .timeout_max(15) + .on_complete([&](std::string b, unsigned status) { + if (status == 200) { + if (nlohmann::json::parse(b, nullptr, false).is_discarded()) + result = BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED; + else { + meta = parse_file_metadata(b); + result = BAMBU_NETWORK_SUCCESS; + } + } + }) + .on_error([&](std::string, std::string err, unsigned status) { + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: file metadata request failed status=" << status << " err=" << err; + }) + .perform_sync(); + + if (!callback) + return; + if (queue) + queue([callback, result, meta]() mutable { callback(result, meta); }); + else + callback(result, meta); +} + int OrcaPrinterAgent::get_printer_file_metadata(const std::string& dev_id, const std::string& path, PrinterFileMetadataFn callback) { std::string origin; bool use_ssl = false; std::string ca_file; QueueOnMainFn queue; + std::shared_ptr conn; bool live = false; { std::lock_guard lock(state_mutex); @@ -2128,6 +2750,7 @@ int OrcaPrinterAgent::get_printer_file_metadata(const std::string& dev_id, const use_ssl = m_lan_use_ssl; ca_file = m_lan_ca_file; queue = queue_on_main_fn; + conn = lan_mqtt_connection; } } if (!live) { @@ -2135,37 +2758,44 @@ int OrcaPrinterAgent::get_printer_file_metadata(const std::string& dev_id, const callback(ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED, {}); return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED; } - if (origin.empty()) { - if (callback) - callback(BAMBU_NETWORK_ERR_INVALID_HANDLE, {}); - return BAMBU_NETWORK_ERR_INVALID_HANDLE; + + const FileTransport transport = preferred_file_transport(dev_id); + if (transport == FileTransport::http) { + if (origin.empty()) { + if (callback) + callback(BAMBU_NETWORK_ERR_INVALID_HANDLE, {}); + return BAMBU_NETWORK_ERR_INVALID_HANDLE; + } + std::thread([path, origin, use_ssl, ca_file, queue, callback = std::move(callback)]() mutable { + get_printer_file_metadata_http(path, origin, use_ssl, ca_file, queue, std::move(callback)); + }).detach(); + return BAMBU_NETWORK_SUCCESS; } - std::thread([path, origin, use_ssl, ca_file, queue, callback = std::move(callback)]() mutable { + const auto timeout = std::chrono::milliseconds( + transport == FileTransport::probe ? m_file_probe_timeout_ms.load() : m_file_request_timeout_ms.load()); + std::thread([dev_id, path, origin, use_ssl, ca_file, queue, conn, timeout, callback = std::move(callback)]() mutable { + bool ok = false; PrinterFileMetadata meta; - int result = BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED; - - auto http = Http::get(origin + "/server/files/metadata?filename=" + Http::url_encode(path)); - http.tls_verify(use_ssl); - if (!ca_file.empty()) - http.ca_file(ca_file); - http.timeout_connect(5) - .timeout_max(15) - .on_complete([&](std::string b, unsigned status) { - if (status == 200) { - if (nlohmann::json::parse(b, nullptr, false).is_discarded()) - result = BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED; - else { - meta = parse_file_metadata(b); - result = BAMBU_NETWORK_SUCCESS; - } - } - }) - .on_error([&](std::string, std::string err, unsigned status) { - BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: file metadata request failed status=" << status << " err=" << err; - }) - .perform_sync(); + if (conn) { + auto send = [conn, dev_id](const std::string& payload) { + return OrcaPrinterAgent::route_send_via(true, conn.get(), dev_id, payload); + }; + const std::string sequence_id = next_gcode_file_sequence_id(); + const std::string payload = OrcaPrinterAgent::build_files_metadata_request(sequence_id, path); + std::string body; + if (send_and_wait_files(send, dev_id, sequence_id, payload, timeout, body)) { + ok = !OrcaPrinterAgent::file_reply_requires_http_fallback(body); + if (ok) + meta = OrcaPrinterAgent::parse_opcp_file_metadata(body); + } + } + if (!ok && !origin.empty()) { + get_printer_file_metadata_http(path, origin, use_ssl, ca_file, queue, std::move(callback)); + return; + } + const int result = ok ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED; if (!callback) return; if (queue) diff --git a/src/slic3r/Utils/OrcaPrinterAgent.hpp b/src/slic3r/Utils/OrcaPrinterAgent.hpp index 105dea1214..4901d99049 100644 --- a/src/slic3r/Utils/OrcaPrinterAgent.hpp +++ b/src/slic3r/Utils/OrcaPrinterAgent.hpp @@ -6,6 +6,7 @@ #include "OrcaCloudServiceAgent.hpp" #include "OrcaMqttConnection.hpp" #include +#include #include #include #include "bambu_networking.hpp" @@ -122,6 +123,12 @@ public: 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: shrink the OPCP file-request waits (probe and per-page). + void set_file_opcp_timeouts_for_test(int probe_ms, int request_ms) + { + m_file_probe_timeout_ms.store(probe_ms); + m_file_request_timeout_ms.store(request_ms); + } FilamentSyncMode get_filament_sync_mode() const override { return FilamentSyncMode::subscription; } @@ -142,8 +149,8 @@ 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); void forget_device_capabilities(const std::string& dev_id); - int prepare_outgoing_request(const std::string& dev_id, const std::string& payload, - std::string& command, std::string& prepared) const; + static int prepare_outgoing_request(const std::string& dev_id, const std::string& payload, + std::string& command, std::string& prepared); // Report the asynchronous LAN connection state using the same callback contract as // the other printer agents. The transport result cannot be returned by @@ -185,6 +192,65 @@ protected: // Percent-encode each '/'-separated segment for a Moonraker URL while keeping // the separators intact. protected static for the test Probe. static std::string encode_file_path(const std::string& path); + + // ======================================================================== + // OPCP printer-file transport (Storage tab). + // + // Newer OrcaSonar builds expose files.* over the same MQTT channel the agent + // already owns; older ones only have the Moonraker HTTP façade. The manifest's + // files.list advertisement picks the transport, with a short OPCP probe and an + // HTTP fallback when the capability is not known yet. + // ======================================================================== + + // opcp: the manifest advertises files.list. http: the manifest is known and + // does not. probe: no manifest yet; try OPCP briefly then fall back to HTTP. + enum class FileTransport { opcp, http, probe }; + static FileTransport preferred_file_transport(const std::string& dev_id); + + // One files.list reply: the file entries the GUI shows, plus the subdirectory + // paths the walker still has to visit. + struct OpcpFileListPage { + std::vector files; + std::vector dirs; + bool truncated = false; + bool success = false; + }; + + static std::string build_files_list_request(const std::string& sequence_id, const std::string& path); + static std::string build_files_metadata_request(const std::string& sequence_id, const std::string& path); + static std::string build_files_delete_request(const std::string& sequence_id, const std::string& path); + static std::string build_files_thumbnail_request(const std::string& sequence_id, const std::string& path); + static OpcpFileListPage parse_opcp_file_list(const std::string& body); + static PrinterFileMetadata parse_opcp_file_metadata(const std::string& body); + static std::string parse_opcp_thumbnail(const std::string& body); + // True when a files.* reply is unusable, so the caller should try HTTP. + static bool file_reply_requires_http_fallback(const std::string& body); + + // Consume an inbound files.* reply. Returns true for any files payload, which + // is then not forwarded to the GUI. + bool handle_files_reply(const std::string& dev_id, const std::string& payload); + // Wake every pending file request for a device (disconnect/unbind/forget). + void fail_pending_file_requests(const std::string& dev_id); + + // Bounded BFS over OPCP files.list from the gcodes root. Returns false when the + // root listing itself failed, so the caller can fall back to HTTP. + static bool collect_opcp_file_entries(const std::function& send, + const std::string& dev_id, + std::chrono::milliseconds first_timeout, + std::chrono::milliseconds page_timeout, + std::vector& out, + bool& truncated); + + // HTTP fallbacks, split out of the public ops. Static so the detached workers + // that call them never capture `this`. + static void list_printer_files_http(const std::string& origin, bool use_ssl, const std::string& ca_file, + QueueOnMainFn queue, PrinterFileListFn callback); + static void get_printer_file_thumbnail_http(const std::string& path, const std::string& origin, bool use_ssl, + const std::string& ca_file, QueueOnMainFn queue, PrinterFileThumbnailFn callback); + static void delete_printer_file_http(const std::string& path, const std::string& origin, bool use_ssl, + const std::string& ca_file, QueueOnMainFn queue, PrinterFileDeleteFn callback); + static void get_printer_file_metadata_http(const std::string& path, const std::string& origin, bool use_ssl, + const std::string& ca_file, QueueOnMainFn queue, PrinterFileMetadataFn callback); // Test hook: the ws:// URL connect_printer built for the current LAN session ("" if none). std::string lan_connection_target() const; // Shared post-connect sequence: SUBSCRIBE, then pushing.start, pushall, @@ -218,7 +284,14 @@ private: CurrentConn m_current_connection = NONE; std::shared_ptr m_cloud_agent; - std::unique_ptr lan_mqtt_connection; + // shared_ptr so a detached file worker can hold the connection across a + // disconnect without a use-after-free; the worker never captures `this`. + std::shared_ptr lan_mqtt_connection; + + // OPCP file-request waits, in milliseconds. Probe is short so an older build + // fails over to HTTP quickly; the per-page wait covers a real transfer. + std::atomic m_file_probe_timeout_ms{3000}; + std::atomic m_file_request_timeout_ms{15000}; // Two independent epochs: a cloud (de)selection must not fence the live LAN // feed, and vice versa. Each transport's connect thread and inbound handler @@ -249,6 +322,9 @@ private: // Route one command payload to device//request on the LAN or the shared // cloud connection. The uniform send path for both send_message* overrides. int route_send(bool is_lan, const std::string& dev_id, const std::string& json_str); + // Core of route_send() on an already-resolved connection. Static so detached + // file workers (which must not capture `this`) send through the same path. + static int route_send_via(bool is_lan, OrcaMqttConnection* conn, const std::string& dev_id, const std::string& json_str); // Callbacks OnMsgArrivedFn on_ssdp_msg_fn; diff --git a/tests/slic3rutils/test_orca_printer_agent.cpp b/tests/slic3rutils/test_orca_printer_agent.cpp index 4a9d18a58e..a672dd6d4b 100644 --- a/tests/slic3rutils/test_orca_printer_agent.cpp +++ b/tests/slic3rutils/test_orca_printer_agent.cpp @@ -9,9 +9,12 @@ #include "catch2/matchers/catch_matchers.hpp" #include "orca_mqtt_mock_broker.hpp" +#include #include #include #include +#include +#include #include #include #include @@ -35,7 +38,78 @@ struct Probe : OrcaPrinterAgent { using OrcaPrinterAgent::parse_thumbnail_path; using OrcaPrinterAgent::encode_file_path; using OrcaPrinterAgent::parse_file_metadata; + + using OrcaPrinterAgent::FileTransport; + using OrcaPrinterAgent::preferred_file_transport; + using OrcaPrinterAgent::build_files_list_request; + using OrcaPrinterAgent::build_files_metadata_request; + using OrcaPrinterAgent::build_files_delete_request; + using OrcaPrinterAgent::build_files_thumbnail_request; + using OrcaPrinterAgent::parse_opcp_file_list; + using OrcaPrinterAgent::parse_opcp_file_metadata; + using OrcaPrinterAgent::parse_opcp_thumbnail; + using OrcaPrinterAgent::file_reply_requires_http_fallback; }; + +// Shared helpers for the OPCP file-transport tests below. +bool wait_for(const std::function& predicate, int attempts = 400, int sleep_ms = 10) +{ + for (int i = 0; i < attempts; ++i) { + if (predicate()) + return true; + std::this_thread::sleep_for(std::chrono::milliseconds(sleep_ms)); + } + return predicate(); +} + +// First recorded files. request, optionally narrowed to one path. +std::string find_files_request(const orca_mqtt_test::MockBroker& broker, const std::string& command, const std::string& path) +{ + for (const std::string& request : broker.received_requests()) { + if (request.find("\"files\"") == std::string::npos) + continue; + if (request.find("\"command\":\"" + command + "\"") == std::string::npos) + continue; + if (!path.empty() && request.find("\"path\":\"" + path + "\"") == std::string::npos) + continue; + return request; + } + return {}; +} + +std::string request_sequence_id(const std::string& request) +{ + const nlohmann::json parsed = nlohmann::json::parse(request, nullptr, false); + if (parsed.is_discarded() || !parsed.is_object()) + return {}; + const auto files_it = parsed.find("files"); + if (files_it == parsed.end() || !files_it->is_object()) + return {}; + return files_it->value("sequence_id", std::string()); +} + +std::string files_list_reply(const std::string& sequence_id, const std::string& entries_json, bool truncated = false) +{ + return R"({"files":{"command":"list","sequence_id":")" + sequence_id + R"(","result":"success","errno":0,"entries":)" + + entries_json + R"(,"truncated":)" + (truncated ? "true" : "false") + R"(}})"; +} + +bool connect_and_subscribe(Probe& agent, const orca_mqtt_test::MockBroker& broker, const std::string& dev_id) +{ + const auto endpoint = broker.host_port(); + agent.connect_printer( + Slic3r::PrinterConnectionParams{dev_id, endpoint.first + ":" + endpoint.second, "orcasonar", "code", "", false, ""}); + return wait_for([&] { return broker.connect_count() >= 1 && broker.is_subscribed("device/" + dev_id + "/report"); }); +} + +struct ListOutcome { + std::mutex mutex; + int result = 999; + std::vector files; +}; + +const char* const kFilesListCapabilities = + R"({"info":{"command":"get_capabilities","supported_commands":["files.list"],"capabilities":{"protocol":{"supported_commands":[]}}}})"; } TEST_CASE("OrcaPrinterAgent forwards a status payload to on_message_fn", "[OrcaPrinterAgent]") { @@ -574,3 +648,331 @@ TEST_CASE("destroying an agent mid-connect does not hang or crash", "[OrcaPrinte } SUCCEED(); } + +// ============================================================================ +// OPCP printer-file transport +// ============================================================================ + +TEST_CASE("the file transport follows the advertised files.list capability", "[OrcaPrinterAgent]") { + Probe agent("/tmp"); + + // No manifest yet: probe OPCP briefly, then fall back to HTTP. + CHECK(Probe::preferred_file_transport("transport-unknown") == Probe::FileTransport::probe); + + // Manifest advertises files.list: use OPCP. + agent.deliver_to_sink("transport-opcp", kFilesListCapabilities, true); + CHECK(Probe::preferred_file_transport("transport-opcp") == Probe::FileTransport::opcp); + + // Manifest is known and lacks files.list: use HTTP. + agent.deliver_to_sink("transport-http", + R"({"info":{"command":"get_capabilities","supported_commands":["print.gcode_file"],"capabilities":{"protocol":{"supported_commands":[]}}}})", + true); + CHECK(Probe::preferred_file_transport("transport-http") == Probe::FileTransport::http); +} + +TEST_CASE("the files.* request builders match the OPCP wire contract", "[OrcaPrinterAgent]") { + const nlohmann::json list = nlohmann::json::parse(Probe::build_files_list_request("7", "")); + CHECK(list["files"]["command"] == "list"); + CHECK(list["files"]["sequence_id"] == "7"); + CHECK(list["files"]["root"] == "gcodes"); + CHECK(list["files"]["path"] == ""); + + const nlohmann::json metadata = nlohmann::json::parse(Probe::build_files_metadata_request("8", "sub/model.gcode")); + CHECK(metadata["files"]["command"] == "metadata"); + CHECK(metadata["files"]["root"] == "gcodes"); + CHECK(metadata["files"]["path"] == "sub/model.gcode"); + + const nlohmann::json del = nlohmann::json::parse(Probe::build_files_delete_request("9", "sub/model.gcode")); + CHECK(del["files"]["command"] == "delete"); + CHECK(del["files"]["path"] == "sub/model.gcode"); + + const nlohmann::json thumb = nlohmann::json::parse(Probe::build_files_thumbnail_request("10", "sub/model.gcode")); + CHECK(thumb["files"]["command"] == "thumbnail"); + CHECK(thumb["files"]["max_dimension"] == 0); + CHECK(thumb["files"]["path"] == "sub/model.gcode"); +} + +TEST_CASE("parse_opcp_file_list splits files from directories", "[OrcaPrinterAgent]") { + const auto page = Probe::parse_opcp_file_list(R"({ + "files": { + "command": "list", + "sequence_id": "1", + "result": "success", + "errno": 0, + "root": "gcodes", + "path": "", + "entries": [ + {"name": "a.gcode", "path": "a.gcode", "is_dir": false, "size": 10, "modified": 100.5}, + {"name": "sub", "path": "sub", "is_dir": true, "size": 0, "modified": 0} + ], + "truncated": false + } + })"); + CHECK(page.success); + REQUIRE(page.files.size() == 1); + CHECK(page.files[0].path == "a.gcode"); + CHECK(page.files[0].name == "a.gcode"); + CHECK(page.files[0].size == 10); + CHECK(page.files[0].modified == 100); + REQUIRE(page.dirs.size() == 1); + CHECK(page.dirs[0] == "sub"); + CHECK_FALSE(page.truncated); +} + +TEST_CASE("parse_opcp_file_list reports truncation and failed replies", "[OrcaPrinterAgent]") { + const auto truncated = Probe::parse_opcp_file_list(R"({ + "files": {"command": "list", "sequence_id": "1", "result": "success", "errno": 0, "entries": [], "truncated": true} + })"); + CHECK(truncated.success); + CHECK(truncated.truncated); + + const std::string failed_reply = R"({"files":{"command":"list","sequence_id":"1","result":"error","errno":-19,"entries":[]}})"; + const auto failed = Probe::parse_opcp_file_list(failed_reply); + CHECK_FALSE(failed.success); + CHECK(Probe::file_reply_requires_http_fallback(failed_reply)); + CHECK_FALSE(Probe::file_reply_requires_http_fallback( + R"({"files":{"command":"list","sequence_id":"1","result":"success","errno":0,"entries":[]}})")); +} + +TEST_CASE("parse_opcp_file_metadata reads the OPCP fields", "[OrcaPrinterAgent]") { + using Catch::Matchers::WithinAbs; + const Slic3r::PrinterFileMetadata meta = Probe::parse_opcp_file_metadata(R"({ + "files": {"command": "metadata", "result": "success", "errno": 0, + "estimated_time": 3725, "filament_total": 10500.5, "filament_weight_total": 31.6} + })"); + CHECK(meta.estimated_time == 3725); + CHECK_THAT(meta.filament_total, WithinAbs(10500.5, 1e-9)); + CHECK_THAT(meta.filament_weight, WithinAbs(31.6, 1e-9)); + + const Slic3r::PrinterFileMetadata empty = Probe::parse_opcp_file_metadata(R"({"files": {"command": "metadata"}})"); + CHECK(empty.estimated_time == 0); + CHECK_THAT(empty.filament_total, WithinAbs(0.0, 1e-12)); + CHECK_THAT(empty.filament_weight, WithinAbs(0.0, 1e-12)); +} + +TEST_CASE("parse_opcp_thumbnail decodes the base64 PNG payload", "[OrcaPrinterAgent]") { + // "aGVsbG8=" is base64 for "hello". + const std::string decoded = Probe::parse_opcp_thumbnail( + R"({"files":{"command":"thumbnail","result":"success","errno":0,"width":300,"height":300,"size":5,"data":"aGVsbG8="}})"); + CHECK(decoded == "hello"); + + // data absent means the file has no embedded thumbnail. + CHECK(Probe::parse_opcp_thumbnail( + R"({"files":{"command":"thumbnail","result":"success","errno":0,"width":300,"height":300,"size":0}})").empty()); + CHECK(Probe::parse_opcp_thumbnail("not json").empty()); +} + +TEST_CASE("parse_opcp_thumbnail rejects unpadded and malformed base64", "[OrcaPrinterAgent]") { + // Unpadded: Beast's decoded_size() requires a length multiple of four, so this + // must be rejected rather than decoded into an undersized buffer. + CHECK(Probe::parse_opcp_thumbnail( + R"({"files":{"command":"thumbnail","result":"success","errno":0,"data":"aGVsbG8"}})").empty()); + // Non-alphabet characters must not decode as a partial image (length 8 here, + // so this exercises the character check rather than the length check). + CHECK(Probe::parse_opcp_thumbnail( + R"({"files":{"command":"thumbnail","result":"success","errno":0,"data":"aGVs*G8="}})").empty()); + // Padding is only valid closing the final group. + CHECK(Probe::parse_opcp_thumbnail( + R"({"files":{"command":"thumbnail","result":"success","errno":0,"data":"aG=s"}})").empty()); + CHECK(Probe::parse_opcp_thumbnail( + R"({"files":{"command":"thumbnail","result":"success","errno":0,"data":"===="}})").empty()); + // A correctly padded payload still decodes. + CHECK(Probe::parse_opcp_thumbnail( + R"({"files":{"command":"thumbnail","result":"success","errno":0,"data":"aGVsbG8="}})") == "hello"); +} + +TEST_CASE("list_printer_files parses an OPCP files.list reply", "[OrcaPrinterAgent]") { + orca_mqtt_test::MockBroker broker; + Probe agent("/tmp"); + const std::string dev_id = "opcp-list-flat"; + REQUIRE(connect_and_subscribe(agent, broker, dev_id)); + agent.deliver_to_sink(dev_id, kFilesListCapabilities, true); + + auto outcome = std::make_shared(); + agent.list_printer_files(dev_id, [outcome](int result, std::vector files) { + std::lock_guard lock(outcome->mutex); + outcome->result = result; + outcome->files = std::move(files); + }); + + std::string request; + REQUIRE(wait_for([&] { request = find_files_request(broker, "list", ""); return !request.empty(); })); + const std::string sequence_id = request_sequence_id(request); + REQUIRE_FALSE(sequence_id.empty()); + broker.push_report(dev_id, files_list_reply(sequence_id, + R"([{"name":"a.gcode","path":"a.gcode","is_dir":false,"size":1234,"modified":1700000000.5}])")); + + REQUIRE(wait_for([&] { std::lock_guard lock(outcome->mutex); return outcome->result != 999; })); + { + std::lock_guard lock(outcome->mutex); + CHECK(outcome->result == BAMBU_NETWORK_SUCCESS); + REQUIRE(outcome->files.size() == 1); + CHECK(outcome->files[0].name == "a.gcode"); + CHECK(outcome->files[0].size == 1234); + CHECK(outcome->files[0].modified == 1700000000); + } + agent.disconnect_printer(); +} + +TEST_CASE("list_printer_files walks OPCP subdirectories", "[OrcaPrinterAgent]") { + orca_mqtt_test::MockBroker broker; + Probe agent("/tmp"); + const std::string dev_id = "opcp-list-recursive"; + REQUIRE(connect_and_subscribe(agent, broker, dev_id)); + agent.deliver_to_sink(dev_id, kFilesListCapabilities, true); + + auto outcome = std::make_shared(); + agent.list_printer_files(dev_id, [outcome](int result, std::vector files) { + std::lock_guard lock(outcome->mutex); + outcome->result = result; + outcome->files = std::move(files); + }); + + // Reply to each files.list request the walker emits: root first, then "sub". + std::set replied; + const bool finished = wait_for([&] { + { + std::lock_guard lock(outcome->mutex); + if (outcome->result != 999) + return true; + } + for (const std::string& request : broker.received_requests()) { + if (request.find("\"command\":\"list\"") == std::string::npos) + continue; + const std::string sequence_id = request_sequence_id(request); + if (sequence_id.empty() || replied.count(sequence_id) != 0) + continue; + replied.insert(sequence_id); + + const nlohmann::json parsed = nlohmann::json::parse(request, nullptr, false); + const std::string path = parsed.is_object() ? parsed["files"].value("path", std::string()) : std::string(); + if (path.empty()) + broker.push_report(dev_id, files_list_reply(sequence_id, + R"([{"name":"root.gcode","path":"root.gcode","is_dir":false,"size":11,"modified":100.5},)" + R"({"name":"sub","path":"sub","is_dir":true,"size":0,"modified":0}])")); + else if (path == "sub") + broker.push_report(dev_id, files_list_reply(sequence_id, + R"([{"name":"nested.gcode","path":"sub/nested.gcode","is_dir":false,"size":22,"modified":200.5}])")); + } + return false; + }); + + REQUIRE(finished); + { + std::lock_guard lock(outcome->mutex); + CHECK(outcome->result == BAMBU_NETWORK_SUCCESS); + REQUIRE(outcome->files.size() == 2); + CHECK(outcome->files[0].path == "root.gcode"); + CHECK(outcome->files[1].path == "sub/nested.gcode"); + CHECK(outcome->files[1].size == 22); + } + agent.disconnect_printer(); +} + +TEST_CASE("an unanswered OPCP file listing times out and reports failure", "[OrcaPrinterAgent]") { + orca_mqtt_test::MockBroker broker; + Probe agent("/tmp"); + const std::string dev_id = "opcp-list-timeout"; + REQUIRE(connect_and_subscribe(agent, broker, dev_id)); + agent.deliver_to_sink(dev_id, kFilesListCapabilities, true); + agent.set_file_opcp_timeouts_for_test(/*probe=*/150, /*request=*/150); + + auto result = std::make_shared>(999); + agent.list_printer_files(dev_id, [result](int r, std::vector) { result->store(r); }); + + REQUIRE(wait_for([&] { return !find_files_request(broker, "list", "").empty(); })); + std::this_thread::sleep_for(std::chrono::milliseconds(300)); // let the request wait expire + agent.disconnect_printer(); // free the broker so the HTTP fallback fails fast + + REQUIRE(wait_for([&] { return result->load() != 999; }, 500, 10)); + CHECK(result->load() != BAMBU_NETWORK_SUCCESS); +} + +TEST_CASE("disconnect_printer wakes a pending file request", "[OrcaPrinterAgent]") { + orca_mqtt_test::MockBroker broker; + Probe agent("/tmp"); + const std::string dev_id = "opcp-list-disconnect"; + REQUIRE(connect_and_subscribe(agent, broker, dev_id)); + agent.deliver_to_sink(dev_id, kFilesListCapabilities, true); + // Far longer than the assertion window, so only fail_pending_file_requests + // can wake the worker. + agent.set_file_opcp_timeouts_for_test(/*probe=*/60000, /*request=*/60000); + + auto result = std::make_shared>(999); + agent.list_printer_files(dev_id, [result](int r, std::vector) { result->store(r); }); + + REQUIRE(wait_for([&] { return !find_files_request(broker, "list", "").empty(); })); + REQUIRE(result->load() == 999); // still waiting before the disconnect + + agent.disconnect_printer(); + REQUIRE(wait_for([&] { return result->load() != 999; }, 200, 10)); // 2s, well under the 60s wait + CHECK(result->load() != BAMBU_NETWORK_SUCCESS); +} + +TEST_CASE("an errno -19 files reply falls back to HTTP", "[OrcaPrinterAgent]") { + const std::string failed_reply = R"({"files":{"command":"list","sequence_id":"1","result":"error","errno":-19,"entries":[]}})"; + CHECK(Probe::file_reply_requires_http_fallback(failed_reply)); + CHECK_FALSE(Probe::parse_opcp_file_list(failed_reply).success); + + orca_mqtt_test::MockBroker broker; + Probe agent("/tmp"); + const std::string dev_id = "opcp-list-errno"; + REQUIRE(connect_and_subscribe(agent, broker, dev_id)); + agent.deliver_to_sink(dev_id, kFilesListCapabilities, true); + agent.set_file_opcp_timeouts_for_test(/*probe=*/500, /*request=*/500); + + auto result = std::make_shared>(999); + agent.list_printer_files(dev_id, [result](int r, std::vector) { result->store(r); }); + + std::string request; + REQUIRE(wait_for([&] { request = find_files_request(broker, "list", ""); return !request.empty(); })); + const std::string sequence_id = request_sequence_id(request); + REQUIRE_FALSE(sequence_id.empty()); + broker.push_report(dev_id, + R"({"files":{"command":"list","sequence_id":")" + sequence_id + R"(","result":"error","errno":-19,"entries":[]}})"); + std::this_thread::sleep_for(std::chrono::milliseconds(150)); // let the worker consume the failed reply + agent.disconnect_printer(); // free the broker so the HTTP fallback fails fast + + REQUIRE(wait_for([&] { return result->load() != 999; }, 500, 10)); + CHECK(result->load() != BAMBU_NETWORK_SUCCESS); +} + +TEST_CASE("files replies are consumed and never forwarded to the GUI", "[OrcaPrinterAgent]") { + orca_mqtt_test::MockBroker broker; + Probe agent("/tmp"); + const std::string dev_id = "opcp-list-routing"; + REQUIRE(connect_and_subscribe(agent, broker, dev_id)); + agent.deliver_to_sink(dev_id, kFilesListCapabilities, true); + + std::atomic forwarded{0}; + agent.set_on_local_message_fn([&forwarded](std::string, std::string) { forwarded.fetch_add(1); }); + + auto outcome = std::make_shared(); + agent.list_printer_files(dev_id, [outcome](int result, std::vector files) { + std::lock_guard lock(outcome->mutex); + outcome->result = result; + outcome->files = std::move(files); + }); + + std::string request; + REQUIRE(wait_for([&] { request = find_files_request(broker, "list", ""); return !request.empty(); })); + const std::string sequence_id = request_sequence_id(request); + REQUIRE_FALSE(sequence_id.empty()); + + // The matching reply satisfies the pending request and is not forwarded. + agent.deliver_to_sink(dev_id, + files_list_reply(sequence_id, R"([{"name":"a.gcode","path":"a.gcode","is_dir":false,"size":1,"modified":1}])"), true); + REQUIRE(wait_for([&] { std::lock_guard lock(outcome->mutex); return outcome->result != 999; })); + CHECK(forwarded.load() == 0); + + // An unmatched files report is dropped too. + agent.deliver_to_sink(dev_id, + R"({"files":{"command":"list","sequence_id":"no-such-sequence","result":"success","errno":0,"entries":[]}})"), true); + CHECK(forwarded.load() == 0); + + // An unrelated report still reaches the sink. + agent.deliver_to_sink(dev_id, R"({"print":{"command":"push_status"}})", true); + CHECK(forwarded.load() == 1); + + agent.disconnect_printer(); +}