List OrcaSonar printer files over LAN MQTT

The Storage tab's list/metadata/delete went over OrcaSonar's Moonraker
façade, so they failed when the façade was disabled. Send the files.* MQTT
commands on the LAN connection instead - the same transport OrcaCloud uses -
and match replies by sequence_id.

Thumbnails still use the façade (no MQTT equivalent); gcode upload is
unchanged.
This commit is contained in:
Lam Wei Lun
2026-10-07 18:35:12 +08:00
parent 3c646f305c
commit 9bd4c71a19
3 changed files with 304 additions and 255 deletions
+198 -209
View File
@@ -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<uint64_t> counter{[] {
const auto now = std::chrono::system_clock::now().time_since_epoch();
return static_cast<uint64_t>(std::chrono::duration_cast<std::chrono::seconds>(now).count());
return static_cast<uint64_t>(std::chrono::duration_cast<std::chrono::nanoseconds>(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<OrcaMqttConnection>();
lan_mqtt_connection = std::make_shared<OrcaMqttConnection>();
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<OrcaMqttConnection> doomed;
std::shared_ptr<OrcaMqttConnection> 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<PrinterFileEntry> 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<PrinterFileEntry> OrcaPrinterAgent::parse_files_list_reply(const std::string& payload)
{
std::vector<PrinterFileEntry> 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<bool>())
continue;
const std::string path = path_it->get<std::string>();
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<std::string>();
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<std::string>().empty())
? path_it->get<std::string>()
: name;
const auto size_it = item.find("size");
if (size_it != item.end() && size_it->is_number())
@@ -1802,76 +1820,150 @@ std::vector<PrinterFileEntry> 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<void(int result, const std::string& payload)> 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<OrcaMqttConnection> conn;
QueueOnMainFn queue;
CurrentConn transport = NONE;
std::string lan_dev;
{
std::lock_guard<std::mutex> 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<std::string>();
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<PrinterFileEntry> 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<std::atomic<bool>>(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<std::mutex> lock(registry->mutex);
registry->entries[sequence_id] = FilesReply{dev_id, finish, fired};
}
if (!conn->send_request(dev_id, payload)) {
{
std::lock_guard<std::mutex> 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<std::mutex> 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::string>();
std::function<void(int, const std::string&)> complete;
{
std::lock_guard<std::mutex> 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<std::string>() == "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>() : 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<PrinterFileEntry>{});
});
}
// 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<int>(time_it->get<double>());
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<double>();
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<double>();
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<std::mutex> 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<std::mutex> 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...");
+36 -7
View File
@@ -13,6 +13,7 @@
#include <mutex>
#include <memory>
#include <thread>
#include <unordered_map>
#include <vector>
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<PrinterFileEntry> 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<PrinterFileEntry> 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<void(int result, const std::string& payload)> 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<ICloudServiceAgent> m_cloud_agent;
std::unique_ptr<OrcaMqttConnection> lan_mqtt_connection;
std::shared_ptr<OrcaMqttConnection> 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<void(int, const std::string&)> complete;
std::shared_ptr<std::atomic<bool>> fired = std::make_shared<std::atomic<bool>>(false);
};
struct FilesReplyRegistry {
std::mutex mutex;
std::unordered_map<std::string, FilesReply> entries;
};
std::shared_ptr<FilesReplyRegistry> m_files_replies = std::make_shared<FilesReplyRegistry>();
OrcaCloudServiceAgent* get_orca_cloud_agent();
OrcaMqttConnection* get_appropriate_mqtt_connection(bool is_lan = true);
+70 -39
View File
@@ -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<Slic3r::PrinterFileEntry> 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<Slic3r::PrinterFileEntry> 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<Slic3r::PrinterFileEntry> 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<Slic3r::PrinterFileEntry> 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<Slic3r::PrinterFileEntry> 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<Slic3r::PrinterFileEntry> 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));
}