fix: harden printer-agent filament sync and teardown

This commit is contained in:
Ian Chua
2026-10-08 19:55:39 +08:00
parent e6db086729
commit f075cdcb98
9 changed files with 314 additions and 202 deletions
+5 -5
View File
@@ -298,7 +298,7 @@ bool CrealityPrintAgent::fetch_filament_info(std::string dev_id, FilamentSyncMod
if (info.dev_ip.empty()) {
BOOST_LOG_TRIVIAL(warning)
<< "CrealityPrintAgent::fetch_filament_info: no device IP, falling back to base agent";
return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id));
return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id), sync_mode);
}
// Build a CrealityPrint helper so we can use its model detection + WS helpers
@@ -318,7 +318,7 @@ bool CrealityPrintAgent::fetch_filament_info(std::string dev_id, FilamentSyncMod
BOOST_LOG_TRIVIAL(info)
<< "CrealityPrintAgent: " << host.model_name()
<< " is not CFS-capable, deferring to base Moonraker agent";
return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id));
return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id), sync_mode);
}
BOOST_LOG_TRIVIAL(info)
@@ -333,7 +333,7 @@ bool CrealityPrintAgent::fetch_filament_info(std::string dev_id, FilamentSyncMod
BOOST_LOG_TRIVIAL(warning)
<< "CrealityPrintAgent: CFS query failed (" << parse_err << "), "
<< "falling back to base agent";
return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id));
return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id), sync_mode);
}
if (box_count == 0) {
@@ -342,7 +342,7 @@ bool CrealityPrintAgent::fetch_filament_info(std::string dev_id, FilamentSyncMod
// Moonraker exposes.
BOOST_LOG_TRIVIAL(info)
<< "CrealityPrintAgent: no active CFS boxes, deferring to base agent";
return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id));
return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id), sync_mode);
}
BOOST_LOG_TRIVIAL(info)
@@ -387,7 +387,7 @@ bool CrealityPrintAgent::fetch_filament_info(std::string dev_id, FilamentSyncMod
}
}
build_ams_payload(box_count, max_slots - 1, trays);
build_ams_payload(box_count, max_slots - 1, trays, sync_mode == FilamentSyncMode::pull);
return true;
}
+1
View File
@@ -43,6 +43,7 @@ public:
AgentInfo get_agent_info() override { return get_agent_info_static(); }
bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override;
FilamentSyncMode get_filament_sync_mode() const override { return FilamentSyncMode::pull; }
// Parse the boxsInfo JSON returned by CrealityPrint::query_boxes_info() into
// a flat list of loaded slots, plus the count of CFS boxes the printer reports.
+10 -3
View File
@@ -455,9 +455,16 @@ public:
virtual CameraStreamMode get_camera_stream_mode() const { return CameraStreamMode::none; }
/**
* Refresh filament info from the printer synchronously.
* Should only be called when get_filament_sync_mode() returns FilamentSyncMode::pull.
* Populates the MachineObject's DevFilaSystem with fetched filament data.
* Refresh filament info from the printer.
*
* When called with FilamentSyncMode::pull (which requires get_filament_sync_mode() to
* return pull) this is a blocking, synchronous call: when it returns true the MachineObject's
* DevFilaSystem has already been populated and the caller may read it immediately.
*
* When called with FilamentSyncMode::subscription — by an implementation's own status loop —
* the refresh may be performed asynchronously: a true return then means the refresh was
* scheduled (or is already in flight), and DevFilaSystem is updated later on the main thread.
* Callers must not assume the data is ready on return.
*/
virtual bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) { return false; }
+111 -75
View File
@@ -42,9 +42,11 @@
#include <boost/filesystem/path.hpp>
#include <boost/filesystem/operations.hpp>
#include <boost/log/trivial.hpp>
#include <cstddef>
#include <openssl/ssl.h>
#include <openssl/tls1.h>
#include <algorithm>
#include <cstddef>
#include <chrono>
#include <cstdint>
#include <cctype>
@@ -53,7 +55,6 @@
#include <exception>
#include <map>
#include <memory>
#include <openssl/tls1.h>
#include <stdexcept>
#include <thread>
#include <utility>
@@ -86,6 +87,12 @@ struct WsEndpoint
bool secure = false;
};
// Wrap a bare IPv6 literal in brackets for use as a host / authority (Host header, logs).
std::string bracket_host_for_header(const std::string& host)
{
return host.find(':') != std::string::npos ? "[" + host + "]" : host;
}
bool parse_ws_endpoint(const std::string& base_url, WsEndpoint& endpoint)
{
if (base_url.empty()) {
@@ -110,7 +117,21 @@ bool parse_ws_endpoint(const std::string& base_url, WsEndpoint& endpoint)
endpoint.host = url;
endpoint.port = endpoint.secure ? MOONRAKER_DEFAULT_TLS_PORT : MOONRAKER_DEFAULT_PORT;
if (auto colon = url.rfind(':'); colon != std::string::npos && url.find(']') == std::string::npos) {
if (url.rfind('[', 0) == 0) {
// Bracketed IPv6 literal, e.g. [fe80::1] or [fe80::1]:7125. Keep the bare address for the
// resolver/host-name verification; the Host header re-brackets it via bracket_host_for_header.
const auto close = url.find(']');
if (close == std::string::npos) {
return false;
}
endpoint.host = url.substr(1, close - 1);
if (close + 1 < url.size()) {
if (url[close + 1] != ':') {
return false;
}
endpoint.port = url.substr(close + 2);
}
} else if (auto colon = url.rfind(':'); colon != std::string::npos) {
endpoint.host = url.substr(0, colon);
endpoint.port = url.substr(colon + 1);
}
@@ -149,16 +170,18 @@ struct MoonrakerWebsocket::Impl
using SecurePtr = std::unique_ptr<SecureWebsocket>;
explicit Impl(bool secure, std::string api_key, std::string ca_file)
: secure(secure), api_key(std::move(api_key)), ca_file(std::move(ca_file)), ssl_context(net::ssl::context::tls_client)
: secure(secure), api_key(std::move(api_key)), ca_file(std::move(ca_file))
{
if (this->secure) {
// Build the TLS context lazily so plaintext connections do not initialize OpenSSL.
ssl_context = std::make_unique<net::ssl::context>(net::ssl::context::tls_client);
if (!this->ca_file.empty()) {
ssl_context.load_verify_file(this->ca_file);
ssl_context->load_verify_file(this->ca_file);
} else {
ssl_context.set_default_verify_paths();
ssl_context->set_default_verify_paths();
}
ssl_context.set_verify_mode(net::ssl::verify_peer);
websocket = std::make_unique<SecureWebsocket>(ioc, ssl_context);
ssl_context->set_verify_mode(net::ssl::verify_peer);
websocket = std::make_unique<SecureWebsocket>(ioc, *ssl_context);
} else {
websocket = std::make_unique<PlainWebsocket>(beast::tcp_stream{ioc});
}
@@ -168,7 +191,7 @@ struct MoonrakerWebsocket::Impl
std::string api_key;
std::string ca_file;
net::io_context ioc;
net::ssl::context ssl_context;
std::unique_ptr<net::ssl::context> ssl_context;
std::variant<PlainPtr, SecurePtr> websocket;
};
@@ -529,6 +552,8 @@ int MoonrakerPrinterAgent::bind_detect(std::string dev_ip, std::string sec_link,
// so the name falls back to the IP instead of blank. (matches
// feature/printer-agent-port-pristine; the IP is what shipped before the port)
// note: dummy id/creds; use_ssl false because Moonraker/print-host is http.
// init_device_info writes device_info; take the same lock every other writer/reader uses.
std::lock_guard<std::recursive_mutex> lock(connect_mutex);
init_device_info(PrinterConnectionParams{
dev_ip, dev_ip, "", "", "", false, ""
});
@@ -765,16 +790,21 @@ int MoonrakerPrinterAgent::set_queue_on_main_fn(QueueOnMainFn fn)
return BAMBU_NETWORK_SUCCESS;
}
void MoonrakerPrinterAgent::build_ams_payload(int ams_count, int max_lane_index, const std::vector<AmsTrayData>& trays)
void MoonrakerPrinterAgent::build_ams_payload(int ams_count, int max_lane_index, const std::vector<AmsTrayData>& trays, bool apply_inline,
ResolveAmsTrayIdsFn resolve_tray_ids)
{
// This may be called from a background thread (e.g. run_status_stream's read loop,
// for subscription-mode agents) as well as from the GUI thread (Sidebar's pull-mode
// path). Everything below touches MachineObject/DevFilaSystem, which the GUI thread
// reads without locking — so the actual mutation must run on the main thread. Snapshot
// reads without locking — so the actual mutation must run on the main thread.
//
// In the pull path the caller is already the GUI thread and reads DevFilaSystem as soon
// as this returns, so the payload is applied inline (apply_inline == true) rather than
// posted back through the event queue, which would land after that read. Snapshot
// queue_on_main_fn and the two device_info fields we need up front, then defer the rest,
// mirroring dispatch_message's existing queue_fn ? queue_fn(x) : x() idiom.
QueueOnMainFn queue_fn;
{
if (!apply_inline) {
std::lock_guard<std::recursive_mutex> lock(state_mutex);
queue_fn = queue_on_main_fn;
}
@@ -783,7 +813,14 @@ void MoonrakerPrinterAgent::build_ams_payload(int ams_count, int max_lane_index,
std::string dev_id = info.dev_id;
std::string model_id = info.model_id;
auto apply = [dev_id, model_id, ams_count, max_lane_index, trays]() {
auto apply = [dev_id, model_id, ams_count, max_lane_index, trays, resolve_tray_ids]() {
// Resolve preset-dependent tray ids on the main thread, not on the background fetch thread
// that produced `trays`. resolve_tray_ids must not capture `this` (this commit may outlive
// the agent once queued).
std::vector<AmsTrayData> resolved = trays;
if (resolve_tray_ids) {
resolve_tray_ids(resolved);
}
// Look up MachineObject via DeviceManager
auto* dev_manager = GUI::wxGetApp().getDeviceManager();
if (!dev_manager) {
@@ -816,7 +853,7 @@ void MoonrakerPrinterAgent::build_ams_payload(int ams_count, int max_lane_index,
// Find tray with matching slot_index
const AmsTrayData* tray = nullptr;
for (const auto& t : trays) {
for (const auto& t : resolved) {
if (t.slot_index == slot_index) {
tray = &t;
break;
@@ -870,7 +907,7 @@ void MoonrakerPrinterAgent::build_ams_payload(int ams_count, int max_lane_index,
// Call the parser to populate DevFilaSystem
DevFilaSystemParser::ParseV1_0(print_json, obj, obj->GetFilaSystem().get(), false);
BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::build_ams_payload: Parsed " << trays.size() << " trays";
BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::build_ams_payload: Parsed " << resolved.size() << " trays";
// Set printer_type so update_sync_status() can match it against the preset's printer type.
// Without this, the comparison fails and all sync badges are cleared.
@@ -899,11 +936,15 @@ void MoonrakerPrinterAgent::build_ams_payload(int ams_count, int max_lane_index,
}
};
if (queue_fn) {
queue_fn(apply);
} else {
if (apply_inline) {
// Pull path: the caller is the GUI thread and reads DevFilaSystem as soon as this
// returns, so apply directly instead of posting back through the event queue.
apply();
} else if (queue_fn) {
queue_fn(apply);
}
// else: queue_on_main_fn was cleared (e.g. during GUI shutdown) while this background fetch
// is still running. Skip the mutation rather than touch MachineObject off the main thread.
}
bool MoonrakerPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode)
@@ -922,7 +963,7 @@ bool MoonrakerPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSync
BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::fetch_filament_info: Detected Moonraker filament system with "
<< (max_lane_index + 1) << " lanes";
int ams_count = (max_lane_index + 4) / 4;
build_ams_payload(ams_count, max_lane_index, trays);
build_ams_payload(ams_count, max_lane_index, trays, sync_mode == FilamentSyncMode::pull);
return true;
}
@@ -931,7 +972,7 @@ bool MoonrakerPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSync
BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::fetch_filament_info: Detected Happy Hare MMU with "
<< (max_lane_index + 1) << " gates";
int ams_count = (max_lane_index + 4) / 4;
build_ams_payload(ams_count, max_lane_index, trays);
build_ams_payload(ams_count, max_lane_index, trays, sync_mode == FilamentSyncMode::pull);
return true;
}
@@ -1602,7 +1643,9 @@ int MoonrakerPrinterAgent::handle_request(const std::string& dev_id, const std::
// why: reaching here means no case claimed the command, which is the honest verdict for
// every control Klipper has no equivalent for. Returning SUCCESS instead made all of them
// look like they worked. Nothing surfaces this code to the user yet.
// look like they worked. The caller (MachineObject::publish_json) maps this code to the
// "not supported on this printer" dialog, so only a command the user actually fired should
// reach here.
BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: no translation for " << command_namespace << "." << command_name
<< ", dropping";
return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED;
@@ -1869,7 +1912,7 @@ bool MoonrakerPrinterAgent::send_ws_rpc(const std::string& method, const nlohman
ws.connect(endpoint.host, port, std::chrono::seconds(5));
ws.tls_handshake(endpoint.host);
std::string host_header = endpoint.host;
std::string host_header = bracket_host_for_header(endpoint.host);
if (!port.empty() && port != default_port) {
host_header += ":" + port;
}
@@ -1963,60 +2006,51 @@ bool MoonrakerPrinterAgent::fetch_webcam_info(const ConnectionSettings& connecti
std::string webcam_name;
CameraStreamMode stream_mode = CameraStreamMode::none;
std::string error;
// A subclass may name its stream directly (e.g. a fixed webcam path with no
// /server/webcams/list entry); consult it before doing any HTTP.
const std::string override_url = webcam_stream_override(connection.base_url);
try {
if (!override_url.empty()) {
camera_url = override_url;
stream_mode = (override_url.rfind("rtsp://", 0) == 0 || override_url.rfind("rtsps://", 0) == 0)
? CameraStreamMode::rtsp
: CameraStreamMode::http;
} else {
std::string response_body;
bool success = false;
std::string http_error;
std::string response_body;
bool success = false;
std::string http_error;
auto http = Http::get(join_url(connection.base_url, "/server/webcams/list"));
configure_http(http, connection);
if (!connection.api_key.empty()) {
http.header("X-Api-Key", connection.api_key);
}
http.timeout_connect(5)
.timeout_max(10)
.on_complete([&](std::string body, unsigned status_code) {
if (status_code == 200) {
response_body = body;
success = true;
} else {
http_error = "HTTP error: " + std::to_string(status_code);
}
})
.on_error([&](std::string body, std::string err, unsigned status_code) {
http_error = err;
if (status_code > 0) {
http_error += " (HTTP " + std::to_string(status_code) + ")";
}
})
.perform_sync();
if (!success) {
error = http_error.empty() ? "Connection failed" : http_error;
} else {
BOOST_LOG_TRIVIAL(debug) << "[Moonraker Diagnostic] " << connection.base_url
<< " webcams list: " << response_body.size() << " bytes";
auto json = nlohmann::json::parse(response_body, nullptr, false, true);
if (json.is_discarded()) {
error = "Invalid JSON response";
auto http = Http::get(join_url(connection.base_url, "/server/webcams/list"));
configure_http(http, connection);
if (!connection.api_key.empty()) {
http.header("X-Api-Key", connection.api_key);
}
http.timeout_connect(5)
.timeout_max(10)
.on_complete([&](std::string body, unsigned status_code) {
if (status_code == 200) {
response_body = body;
success = true;
} else {
MoonrakerWebcamSelection selection;
if (moonraker_parse_webcam_list(json, connection.base_url, selection)) {
camera_url = selection.url;
stream_mode = selection.mode;
webcam_name = selection.name;
} else {
error = selection.error;
}
http_error = "HTTP error: " + std::to_string(status_code);
}
})
.on_error([&](std::string body, std::string err, unsigned status_code) {
http_error = err;
if (status_code > 0) {
http_error += " (HTTP " + std::to_string(status_code) + ")";
}
})
.on_progress([this](Http::Progress, bool& cancel) { cancel = ws_stop.load(); })
.perform_sync();
if (!success) {
error = http_error.empty() ? "Connection failed" : http_error;
} else {
BOOST_LOG_TRIVIAL(debug) << "[Moonraker Diagnostic] " << connection.base_url
<< " webcams list: " << response_body.size() << " bytes";
auto json = nlohmann::json::parse(response_body, nullptr, false, true);
if (json.is_discarded()) {
error = "Invalid JSON response";
} else {
MoonrakerWebcamSelection selection;
if (moonraker_parse_webcam_list(json, connection.base_url, selection)) {
camera_url = selection.url;
stream_mode = selection.mode;
webcam_name = selection.name;
} else {
error = selection.error;
}
}
}
@@ -2219,6 +2253,7 @@ bool MoonrakerPrinterAgent::fetch_object_list(const ConnectionSettings& connecti
http_error += " (HTTP " + std::to_string(status) + ")";
}
})
.on_progress([this](Http::Progress, bool& cancel) { cancel = ws_stop.load(); })
.perform_sync();
if (!success) {
@@ -2445,7 +2480,7 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, ConnectionSett
ws.connect(endpoint.host, endpoint.port, std::chrono::seconds(10));
ws.tls_handshake(endpoint.host);
std::string host_header = endpoint.host;
std::string host_header = bracket_host_for_header(endpoint.host);
if (!endpoint.port.empty() && endpoint.port != (endpoint.secure ? MOONRAKER_DEFAULT_TLS_PORT : MOONRAKER_DEFAULT_PORT)) {
host_header += ":" + endpoint.port;
}
@@ -3183,7 +3218,8 @@ void MoonrakerPrinterAgent::perform_connection_async(const std::string& dev_id,
if (connection.use_ssl && !connection.ca_file.empty() && !Http::ca_file_supported()) {
BOOST_LOG_TRIVIAL(warning)
<< "MoonrakerPrinterAgent: a custom CA file is configured but this platform cannot load CA files "
"(Schannel/DarwinSSL); the CA will be ignored and the TLS connection may fail verification.";
"for HTTP requests (Schannel/DarwinSSL); HTTPS verification may fail. WebSocket (wss://) "
"connections still use the CA via OpenSSL.";
}
try {
+13 -6
View File
@@ -15,7 +15,6 @@
#include <chrono>
#include <condition_variable>
#include <deque>
#include <functional>
#include <nlohmann/json.hpp>
#include <vector>
@@ -164,8 +163,19 @@ protected:
int nozzle_temp = 0; // Optional
};
// Build ams JSON and call parser
void build_ams_payload(int ams_count, int max_lane_index, const std::vector<AmsTrayData>& trays);
// Build ams JSON and call parser.
// apply_inline: when true the MachineObject mutation runs on the calling thread, which
// must therefore be the main thread; when false it is deferred through queue_on_main_fn.
// Pull-mode fetches run synchronously on the GUI thread and are read back immediately, so
// they pass true to avoid the deferred mutation landing after the caller's read.
//
// resolve_tray_ids: optional hook that runs as part of the commit, i.e. on the main thread.
// Agents that would otherwise resolve tray_info_idx against GUI-owned preset state on a
// background fetch thread must do it here to avoid racing the GUI. It must not capture
// `this` (the commit may outlive the agent).
using ResolveAmsTrayIdsFn = std::function<void(std::vector<AmsTrayData>&)>;
void build_ams_payload(int ams_count, int max_lane_index, const std::vector<AmsTrayData>& trays, bool apply_inline = false,
ResolveAmsTrayIdsFn resolve_tray_ids = {});
// Methods that derived classes may need to override or access
virtual bool init_device_info(const PrinterConnectionParams& params);
@@ -267,9 +277,6 @@ private:
ConnectionSettings connection,
uint64_t generation);
// why: a printer with no /server/webcams/list entry can still name its stream directly;
// subclasses (e.g. printers with a fixed webcam path) can override this instead.
virtual std::string webcam_stream_override(const std::string& base_url) const { return {}; }
void refresh_webcam_info() const;
bool fetch_webcam_info(const ConnectionSettings& connection, uint64_t generation) const;
+69 -48
View File
@@ -13,6 +13,7 @@
#include <boost/algorithm/string/case_conv.hpp>
#include <boost/log/trivial.hpp>
#include <cctype>
#include <cmath>
#include "libslic3r/Preset.hpp"
#include <cstddef>
#include <exception>
@@ -57,7 +58,21 @@ struct InFlightGuard
int read_int_or(const nlohmann::json& obj, const std::string& key, int fallback)
{
auto it = obj.find(key);
return (it != obj.end() && it->is_number_integer()) ? it->get<int>() : fallback;
if (it == obj.end()) {
return fallback;
}
if (it->is_number_integer()) {
return it->get<int>();
}
// Firmware may report an integral field as a JSON float (e.g. 2.0). Accept it only when it
// has no fractional part, so a genuinely fractional value falls back instead of truncating.
if (it->is_number_float()) {
const double value = it->get<double>();
if (value == std::floor(value)) {
return static_cast<int>(value);
}
}
return fallback;
}
} // anonymous namespace
@@ -95,18 +110,11 @@ bool QidiPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode
model_name = device_info.model_name;
}
// Reserve under the same mutex shutdown() uses, so the flag and the count can't race.
{
std::lock_guard<std::mutex> lock(fetch_lifecycle_mutex);
if (shutting_down.load())
return false;
if (filament_fetch_in_flight.load() > 0)
return true; // a fetch is already running; don't pile on
filament_fetch_in_flight.fetch_add(1, std::memory_order_relaxed);
}
InFlightGuard guard{*this};
std::thread([this, guard = std::move(guard), connection = std::move(connection), model_id, model_name]() mutable {
// One implementation for both modes; only where it runs differs. pull is the documented
// blocking contract (the GUI thread reads DevFilaSystem right after the call), so it runs
// on the caller with the payload applied inline. subscription runs on a background thread
// so the status loop is not stalled, and the payload is marshalled back to the main thread.
auto work = [this, connection = std::move(connection), model_id, model_name](bool apply_inline) -> bool {
try {
std::string error;
@@ -134,18 +142,57 @@ bool QidiPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode
int box_count = 0;
if (!fetch_slot_info(connection, dict, series_id, trays, box_count, error)) {
BOOST_LOG_TRIVIAL(warning) << "QidiPrinterAgent::fetch_filament_info: Failed to fetch slot info: " << error;
return;
return false;
}
// 4. Build the AMS payload
build_ams_payload(box_count, box_count * 4 - 1, trays);
// 4. Build the AMS payload, resolving preset-dependent ids on the main thread.
auto resolve_ids = [](std::vector<AmsTrayData>& resolved_trays) {
auto* bundle = GUI::wxGetApp().preset_bundle;
if (!bundle) {
return; // keep the Qidi-specific ids computed from device data
}
for (auto& tray : resolved_trays) {
if (!tray.has_filament) {
continue;
}
if (!tray.tray_info_idx.empty() && has_visible_base_preset(bundle->filaments, tray.tray_info_idx)) {
continue;
}
tray.tray_info_idx = bundle->filaments.filament_id_by_type(tray.tray_type);
}
};
build_ams_payload(box_count, box_count * 4 - 1, trays, apply_inline, resolve_ids);
return true;
} catch (const std::exception& e) {
// why: an exception escaping a detached thread is std::terminate, and firmware
// JSON is untrusted; mirror run_command_worker and swallow it here.
BOOST_LOG_TRIVIAL(error) << "QidiPrinterAgent::fetch_filament_info: unhandled exception: " << e.what();
return false;
} catch (...) {
BOOST_LOG_TRIVIAL(error) << "QidiPrinterAgent::fetch_filament_info: unhandled exception";
return false;
}
};
if (sync_mode == FilamentSyncMode::pull) {
return work(/*apply_inline=*/true);
}
// Subscription: fire-and-forget. A `true` return means the refresh was scheduled (or is
// already running), not that DevFilaSystem has been updated. Reserve under the same mutex
// shutdown() uses, so the flag and the count can't race.
{
std::lock_guard<std::mutex> lock(fetch_lifecycle_mutex);
if (shutting_down.load())
return false;
if (filament_fetch_in_flight.load() > 0)
return true; // a fetch is already running; don't pile on
filament_fetch_in_flight.fetch_add(1, std::memory_order_relaxed);
}
InFlightGuard guard{*this};
std::thread([work = std::move(work), guard = std::move(guard)]() mutable {
work(/*apply_inline=*/false);
}).detach();
return true;
}
@@ -198,33 +245,14 @@ bool QidiPrinterAgent::apply_box_mapping(const PrintParams& params) const
int QidiPrinterAgent::start_local_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn)
{
// Only the LAN print path is implemented by the Moonraker base; the box config is emitted
// here, before the print starts. The cloud/record/sdcard variants inherited from the base
// deliberately return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED without side effects.
if (!apply_box_mapping(params))
return BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED;
return MoonrakerPrinterAgent::start_local_print(std::move(params), update_fn, cancel_fn);
}
int QidiPrinterAgent::start_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn)
{
if (!apply_box_mapping(params))
return BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED;
return MoonrakerPrinterAgent::start_print(std::move(params), update_fn, cancel_fn, wait_fn);
}
int QidiPrinterAgent::start_local_print_with_record(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn)
{
// A failed box mapping is a send failure, not an upload failure.
if (!apply_box_mapping(params))
return BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED;
return MoonrakerPrinterAgent::start_local_print_with_record(std::move(params), update_fn, cancel_fn, wait_fn);
}
int QidiPrinterAgent::start_sdcard_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn)
{
if (!apply_box_mapping(params))
return BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED;
return MoonrakerPrinterAgent::start_sdcard_print(std::move(params), update_fn, cancel_fn);
}
bool QidiPrinterAgent::fetch_slot_info(const ConnectionSettings& connection,
const QidiFilamentDict& dict,
const std::string& series_id,
@@ -321,16 +349,9 @@ bool QidiPrinterAgent::fetch_slot_info(const ConnectionSettings& connection,
}
tray.tray_type = normalize_filament_type(filament_name);
// Try Qidi-specific setting ID first; fall back to visible preset by type
std::string setting_id = build_setting_id(filament_type, vendor_type, tray.tray_type);
auto* bundle = GUI::wxGetApp().preset_bundle;
if (!bundle) {
tray.tray_info_idx = setting_id;
} else if (!setting_id.empty() && has_visible_base_preset(bundle->filaments, setting_id)) {
tray.tray_info_idx = setting_id;
} else {
tray.tray_info_idx = bundle->filaments.filament_id_by_type(tray.tray_type);
}
// Qidi-specific setting id derived from device data only; the preset-dependent
// fallback runs on the main thread in the resolve hook passed to build_ams_payload.
tray.tray_info_idx = build_setting_id(filament_type, vendor_type, tray.tray_type);
// Look up color from dictionary
auto color_it = dict.colors.find(color_index);
+2 -3
View File
@@ -30,10 +30,9 @@ public:
std::string& error);
// Print operations — emit QiDi multi-color box config, then delegate to base.
int start_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) override;
// Only the LAN print path is supported by the base; the cloud/record/sdcard variants
// are inherited and return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED.
int start_local_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn) override;
int start_local_print_with_record(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) override;
int start_sdcard_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn) override;
FilamentSyncMode get_filament_sync_mode() const override;
+75 -38
View File
@@ -10,6 +10,7 @@
#include <atomic>
#include <boost/log/trivial.hpp>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <exception>
#include <mutex>
@@ -51,7 +52,21 @@ struct InFlightGuard
int read_int_or(const nlohmann::json& obj, const char* key, int fallback)
{
auto it = obj.find(key);
return (it != obj.end() && it->is_number_integer()) ? it->get<int>() : fallback;
if (it == obj.end()) {
return fallback;
}
if (it->is_number_integer()) {
return it->get<int>();
}
// Firmware may report an integral field as a JSON float (e.g. 2.0). Accept it only when it
// has no fractional part, so a genuinely fractional value falls back instead of truncating.
if (it->is_number_float()) {
const double value = it->get<double>();
if (value == std::floor(value)) {
return static_cast<int>(value);
}
}
return fallback;
}
std::vector<std::string> read_string_array_or(const nlohmann::json& obj, const char* key)
@@ -225,18 +240,11 @@ bool SnapmakerPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSync
// device_info meanwhile.
const ConnectionSettings connection = get_connection_settings();
// Reserve under the same mutex shutdown() uses, so the flag and the count can't race.
{
std::lock_guard<std::mutex> lock(fetch_lifecycle_mutex);
if (shutting_down.load())
return false;
if (filament_fetch_in_flight.load() > 0)
return true; // a fetch is already running; don't pile on
filament_fetch_in_flight.fetch_add(1, std::memory_order_relaxed);
}
InFlightGuard guard{*this};
std::thread([this, guard = std::move(guard), connection]() {
// One implementation for both modes; only where it runs differs. pull is the documented
// blocking contract (the GUI thread reads DevFilaSystem right after the call), so it runs
// on the caller with the payload applied inline. subscription runs on a background thread
// so the status loop is not stalled, and the payload is marshalled back to the main thread.
auto work = [this, connection](bool apply_inline) -> bool {
try {
const std::string url = join_url(connection.base_url, "/printer/objects/query?print_task_config&filament_detect");
@@ -269,19 +277,19 @@ bool SnapmakerPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSync
if (!success) {
BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: HTTP request failed: " << http_error;
return;
return false;
}
auto json = nlohmann::json::parse(response_body, nullptr, false, true);
if (json.is_discarded()) {
BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: Invalid JSON response";
return;
return false;
}
// Navigate to result.status.print_task_config
if (!json.contains("result") || !json["result"].contains("status") || !json["result"]["status"].contains("print_task_config")) {
BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: Missing print_task_config in response";
return;
return false;
}
auto& ptc = json["result"]["status"]["print_task_config"];
@@ -296,7 +304,7 @@ bool SnapmakerPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSync
const int slot_count = static_cast<int>(filament_exist.size());
if (slot_count == 0) {
BOOST_LOG_TRIVIAL(info) << "SnapmakerPrinterAgent::fetch_filament_info: No filament slots reported";
return;
return false;
}
// Read NFC filament_detect data for temperature info (optional)
@@ -308,6 +316,9 @@ bool SnapmakerPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSync
static const std::string empty_str;
static const std::string default_color = "FFFFFFFF";
// Per-tray vendor names, kept so the preset lookup can run on the main thread.
std::vector<std::string> vendors(slot_count);
std::vector<AmsTrayData> trays;
trays.reserve(slot_count);
@@ -319,25 +330,11 @@ bool SnapmakerPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSync
if (tray.has_filament) {
tray.tray_type = combine_filament_type(safe_at(filament_type, i, empty_str), safe_at(filament_sub_type, i, empty_str));
tray.tray_color = safe_at(filament_color, i, default_color);
vendors[i] = safe_at(filament_vendor, i, empty_str);
auto* bundle = GUI::wxGetApp().preset_bundle;
// Try to find a matching preset for this filament based on vendor, type and color.
// If not found, default to traditional search by type only or generic type mapping.
if (bundle) {
std::string vendor = safe_at(filament_vendor, i, empty_str);
std::string filament_id = find_closest_color_preset_by_vendor_and_type(bundle->filaments, vendor, tray.tray_type,
tray.tray_color);
if (!filament_id.empty()) {
tray.tray_info_idx = filament_id;
BOOST_LOG_TRIVIAL(warning)
<< "Filament sync: Found manufacturer-specific profile for slot " << i << ": " << filament_id;
} else {
tray.tray_info_idx = bundle->filaments.filament_id_by_type(tray.tray_type);
}
} else {
tray.tray_info_idx = map_filament_type_to_generic_id(tray.tray_type);
}
// Generic id derived from device data only; the preset-dependent lookup runs
// on the main thread in the resolve hook passed to build_ams_payload.
tray.tray_info_idx = map_filament_type_to_generic_id(tray.tray_type);
// Extract NFC temperature data if available
if (nfc_info.is_array() && i < static_cast<int>(nfc_info.size()) && nfc_info[i].is_object()) {
@@ -356,22 +353,62 @@ bool SnapmakerPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSync
trays.emplace_back(std::move(tray));
}
build_ams_payload(1, slot_count - 1, trays);
// Resolve against GUI-owned presets on the main thread (inside build_ams_payload).
auto resolve_ids = [vendors](std::vector<AmsTrayData>& resolved_trays) {
auto* bundle = GUI::wxGetApp().preset_bundle;
if (!bundle) {
return; // keep the generic ids computed from device data
}
for (size_t i = 0; i < resolved_trays.size() && i < vendors.size(); ++i) {
AmsTrayData& tray = resolved_trays[i];
if (!tray.has_filament) {
continue;
}
// Prefer a manufacturer/colour-specific preset, else fall back to type.
std::string filament_id = find_closest_color_preset_by_vendor_and_type(bundle->filaments, vendors[i], tray.tray_type,
tray.tray_color);
tray.tray_info_idx = filament_id.empty() ? bundle->filaments.filament_id_by_type(tray.tray_type) : filament_id;
}
};
build_ams_payload(1, slot_count - 1, trays, apply_inline, resolve_ids);
return true;
} catch (const std::exception& e) {
// why: an exception escaping a detached thread is std::terminate, and firmware
// JSON is untrusted; mirror run_command_worker and swallow it here.
BOOST_LOG_TRIVIAL(error) << "SnapmakerPrinterAgent::fetch_filament_info: unhandled exception: " << e.what();
return false;
} catch (...) {
BOOST_LOG_TRIVIAL(error) << "SnapmakerPrinterAgent::fetch_filament_info: unhandled exception";
return false;
}
}).detach();
};
if (sync_mode == FilamentSyncMode::pull) {
return work(/*apply_inline=*/true);
}
// Subscription: fire-and-forget. A `true` return means the refresh was scheduled (or is
// already running), not that DevFilaSystem has been updated. Reserve under the same mutex
// shutdown() uses, so the flag and the count can't race.
{
std::lock_guard<std::mutex> lock(fetch_lifecycle_mutex);
if (shutting_down.load())
return false;
if (filament_fetch_in_flight.load() > 0)
return true; // a fetch is already running; don't pile on
filament_fetch_in_flight.fetch_add(1, std::memory_order_relaxed);
}
InFlightGuard guard{*this};
std::thread([work = std::move(work), guard = std::move(guard)]() mutable {
work(/*apply_inline=*/false);
}).detach();
return true;
}
std::string SnapmakerPrinterAgent::get_camera_url() const
{
return get_connection_settings().base_url + "/server/files/camera/monitor.jpg";
return join_url(get_connection_settings().base_url, "/server/files/camera/monitor.jpg");
}
FilamentSyncMode SnapmakerPrinterAgent::get_filament_sync_mode() const
+28 -24
View File
@@ -10,7 +10,6 @@
#include <catch2/catch_test_macros.hpp>
#include <pybind11/pytypes.h>
#include <catch2/catch_message.hpp>
#include "catch2/catch_approx.hpp"
#include "python_test_support.hpp"
#include <pybind11/embed.h>
@@ -66,7 +65,7 @@ public:
explicit MoonrakerParserProbe(std::string log_dir) : MoonrakerPrinterAgent(std::move(log_dir)) {}
};
TEST_CASE("Moonraker parses nozzle diameter from configfile settings", "[unit][moonraker]")
TEST_CASE("Moonraker parses nozzle diameter from configfile settings", "[MoonrakerPrinterAgent]")
{
const auto response = nlohmann::json::parse(R"({
"result": {
@@ -82,10 +81,10 @@ TEST_CASE("Moonraker parses nozzle diameter from configfile settings", "[unit][m
}
})");
CHECK(MoonrakerParserProbe::parse_nozzle_diameter(response) == Catch::Approx(0.6f));
CHECK_THAT(MoonrakerParserProbe::parse_nozzle_diameter(response), Catch::Matchers::WithinAbs(0.6f, 1e-4f));
}
TEST_CASE("Moonraker parses nozzle diameter from raw config and tolerates missing data", "[unit][moonraker]")
TEST_CASE("Moonraker parses nozzle diameter from raw config and tolerates missing data", "[MoonrakerPrinterAgent]")
{
const auto raw_config_response = nlohmann::json::parse(R"({
"result": {
@@ -102,12 +101,12 @@ TEST_CASE("Moonraker parses nozzle diameter from raw config and tolerates missin
})");
const auto missing_response = nlohmann::json::object();
CHECK(MoonrakerParserProbe::parse_nozzle_diameter(raw_config_response) == Catch::Approx(0.8f));
CHECK(MoonrakerParserProbe::parse_nozzle_diameter(missing_response) == 0.0f);
CHECK_THAT(MoonrakerParserProbe::parse_nozzle_diameter(raw_config_response), Catch::Matchers::WithinAbs(0.8f, 1e-4f));
CHECK_THAT(MoonrakerParserProbe::parse_nozzle_diameter(missing_response), Catch::Matchers::WithinAbs(0.0f, 1e-6f));
}
// why: an agent without a Bambu-dialect translation must refuse these commands before any network or wx path.
TEST_CASE("unit: default AMS commands report not supported", "[unit][moonraker]")
TEST_CASE("default AMS commands report not supported", "[MoonrakerPrinterAgent]")
{
MoonrakerPrinterAgent agent("");
@@ -116,7 +115,7 @@ TEST_CASE("unit: default AMS commands report not supported", "[unit][moonraker]"
CHECK(agent.command_ams_select_tray("dev", "123", 3, false) == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
}
TEST_CASE("unit: Moonraker light name matching", "[unit][moonraker]")
TEST_CASE("Moonraker light name matching", "[MoonrakerPrinterAgent]")
{
CHECK(moonraker_is_light_name("caselight"));
CHECK(moonraker_is_light_name("LED_STRIP"));
@@ -126,7 +125,7 @@ TEST_CASE("unit: Moonraker light name matching", "[unit][moonraker]")
}
TEST_CASE("Moonraker webcam selection skips disabled webcams and prefers the first enabled one",
"[unit][moonraker]")
"[MoonrakerPrinterAgent]")
{
const auto response = nlohmann::json::parse(R"({
"result": { "webcams": [
@@ -144,7 +143,7 @@ TEST_CASE("Moonraker webcam selection skips disabled webcams and prefers the fir
}
TEST_CASE("Moonraker webcam selection resolves relative URLs, maps rtsp, and rejects other schemes",
"[unit][moonraker]")
"[MoonrakerPrinterAgent]")
{
const auto relative = nlohmann::json::parse(R"({
"result": { "webcams": [ { "name": "cam", "snapshot_url": "/webcam/?action=snapshot" } ] }
@@ -170,7 +169,7 @@ TEST_CASE("Moonraker webcam selection resolves relative URLs, maps rtsp, and rej
CHECK(bad.error == "Unsupported webcam URL");
}
TEST_CASE("Moonraker webcam selection reports no webcam and malformed structure", "[unit][moonraker]")
TEST_CASE("Moonraker webcam selection reports no webcam and malformed structure", "[MoonrakerPrinterAgent]")
{
const auto empty = nlohmann::json::parse(R"({ "result": { "webcams": [] } })");
MoonrakerWebcamSelection none;
@@ -198,7 +197,7 @@ TEST_CASE("Moonraker webcam selection reports no webcam and malformed structure"
// roughly once a second, so it must stay a success or it would raise a dialog on a
// timer. Only branches that touch neither the network nor wx are exercised.
// ===========================================================================
TEST_CASE("unit: Moonraker reports untranslated commands as not supported", "[unit][moonraker]")
TEST_CASE("Moonraker reports untranslated commands as not supported", "[MoonrakerPrinterAgent]")
{
MoonrakerPrinterAgent agent("");
@@ -217,12 +216,13 @@ TEST_CASE("unit: Moonraker reports untranslated commands as not supported", "[un
CHECK(agent.send_message("dev", "{not json", 0, 0) == BAMBU_NETWORK_ERR_INVALID_RESULT);
}
// why: IPrinterAgent::fetch_filament_info is the single virtual hook derived agents override
// (MoonrakerPrinterAgent's own override is synchronous, but QidiPrinterAgent's override is
// fire-and-forget: it spawns a detached thread and returns immediately). QidiPrinterAgent is
// `final`, so this probes the same contract with a controllable double instead.
TEST_CASE("unit: a fire-and-forget override of fetch_filament_info is not waited on by the caller",
"[unit][moonraker]")
// why: IPrinterAgent::fetch_filament_info is the single virtual hook derived agents override.
// Pull-mode overrides must be synchronous (the caller reads DevFilaSystem as soon as it
// returns), but subscription overrides may be fire-and-forget: QidiPrinterAgent/Snapmaker
// spawn a detached thread when asked for subscription updates. QidiPrinterAgent is `final`,
// so this probes the subscription contract with a controllable double instead.
TEST_CASE("a fire-and-forget subscription fetch is not waited on by the caller",
"[MoonrakerPrinterAgent]")
{
class RecordingAgent : public Slic3r::MoonrakerPrinterAgent
{
@@ -235,8 +235,12 @@ TEST_CASE("unit: a fire-and-forget override of fetch_filament_info is not waited
std::shared_ptr<std::promise<void>> release_gate{std::make_shared<std::promise<void>>()};
std::shared_ptr<std::promise<void>> done_promise{std::make_shared<std::promise<void>>()};
bool fetch_filament_info(std::string /*dev_id*/, FilamentSyncMode /*sync_mode*/ = FilamentSyncMode::pull) override
bool fetch_filament_info(std::string /*dev_id*/, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override
{
// Only the subscription path is allowed to be fire-and-forget.
if (sync_mode != FilamentSyncMode::subscription)
return true;
auto invoked_p = invoked;
auto release_gate_p = release_gate;
auto done_promise_p = done_promise;
@@ -255,7 +259,7 @@ TEST_CASE("unit: a fire-and-forget override of fetch_filament_info is not waited
auto done_future = agent->done_promise->get_future();
ScopedPromiseRelease release_gate_guard{agent->release_gate};
bool immediate_result = agent->fetch_filament_info("test-dev");
bool immediate_result = agent->fetch_filament_info("test-dev", FilamentSyncMode::subscription);
// fetch_filament_info must return before its background work completes — prove
// it by confirming the background call is still blocked on the gate right now.
@@ -353,7 +357,7 @@ public:
// The command worker parks a fetch before it reserves the in-flight slot, forcing
// the "wait already observed zero" interleaving deterministically.
TEST_CASE("an agent's destruction waits for a fetch started by its worker during teardown",
"[unit][moonraker][Regression]")
"[MoonrakerPrinterAgent][Regression]")
{
g_deferred_fetch_running.store(0);
g_deferred_destroy_returned.store(false);
@@ -409,7 +413,7 @@ TEST_CASE("an agent's destruction waits for a fetch started by its worker during
// Confirms a duplicate agent id is rejected so a plugin cannot shadow a built-in
// or previously registered agent.
// ===========================================================================
TEST_CASE("unit: printer-agent registry register / lookup / duplicate-reject", "[registry][unit]")
TEST_CASE("printer-agent registry register / lookup / duplicate-reject", "[PrinterAgent]")
{
// why: the registry is process-global state shared by the test binary, and
// Catch2 may run cases in any order. Use an id that cannot collide with
@@ -453,7 +457,7 @@ TEST_CASE("unit: printer-agent registry register / lookup / duplicate-reject", "
// fails to load the codecs needed for the filesystem encoding. The shared helper
// points PyConfig.home at the python/ runtime staged next to the test executable.
TEST_CASE("integration: orca.printer_agent binding surface", "[integration][Python]")
TEST_CASE("orca.printer_agent binding surface", "[integration][Python]")
{
py::module_ orca = import_orca_module();
@@ -504,7 +508,7 @@ TEST_CASE("integration: orca.printer_agent binding surface", "[integration][Pyth
// them in the lightweight embedded-interpreter test catches binding breakage
// before the plugin-loader test needs to run.
// ===========================================================================
TEST_CASE("integration: orca plugin-registration API surface + discovery-context guards", "[integration][Python]")
TEST_CASE("orca plugin-registration API surface + discovery-context guards", "[integration][Python]")
{
py::module_ orca = import_orca_module();