diff --git a/src/slic3r/Utils/OrcaPrinterAgent.cpp b/src/slic3r/Utils/OrcaPrinterAgent.cpp index 4e2a82a6b4..65f143defc 100644 --- a/src/slic3r/Utils/OrcaPrinterAgent.cpp +++ b/src/slic3r/Utils/OrcaPrinterAgent.cpp @@ -156,14 +156,15 @@ std::string http_origin_from_lan_ws(const std::string& ws_url) return s; } -// print.gcode_file is non-idempotent and OrcaSonar replays a cached response for a -// reused (namespace, command, sequence_id). Seed from the wall clock so ids do not -// collide across slicer restarts, then bump once per call within a run. -std::string next_gcode_file_sequence_id() +// print.gcode_file and files.* are non-idempotent and OrcaSonar replays a cached +// response for a reused (namespace, command, sequence_id). Seed from a +// high-resolution wall clock so a fast restart cannot reissue an id a prior run +// used, then bump once per call within a run. +std::string next_command_sequence_id() { static std::atomic counter{[] { const auto now = std::chrono::system_clock::now().time_since_epoch(); - return static_cast(std::chrono::duration_cast(now).count()); + return static_cast(std::chrono::duration_cast(now).count()); }()}; return std::to_string(counter.fetch_add(1, std::memory_order_relaxed)); } @@ -742,6 +743,10 @@ void OrcaPrinterAgent::forget_device_capabilities(const std::string& dev_id) void OrcaPrinterAgent::deliver_to_sink(const std::string& dev_id, const std::string& payload, bool local) { + // A files.* reply answers a pending request and is not printer state. + if (local && try_consume_files_reply(dev_id, payload)) + return; + parse_ipcam_info(dev_id, payload); std::string merged_payload = merge_capabilities(dev_id, payload); @@ -1218,7 +1223,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 +1284,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; @@ -1759,35 +1764,48 @@ int OrcaPrinterAgent::start_send_gcode_to_sdcard(PrintParams params, return BAMBU_NETWORK_SUCCESS; } -// Pure normalization of Moonraker's /server/files/list reply: the `result` array -// of {path, modified, size}. `name` is the basename of `path`; malformed entries -// (non-object, missing/empty path) are skipped. size/modified default to 0. -std::vector OrcaPrinterAgent::parse_file_list(const std::string& body) +// Pure normalization of the files.list MQTT reply: +// {"files":{...,"entries":[{name,path,is_dir,size,modified}]}}. Directory entries +// and entries without a usable name are skipped; a missing path falls back to the +// name and size/modified default to 0. +std::vector OrcaPrinterAgent::parse_files_list_reply(const std::string& payload) { std::vector files; - const nlohmann::json envelope = nlohmann::json::parse(body, nullptr, false); + const nlohmann::json envelope = nlohmann::json::parse(payload, nullptr, false); if (envelope.is_discarded() || !envelope.is_object()) return files; - const auto result_it = envelope.find("result"); - if (result_it == envelope.end() || !result_it->is_array()) + const auto files_it = envelope.find("files"); + if (files_it == envelope.end() || !files_it->is_object()) return files; - for (const auto& item : *result_it) { + const auto entries_it = files_it->find("entries"); + if (entries_it == files_it->end() || !entries_it->is_array()) + return files; + + 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()) + // Type-check is_dir rather than value(): a non-boolean would throw. + const auto is_dir_it = item.find("is_dir"); + if (is_dir_it != item.end() && is_dir_it->is_boolean() && is_dir_it->get()) continue; - const std::string path = path_it->get(); - if (path.empty()) + + const auto name_it = item.find("name"); + if (name_it == item.end() || !name_it->is_string()) + continue; + const std::string name = name_it->get(); + if (name.empty()) continue; PrinterFileEntry entry; - entry.path = path; - entry.name = fs::path(path).filename().string(); + entry.name = name; + + const auto path_it = item.find("path"); + entry.path = (path_it != item.end() && path_it->is_string() && !path_it->get().empty()) + ? path_it->get() + : name; const auto size_it = item.find("size"); if (size_it != item.end() && size_it->is_number()) @@ -1802,76 +1820,150 @@ std::vector OrcaPrinterAgent::parse_file_list(const std::strin return files; } -int OrcaPrinterAgent::list_printer_files(const std::string& dev_id, PrinterFileListFn callback) +// Dispatch one files.* command over the LAN MQTT connection and route its reply to +// `complete`, matched by sequence_id. The UI-thread marshalling and the timing-out +// watchdog capture only values and the heap-owned registry, never `this`. +int OrcaPrinterAgent::send_files_request(const std::string& dev_id, const std::string& command, nlohmann::json fields, + std::function complete) { - std::string origin; - bool use_ssl = false; - std::string ca_file; - QueueOnMainFn queue; - bool live = false; + if (!complete) + return BAMBU_NETWORK_ERR_INVALID_HANDLE; + + std::shared_ptr conn; + QueueOnMainFn queue; + CurrentConn transport = NONE; + std::string lan_dev; { std::lock_guard lock(state_mutex); - live = m_current_connection == LAN && m_lan_dev_id == dev_id; - if (live) { - origin = http_origin_from_lan_ws(m_lan_url); - use_ssl = m_lan_use_ssl; - ca_file = m_lan_ca_file; - queue = queue_on_main_fn; - } + transport = m_current_connection; + lan_dev = m_lan_dev_id; + if (m_current_connection == LAN && m_lan_dev_id == dev_id) + conn = lan_mqtt_connection; // keep alive across the send, racing disconnects included + queue = queue_on_main_fn; } - if (!live) { - if (callback) - callback(ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED, {}); + if (!conn) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: files." << command << " not sent: no live LAN session for dev_id=" << dev_id + << " transport=" << connection_type_name(transport) << " m_lan_dev_id=" << lan_dev; + complete(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; - } - // 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; + fields["command"] = command; + fields["sequence_id"] = next_command_sequence_id(); + const std::string sequence_id = fields["sequence_id"].get(); + const std::string payload = nlohmann::json{{"files", std::move(fields)}}.dump(); - 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) + auto registry = m_files_replies; + auto fired = std::make_shared>(false); + auto finish = [queue, complete = std::move(complete), fired](int result, const std::string& reply) { + if (fired->exchange(true)) return; if (queue) - queue([callback, result, files = std::move(files)]() mutable { callback(result, std::move(files)); }); + queue([complete, result, reply]() { complete(result, reply); }); else - callback(result, std::move(files)); + complete(result, reply); + }; + + { + std::lock_guard lock(registry->mutex); + registry->entries[sequence_id] = FilesReply{dev_id, finish, fired}; + } + + if (!conn->send_request(dev_id, payload)) { + { + std::lock_guard lock(registry->mutex); + registry->entries.erase(sequence_id); + } + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: files." << command << " send_request failed dev_id=" << dev_id + << " sequence_id=" << sequence_id << " mqtt_connected=" << conn->is_connected(); + finish(BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED, {}); + return BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED; + } + + // Watchdog: the connector answers promptly on a healthy link; fail the request + // if nothing arrives so the UI does not wait on a dropped report. + std::thread([registry, sequence_id, command, fired, finish]() { + for (int waited = 0; waited < 100 && !fired->load(); ++waited) + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + if (fired->load()) + return; + { + std::lock_guard lock(registry->mutex); + if (registry->entries.erase(sequence_id) == 0) + return; // consumed concurrently + } + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: files." << command << " timed out waiting for reply sequence_id=" << sequence_id; + finish(BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED, {}); }).detach(); return BAMBU_NETWORK_SUCCESS; } +bool OrcaPrinterAgent::try_consume_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; + + const auto sequence_it = files_it->find("sequence_id"); + if (sequence_it == files_it->end() || !sequence_it->is_string()) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: files frame without string sequence_id dev_id=" << dev_id; + return false; + } + const std::string sequence_id = sequence_it->get(); + + std::function complete; + { + std::lock_guard lock(m_files_replies->mutex); + const auto it = m_files_replies->entries.find(sequence_id); + if (it == m_files_replies->entries.end()) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: files reply sequence_id=" << sequence_id + << " has no pending request dev_id=" << dev_id; + return false; + } + if (it->second.device_id != dev_id) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: files reply sequence_id=" << sequence_id << " device mismatch: pending=" + << it->second.device_id << " reply=" << dev_id; + return false; + } + complete = it->second.complete; + m_files_replies->entries.erase(it); + } + + // A non-string result (e.g. null) is a failure, never a throw. + const auto result_it = files_it->find("result"); + const bool ok = result_it != files_it->end() && result_it->is_string() && result_it->get() == "success"; + if (!ok) { + const auto reason_it = files_it->find("reason"); + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: files reply result=fail sequence_id=" << sequence_id + << " reason=" << (reason_it != files_it->end() && reason_it->is_string() ? reason_it->get() : std::string()); + } + complete(ok ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED, ok ? payload : std::string()); + return true; +} + +// List the printer's G-code files over the LAN MQTT files.list command — the same +// transport OrcaCloud uses, so the list works whether or not OrcaSonar's Moonraker +// façade is enabled. +int OrcaPrinterAgent::list_printer_files(const std::string& dev_id, PrinterFileListFn callback) +{ + return send_files_request( + dev_id, "list", nlohmann::json{{"root", "gcodes"}, {"path", ""}}, + [callback = std::move(callback)](int result, const std::string& payload) mutable { + if (!callback) + return; + callback(result, result == BAMBU_NETWORK_SUCCESS ? parse_files_list_reply(payload) + : std::vector{}); + }); +} + // Pure pick of the widest thumbnail path from Moonraker's /server/files/thumbnails // reply. The `result` array is ordered smallest-first, so the largest `width` is // chosen. The key was renamed across Moonraker versions; both spellings are accepted. @@ -1905,31 +1997,32 @@ std::string OrcaPrinterAgent::parse_thumbnail_path(const std::string& body) return path; } -// Pure normalization of Moonraker's /server/files/metadata reply: the `result` -// object's estimated_time (seconds), filament_total (mm) and filament_weight_total -// (grams). A malformed reply or any non-numeric field defaults to 0. -PrinterFileMetadata OrcaPrinterAgent::parse_file_metadata(const std::string& body) +// Pure normalization of the files.metadata MQTT reply: the `files` object's +// estimated_time (seconds), filament_total (mm) and filament_weight_total (grams; +// OrcaSonar currently reports only filament_total). A malformed reply or any +// non-numeric field defaults to 0. +PrinterFileMetadata OrcaPrinterAgent::parse_files_metadata_reply(const std::string& payload) { PrinterFileMetadata meta; - const nlohmann::json envelope = nlohmann::json::parse(body, nullptr, false); + const nlohmann::json envelope = nlohmann::json::parse(payload, nullptr, false); if (envelope.is_discarded() || !envelope.is_object()) return meta; - const auto result_it = envelope.find("result"); - if (result_it == envelope.end() || !result_it->is_object()) + const auto files_it = envelope.find("files"); + if (files_it == envelope.end() || !files_it->is_object()) return meta; - const auto time_it = result_it->find("estimated_time"); - if (time_it != result_it->end() && time_it->is_number()) + const auto time_it = files_it->find("estimated_time"); + if (time_it != files_it->end() && time_it->is_number()) meta.estimated_time = static_cast(time_it->get()); - const auto total_it = result_it->find("filament_total"); - if (total_it != result_it->end() && total_it->is_number()) + const auto total_it = files_it->find("filament_total"); + if (total_it != files_it->end() && total_it->is_number()) meta.filament_total = total_it->get(); - const auto weight_it = result_it->find("filament_weight_total"); - if (weight_it != result_it->end() && weight_it->is_number()) + const auto weight_it = files_it->find("filament_weight_total"); + if (weight_it != files_it->end() && weight_it->is_number()) meta.filament_weight = weight_it->get(); return meta; @@ -2050,131 +2143,27 @@ 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`. +// Delete one G-code file over the LAN MQTT files.delete command. 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; - bool live = false; - { - std::lock_guard lock(state_mutex); - live = m_current_connection == LAN && m_lan_dev_id == dev_id; - if (live) { - origin = http_origin_from_lan_ws(m_lan_url); - use_ssl = m_lan_use_ssl; - ca_file = m_lan_ca_file; - queue = queue_on_main_fn; - } - } - if (!live) { - if (callback) - 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; - } - - 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(); - - if (!callback) - return; - if (queue) - queue([callback, result]() mutable { callback(result); }); - else - callback(result); - }).detach(); - - return BAMBU_NETWORK_SUCCESS; + return send_files_request(dev_id, "delete", nlohmann::json{{"root", "gcodes"}, {"path", path}}, + [callback = std::move(callback)](int result, const std::string&) mutable { + if (callback) + callback(result); + }); } -// 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`. +// Fetch one file's metadata (print time and filament usage) over the LAN MQTT +// files.metadata command. 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; - bool live = false; - { - std::lock_guard lock(state_mutex); - live = m_current_connection == LAN && m_lan_dev_id == dev_id; - if (live) { - origin = http_origin_from_lan_ws(m_lan_url); - use_ssl = m_lan_use_ssl; - ca_file = m_lan_ca_file; - queue = queue_on_main_fn; - } - } - if (!live) { - if (callback) - 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; - } - - std::thread([path, origin, use_ssl, ca_file, queue, callback = std::move(callback)]() mutable { - 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); - }).detach(); - - return BAMBU_NETWORK_SUCCESS; + return send_files_request( + dev_id, "metadata", nlohmann::json{{"root", "gcodes"}, {"path", path}}, + [callback = std::move(callback)](int result, const std::string& payload) mutable { + if (!callback) + return; + callback(result, result == BAMBU_NETWORK_SUCCESS ? parse_files_metadata_reply(payload) : PrinterFileMetadata{}); + }); } // Upload the sliced G-code, then start it: the LAN "print now" path. @@ -2279,7 +2268,7 @@ int OrcaPrinterAgent::start_sdcard_print(PrintParams params, OnUpdateStatusFn up BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: start_sdcard_print emitting filament_mapping entries=" << filament_mapping.size(); } - nlohmann::json j = build_gcode_file_payload(next_gcode_file_sequence_id(), target, filament_mapping); + nlohmann::json j = build_gcode_file_payload(next_command_sequence_id(), target, filament_mapping); if (update_fn) update_fn(PrintingStageSending, 0, "Starting print..."); diff --git a/src/slic3r/Utils/OrcaPrinterAgent.hpp b/src/slic3r/Utils/OrcaPrinterAgent.hpp index 105dea1214..9ad66f5e92 100644 --- a/src/slic3r/Utils/OrcaPrinterAgent.hpp +++ b/src/slic3r/Utils/OrcaPrinterAgent.hpp @@ -13,6 +13,7 @@ #include #include #include +#include #include namespace Slic3r { class ICloudServiceAgent; } @@ -170,17 +171,31 @@ protected: const std::string& target, const nlohmann::json& filament_mapping); - // Pure JSON -> entries normalization for OrcaSonar's /server/files/list reply. - // protected static so the test Probe reaches it. - static std::vector parse_file_list(const std::string& body); + // Pure JSON -> entries normalization for the files.list MQTT reply + // ({"files":{...,"entries":[{name,path,is_dir,size,modified}]}}). Directory + // entries and entries without a usable name are skipped; a missing path falls + // back to the name and size/modified default to 0. protected static for the Probe. + static std::vector parse_files_list_reply(const std::string& payload); // Pick the widest thumbnail path from OrcaSonar's /server/files/thumbnails // reply (the array is smallest-first). Empty when none carry a path. static std::string parse_thumbnail_path(const std::string& body); - // Pure JSON -> metadata normalization for OrcaSonar's /server/files/metadata - // reply. Missing or malformed fields default to 0. protected static for the Probe. - static PrinterFileMetadata parse_file_metadata(const std::string& body); + // Pure JSON -> metadata normalization for the files.metadata MQTT reply. Missing + // or malformed fields default to 0. protected static for the Probe. + static PrinterFileMetadata parse_files_metadata_reply(const std::string& payload); + + // Dispatch one files.* command (list/metadata/delete) over the LAN MQTT + // connection and route its reply, matched by sequence_id, into `complete`. + // `complete` runs once on the UI thread (or the calling thread when no queue is + // set) with the raw reply payload on success, or an empty string on + // failure/timeout. Returns whether the request was dispatched. + int send_files_request(const std::string& dev_id, const std::string& command, nlohmann::json fields, + std::function complete); + + // Consume an inbound report that answers a pending files.* request. Returns true + // when it matched, so the caller does not forward it to the machine-state sink. + bool try_consume_files_reply(const std::string& dev_id, const std::string& payload); // Percent-encode each '/'-separated segment for a Moonraker URL while keeping // the separators intact. protected static for the test Probe. @@ -218,7 +233,7 @@ private: CurrentConn m_current_connection = NONE; std::shared_ptr m_cloud_agent; - std::unique_ptr lan_mqtt_connection; + std::shared_ptr lan_mqtt_connection; // 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 @@ -241,6 +256,20 @@ private: CameraStreamMode m_camera_stream_mode = CameraStreamMode::none; // guarded by state_mutex std::string m_camera_url; // guarded by state_mutex + // Pending files.* MQTT replies, keyed by sequence_id. The registry is + // heap-owned so a timeout watchdog can outlive the agent without touching + // `this`; each entry carries its own one-shot guard. + struct FilesReply { + std::string device_id; + std::function complete; + std::shared_ptr> fired = std::make_shared>(false); + }; + struct FilesReplyRegistry { + std::mutex mutex; + std::unordered_map entries; + }; + std::shared_ptr m_files_replies = std::make_shared(); + OrcaCloudServiceAgent* get_orca_cloud_agent(); OrcaMqttConnection* get_appropriate_mqtt_connection(bool is_lan = true); diff --git a/tests/slic3rutils/test_orca_printer_agent.cpp b/tests/slic3rutils/test_orca_printer_agent.cpp index 4a9d18a58e..e444f89cba 100644 --- a/tests/slic3rutils/test_orca_printer_agent.cpp +++ b/tests/slic3rutils/test_orca_printer_agent.cpp @@ -31,10 +31,11 @@ struct Probe : OrcaPrinterAgent { using OrcaPrinterAgent::build_filament_mapping; using OrcaPrinterAgent::build_gcode_file_payload; using OrcaPrinterAgent::prepare_outgoing_request; - using OrcaPrinterAgent::parse_file_list; + using OrcaPrinterAgent::parse_files_list_reply; using OrcaPrinterAgent::parse_thumbnail_path; using OrcaPrinterAgent::encode_file_path; - using OrcaPrinterAgent::parse_file_metadata; + using OrcaPrinterAgent::parse_files_metadata_reply; + using OrcaPrinterAgent::try_consume_files_reply; }; } @@ -182,12 +183,17 @@ TEST_CASE("OrcaPrinterAgent::make_lan_client_id is stable and prefixed", "[OrcaP CHECK(a.rfind("orcaslicer-lan-dev-1-", 0) == 0); } -TEST_CASE("OrcaPrinterAgent::parse_file_list normalizes Moonraker entries", "[OrcaPrinterAgent]") { - const std::vector files = Probe::parse_file_list(R"({ - "result": [ - {"path": "sub/foo.gcode", "modified": 1700000000.75, "size": 1234, "permissions": "rw"}, - {"path": "bar.gcode", "modified": 42, "size": 7} - ] +TEST_CASE("OrcaPrinterAgent::parse_files_list_reply normalizes the MQTT entry fields", "[OrcaPrinterAgent]") { + const std::vector files = Probe::parse_files_list_reply(R"({ + "files": { + "command": "list", + "sequence_id": "70001", + "result": "success", + "entries": [ + {"name": "foo.gcode", "path": "sub/foo.gcode", "is_dir": false, "size": 1234, "modified": 1700000000.75}, + {"name": "bar.gcode", "path": "bar.gcode", "is_dir": false, "size": 7, "modified": 42} + ] + } })"); REQUIRE(files.size() == 2); CHECK(files[0].path == "sub/foo.gcode"); @@ -200,31 +206,54 @@ TEST_CASE("OrcaPrinterAgent::parse_file_list normalizes Moonraker entries", "[Or CHECK(files[1].modified == 42); } -TEST_CASE("OrcaPrinterAgent::parse_file_list handles an empty result", "[OrcaPrinterAgent]") { - CHECK(Probe::parse_file_list(R"({"result": []})").empty()); -} - -TEST_CASE("OrcaPrinterAgent::parse_file_list rejects malformed JSON", "[OrcaPrinterAgent]") { - CHECK(Probe::parse_file_list("not json").empty()); - CHECK(Probe::parse_file_list(R"({"result": "nope"})").empty()); -} - -TEST_CASE("OrcaPrinterAgent::parse_file_list skips entries missing fields", "[OrcaPrinterAgent]") { - const std::vector files = Probe::parse_file_list(R"({ - "result": [ - {"size": 5}, - {"path": ""}, - {"path": "kept.gcode"}, - "not-an-object" - ] - })"); +TEST_CASE("OrcaPrinterAgent::parse_files_list_reply falls back to the name when path is absent", "[OrcaPrinterAgent]") { + const std::vector files = + Probe::parse_files_list_reply(R"({"files": {"result": "success", "entries": [{"name": "solo.gcode"}]}})"); REQUIRE(files.size() == 1); - CHECK(files[0].path == "kept.gcode"); - CHECK(files[0].name == "kept.gcode"); + CHECK(files[0].path == "solo.gcode"); + CHECK(files[0].name == "solo.gcode"); CHECK(files[0].size == 0); CHECK(files[0].modified == 0); } +TEST_CASE("OrcaPrinterAgent::parse_files_list_reply skips directories and unusable entries", "[OrcaPrinterAgent]") { + const std::vector files = Probe::parse_files_list_reply(R"({ + "files": { + "result": "success", + "entries": [ + {"name": "sub", "path": "sub", "is_dir": true}, + {"path": "no-name.gcode"}, + {"name": ""}, + "not-an-object", + {"name": "kept.gcode", "path": "kept.gcode"} + ] + } + })"); + REQUIRE(files.size() == 1); + CHECK(files[0].name == "kept.gcode"); + CHECK(files[0].path == "kept.gcode"); +} + +TEST_CASE("OrcaPrinterAgent::parse_files_list_reply rejects a malformed or empty reply", "[OrcaPrinterAgent]") { + CHECK(Probe::parse_files_list_reply("not json").empty()); + CHECK(Probe::parse_files_list_reply(R"({"result": []})").empty()); + CHECK(Probe::parse_files_list_reply(R"({"files": {"entries": "nope"}})").empty()); +} + +TEST_CASE("OrcaPrinterAgent::parse_files_list_reply tolerates a non-boolean is_dir", "[OrcaPrinterAgent]") { + const std::vector files = + Probe::parse_files_list_reply(R"({"files": {"result": "success", "entries": [{"name": "x.gcode", "is_dir": "yes"}]}})"); + REQUIRE(files.size() == 1); + CHECK(files[0].name == "x.gcode"); +} + +TEST_CASE("OrcaPrinterAgent::try_consume_files_reply ignores reports it did not request", "[OrcaPrinterAgent]") { + Probe agent("/tmp"); + CHECK_FALSE(agent.try_consume_files_reply("dev-1", R"({"print": {"command": "push_status"}})")); + CHECK_FALSE(agent.try_consume_files_reply("dev-1", R"({"files": {"command": "list"}})")); // no sequence_id + CHECK_FALSE(agent.try_consume_files_reply("dev-1", R"({"files": {"command": "list", "sequence_id": "999"}})")); // not pending +} + TEST_CASE("OrcaPrinterAgent::parse_thumbnail_path picks the largest width", "[OrcaPrinterAgent]") { const std::string path = Probe::parse_thumbnail_path(R"({ "result": [ @@ -263,17 +292,19 @@ TEST_CASE("OrcaPrinterAgent::encode_file_path preserves separators and encodes s CHECK(Probe::encode_file_path("design+part.gcode") == "design%2Bpart.gcode"); } -TEST_CASE("OrcaPrinterAgent::parse_file_metadata parses the Moonraker fields", "[OrcaPrinterAgent]") { +TEST_CASE("OrcaPrinterAgent::parse_files_metadata_reply parses the metadata fields", "[OrcaPrinterAgent]") { using Catch::Matchers::WithinAbs; - const Slic3r::PrinterFileMetadata meta = Probe::parse_file_metadata(R"({ - "result": { - "filename": "sub/foo.gcode", + const Slic3r::PrinterFileMetadata meta = Probe::parse_files_metadata_reply(R"({ + "files": { + "command": "metadata", + "sequence_id": "70002", + "result": "success", + "path": "sub/foo.gcode", "size": 1234, "modified": 1700000000.5, "estimated_time": 3725, "filament_total": 10500.5, - "filament_weight_total": 31.6, - "thumbnails": [] + "filament_weight_total": 31.6 } })"); CHECK(meta.estimated_time == 3725); @@ -281,22 +312,22 @@ TEST_CASE("OrcaPrinterAgent::parse_file_metadata parses the Moonraker fields", " CHECK_THAT(meta.filament_weight, WithinAbs(31.6, 1e-9)); } -TEST_CASE("OrcaPrinterAgent::parse_file_metadata defaults missing fields to zero", "[OrcaPrinterAgent]") { +TEST_CASE("OrcaPrinterAgent::parse_files_metadata_reply defaults missing fields to zero", "[OrcaPrinterAgent]") { using Catch::Matchers::WithinAbs; - const Slic3r::PrinterFileMetadata meta = Probe::parse_file_metadata(R"({"result": {"filename": "foo.gcode"}})"); + const Slic3r::PrinterFileMetadata meta = Probe::parse_files_metadata_reply(R"({"files": {"path": "foo.gcode"}})"); CHECK(meta.estimated_time == 0); CHECK_THAT(meta.filament_total, WithinAbs(0.0, 1e-12)); CHECK_THAT(meta.filament_weight, WithinAbs(0.0, 1e-12)); } -TEST_CASE("OrcaPrinterAgent::parse_file_metadata yields zeros for a malformed reply", "[OrcaPrinterAgent]") { +TEST_CASE("OrcaPrinterAgent::parse_files_metadata_reply yields zeros for a malformed reply", "[OrcaPrinterAgent]") { using Catch::Matchers::WithinAbs; - const Slic3r::PrinterFileMetadata malformed = Probe::parse_file_metadata("not json"); + const Slic3r::PrinterFileMetadata malformed = Probe::parse_files_metadata_reply("not json"); CHECK(malformed.estimated_time == 0); CHECK_THAT(malformed.filament_total, WithinAbs(0.0, 1e-12)); CHECK_THAT(malformed.filament_weight, WithinAbs(0.0, 1e-12)); - const Slic3r::PrinterFileMetadata wrong_type = Probe::parse_file_metadata(R"({"result": "nope"})"); + const Slic3r::PrinterFileMetadata wrong_type = Probe::parse_files_metadata_reply(R"({"files": "nope"})"); CHECK(wrong_type.estimated_time == 0); CHECK_THAT(wrong_type.filament_total, WithinAbs(0.0, 1e-12)); }