Compare commits

..
Author SHA1 Message Date
Hanif Koh aecb863493 Log Instead of Showing Info and Substitution Dialogs When Opening a BambuStudio Project
Every BambuStudio project opened with a "BambuStudio Project" info dialog
(or, from BambuStudio 2.8.2, one saying the file is newer than the
compatible version and to update the software), followed by the
configuration-substitution dialogs for the project and its embedded
presets. None of them asks anything and all of them fire for every
BambuStudio file.

For BambuStudio projects (untagged files newer than 2.3.2, the existing
test) log the version with the unrecognized settings, and each replaced
value, instead. The geometry-only, invalid-values and G-code safety
dialogs stay, and other 3MFs are unchanged.
2026-10-05 16:29:13 +08:00
13 changed files with 385 additions and 1980 deletions
+33 -28
View File
@@ -8859,6 +8859,20 @@ void read_binary_stl(const std::string& filename, std::string& model_id, std::st
return;
}
// Logs what show_substitutions_info() would list, for loads that don't show the dialog.
static void log_substitutions(const ConfigSubstitutions& substitutions, const std::string& source)
{
for (const ConfigSubstitution& substitution : substitutions)
BOOST_LOG_TRIVIAL(warning) << "Loading " << source << ": " << substitution.opt_def->opt_key << " = \"" << substitution.old_value
<< "\" replaced with \"" << substitution.new_value->serialize() << "\"";
}
static void log_substitutions(const PresetsConfigSubstitutions& substitutions, const std::string& source)
{
for (const PresetConfigSubstitutions& preset : substitutions)
log_substitutions(preset.substitutions, source + " (preset " + preset.preset_name + ")");
}
// BBS: backup & restore
std::vector<size_t> Plater::priv::load_files(const std::vector<fs::path>& input_files,
LoadStrategy strategy,
@@ -9160,7 +9174,6 @@ std::vector<size_t> Plater::priv::load_files(const std::vector<fs::path>& input_
Semver app_version = *(Semver::parse(SoftFever_VERSION));
const wxString load_3mf_title = _L("Load 3MF");
const wxString newer_3mf_title = _L("Newer 3MF version");
const wxString bambu_project_title = _L("BambuStudio Project");
const wxString msg_unsupported_geometry = _L("The 3MF is not supported by OrcaSlicer, loading geometry data only.");
const wxString msg_old_orca_geometry = _L("The 3MF file was generated by an old OrcaSlicer version, loading geometry data only.");
const wxString msg_older_geometry = _L("The 3MF file was generated by an older version, loading geometry data only.");
@@ -9171,6 +9184,8 @@ std::vector<size_t> Plater::priv::load_files(const std::vector<fs::path>& input_
<< boost::format("3MF import message [%1%]: %2% | file: %3%") % into_u8(title) % into_u8(text) % path.string();
show_info(q, text, title);
};
// Untagged files up to 2.3.2 may also come from OrcaSlicer, which only started tagging its 3MFs after it.
const bool is_bambu_studio_project = en_3mf_file_type == En3mfType::From_BBS && file_version > Semver(2, 3, 2);
if (en_3mf_file_type == En3mfType::From_Prusa) {
// do not reset the model config
load_config = false;
@@ -9230,8 +9245,7 @@ std::vector<size_t> Plater::priv::load_files(const std::vector<fs::path>& input_
}
else if (en_3mf_file_type == En3mfType::From_BBS) {
// No OrcaSlicer tag - check Bambu/Application version
Semver orca_tag_start_version(2, 3, 2);
if (file_version <= orca_tag_start_version) {
if (!is_bambu_studio_project) {
// Compatible old version (before OrcaSlicer tagging was introduced after 2.3.2).
// Any version prior or equal to 2.3.2 is older than the current one, no version warnings needed.
// Still apply migration fixes for known old versions.
@@ -9264,33 +9278,17 @@ std::vector<size_t> Plater::priv::load_files(const std::vector<fs::path>& input_
}
} else {
// BambuStudio project (version > 2.3.2 without OrcaSlicer tag)
// Report that a BambuStudio project is being imported and compare with SLIC3R_VERSION
Semver slic3r_version = *(Semver::parse(SLIC3R_VERSION));
if (load_config && config_loaded.empty()) {
load_config = false;
log_and_show_3mf_info(msg_bambu_geometry, load_3mf_title);
}
else if (load_config && (file_version > slic3r_version)) {
// BambuStudio file version is newer than our compatible SLIC3R_VERSION
if (config_substitutions.unrecogized_keys.size() > 0) {
wxString text = wxString::Format(_L("The 3MF was created by BambuStudio (version %s), which is newer than the compatible version %s. Found unrecognized settings:"),
file_version.to_string(), slic3r_version.to_string());
text += "\n";
wxString context = text;
wxString append = _L("You should update your software.\n");
context += "\n\n";
context += append;
log_and_show_3mf_info(context, bambu_project_title);
} else {
wxString text = wxString::Format(_L("The 3MF was created by BambuStudio (version %s), which is newer than the compatible version %s. Some settings may not be fully compatible."),
file_version.to_string(), slic3r_version.to_string());
text += "\n";
log_and_show_3mf_info(text, bambu_project_title);
}
} else if (load_config && !published_config.published) {
// BambuStudio version is older or same as our SLIC3R_VERSION
wxString text = _L("The 3MF was created by BambuStudio. Some settings may differ from OrcaSlicer.");
log_and_show_3mf_info(text, bambu_project_title);
else if (load_config) {
// Logged, not shown: it is the same for every BambuStudio project and needs no action.
std::string unrecognized;
for (const std::string& key : config_substitutions.unrecogized_keys)
unrecognized += (unrecognized.empty() ? "" : ", ") + key;
BOOST_LOG_TRIVIAL(info) << "BambuStudio " << file_version.to_string() << " project " << path.string()
<< ", unrecognized settings: " << (unrecognized.empty() ? "none" : unrecognized);
}
}
}
@@ -9379,7 +9377,10 @@ std::vector<size_t> Plater::priv::load_files(const std::vector<fs::path>& input_
PresetsConfigSubstitutions preset_substitutions;
PresetBundle & preset_bundle = *wxGetApp().preset_bundle;
preset_substitutions = preset_bundle.load_project_embedded_presets(project_presets, ForwardCompatibilitySubstitutionRule::Enable);
if (!preset_substitutions.empty()) show_substitutions_info(preset_substitutions);
if (is_bambu_studio_project)
log_substitutions(preset_substitutions, path.string());
else if (!preset_substitutions.empty())
show_substitutions_info(preset_substitutions);
}
if (project_presets.size() > 0) {
for (unsigned int i = 0; i < project_presets.size(); i++) { delete project_presets[i]; }
@@ -9417,7 +9418,11 @@ std::vector<size_t> Plater::priv::load_files(const std::vector<fs::path>& input_
notify_manager->bbl_show_3mf_warn_notification(error_message);
}
}
if (!config_substitutions.empty()) show_substitutions_info(config_substitutions.substitutions, filename.string());
// BambuStudio projects routinely carry values Orca replaces; log them rather than showing a dialog on every open.
if (is_bambu_studio_project)
log_substitutions(config_substitutions.substitutions, path.string());
else if (!config_substitutions.empty())
show_substitutions_info(config_substitutions.substitutions, filename.string());
// BBS
if (load_model && !load_config) {
+6 -16
View File
@@ -203,9 +203,8 @@ int BBLPrinterAgent::command_axis_control(std::string dev_id, std::string axis,
int dir = input_val > 0 ? 1 : -1;
// i3-arch printers move the bed for Y/Z, so the on-screen direction is
// reversed -- same negation the g-code fallback below applies.
if (!is_core_xy && (axis == "Y" || axis == "Z")) {
if (!is_core_xy && (axis == "Y" || axis == "Z"))
dir = -dir;
}
j["print"]["command"] = "xyz_ctrl";
j["print"]["axis"] = axis;
@@ -215,9 +214,8 @@ int BBLPrinterAgent::command_axis_control(std::string dev_id, std::string axis,
}
double value = input_val;
if (!is_core_xy && (axis == "Y" || axis == "Z")) {
value = -1.0 * input_val;
}
if (!is_core_xy && (axis == "Y" || axis == "Z"))
value = -input_val;
std::string value_str = (boost::format("%.1f") % (value * unit)).str();
std::string gcode;
@@ -238,11 +236,10 @@ int BBLPrinterAgent::command_axis_control(std::string dev_id, std::string axis,
int BBLPrinterAgent::publish(const std::string& dev_id, const nlohmann::json& j, bool lan_mode)
{
const int rtn = lan_mode ? send_message_to_printer(dev_id, j.dump(), 0, 0) : send_message(dev_id, j.dump(), 0, 0);
if (rtn == 0) {
if (rtn == 0)
BOOST_LOG_TRIVIAL(info) << "publish_json: " << j.dump() << " code: " << rtn;
} else {
else
BOOST_LOG_TRIVIAL(error) << "publish_json: " << j.dump() << " code: " << rtn;
}
return rtn;
}
@@ -588,15 +585,8 @@ int BBLPrinterAgent::start_local_print_with_record(PrintParams params, OnUpdateS
int BBLPrinterAgent::start_send_gcode_to_sdcard(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn)
{
int result = dispatch_start<func_start_send_gcode_to_sdcard_legacy, func_start_send_gcode_to_sdcard_0203>(
return dispatch_start<func_start_send_gcode_to_sdcard_legacy, func_start_send_gcode_to_sdcard_0203>(
BBLNetworkPlugin::instance().get_start_send_gcode_to_sdcard(), params, update_fn, cancel_fn, wait_fn);
if (result != 0) {
BOOST_LOG_TRIVIAL(error) << "start_send_gcode_to_sdcard failed: result=" << result
<< ", try_emmc_print=" << params.try_emmc_print
<< ", legacy_mode=" << BBLNetworkPlugin::instance().use_legacy_network()
<< ", dev_ip=" << params.dev_ip << ", dev_id=" << params.dev_id;
}
return result;
}
int BBLPrinterAgent::start_local_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn)
-1
View File
@@ -109,7 +109,6 @@ public:
static std::string from_orca_payload(std::string json_text);
private:
// why: the lan/cloud DECISION stays machine-side; keep this mechanical branch in sync with publish_json.
int publish(const std::string& dev_id, const nlohmann::json& j, bool lan_mode);
};
+1 -4
View File
@@ -289,11 +289,8 @@ bool CrealityPrintAgent::parse_cfs_response(const std::string& response,
return true;
}
bool CrealityPrintAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode)
bool CrealityPrintAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode /*sync_mode*/)
{
if (sync_mode != get_filament_sync_mode())
return false;
if (device_info.dev_ip.empty()) {
BOOST_LOG_TRIVIAL(warning)
<< "CrealityPrintAgent::fetch_filament_info: no device IP, falling back to base agent";
File diff suppressed because it is too large Load Diff
+19 -128
View File
@@ -13,49 +13,12 @@
#include <set>
#include <string>
#include <thread>
#include <chrono>
#include <condition_variable>
#include <deque>
#include <functional>
#include <nlohmann/json.hpp>
#include <vector>
namespace Slic3r {
class Http;
bool moonraker_is_light_name(const std::string& name);
class MoonrakerWebsocket
{
public:
enum class ReadResult
{
message,
timeout,
closed,
error,
};
MoonrakerWebsocket(bool secure, std::string api_key, std::string ca_file);
~MoonrakerWebsocket();
void connect(const std::string& host, const std::string& port, std::chrono::seconds timeout);
void tls_handshake(const std::string& host);
void handshake(const std::string& host, const std::string& target);
void text(bool enabled);
void write(const std::string& body);
ReadResult read(std::string& payload, std::string& error_message);
void close();
void expires_after(std::chrono::seconds timeout);
void abort();
private:
struct Impl;
std::unique_ptr<Impl> m_impl;
};
class MoonrakerPrinterAgent : public IPrinterAgent
{
public:
@@ -101,20 +64,12 @@ public:
int set_on_local_connect_fn(OnLocalConnectedFn fn) override;
int set_on_local_message_fn(OnMessageFn fn) override;
int set_queue_on_main_fn(QueueOnMainFn fn) override;
// Pull-mode agent (on-demand filament sync)
FilamentSyncMode get_filament_sync_mode() const override { return FilamentSyncMode::pull; }
bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override;
CameraStreamMode get_camera_stream_mode() const override;
std::string get_camera_url() const override;
protected:
struct ConnectionSettings
{
std::string dev_id;
std::string base_url;
std::string api_key;
bool use_ssl = false;
std::string ca_file;
};
struct MoonrakerDeviceInfo
{
std::string dev_id;
@@ -126,9 +81,7 @@ protected:
std::string dev_name;
std::string version;
std::string klippy_state;
float nozzle_diameter = 0.0f;
bool use_ssl = false;
std::string ca_file;
} device_info;
// Tray data for AMS payload building
@@ -146,21 +99,12 @@ protected:
void build_ams_payload(int ams_count, int max_lane_index, const std::vector<AmsTrayData>& trays);
// Methods that derived classes may need to override or access
virtual bool init_device_info(const PrinterConnectionParams& params);
virtual bool fetch_device_info(const ConnectionSettings& connection, MoonrakerDeviceInfo& info, std::string& error) const;
ConnectionSettings get_connection_settings() const;
void configure_http(Http& http, const ConnectionSettings& connection) const;
static float parse_nozzle_diameter(const nlohmann::json& response);
virtual bool init_device_info(const std::string& dev_id, const std::string& dev_ip, const std::string& username, const std::string& password, bool use_ssl, const std::string& port);
virtual bool fetch_device_info(const std::string& base_url, const std::string& api_key, MoonrakerDeviceInfo& info, std::string& error) const;
// State access for derived classes
mutable std::recursive_mutex state_mutex;
// Counts detached fetch_filament_info() background threads currently touching `this`
// (see QidiPrinterAgent::fetch_filament_info). Those threads hold a raw `this` with no
// other lifetime protection, so the destructor waits for this to reach 0 before any part
// of the object is torn down — see ~MoonrakerPrinterAgent().
std::atomic<int> filament_fetch_in_flight{0};
// Helpers
bool is_numeric(const std::string& value);
std::string normalize_base_url(bool use_ssl, const std::string& host, const std::string& port);
@@ -173,29 +117,13 @@ protected:
// Map filament type to OrcaFilamentLibrary preset ID for AMS sync compatibility
static std::string map_filament_type_to_generic_id(const std::string& filament_type);
// Send a G-code script via Moonraker (/printer/gcode/script)
bool send_gcode(const std::string& dev_id, const std::string& gcode) const;
bool send_gcode(const std::string& dev_id, const std::string& gcode,
const ConnectionSettings& connection) const;
bool post_print_action(const std::string& action) const;
bool post_print_action(const std::string& action,
const ConnectionSettings& connection) const;
bool send_ws_rpc(const std::string& method, const nlohmann::json& params);
virtual void on_status_loop_tick(const std::string& dev_id) {}
// Queue work that may use agent state. The command worker is joined during
// destruction, so queued commands cannot outlive the agent.
void enqueue_command(std::function<void()> fn);
private:
int handle_request(const std::string& dev_id, const std::string& json_str);
int send_version_info(const std::string& dev_id);
int send_access_code(const std::string& dev_id);
bool fetch_object_list(const ConnectionSettings& connection, std::set<std::string>& objects, std::string& error) const;
bool query_printer_status(const ConnectionSettings& connection, nlohmann::json& status, std::string& error) const;
bool fetch_object_list(const std::string& base_url, const std::string& api_key, std::set<std::string>& objects, std::string& error) const;
bool query_printer_status(const std::string& base_url, const std::string& api_key, nlohmann::json& status, std::string& error) const;
bool send_gcode_sync(const std::string& dev_id, const std::string& gcode) const;
void send_gcode_async(const std::string& dev_id, const std::string& gcode,
std::function<void(bool)> on_result = {}) const;
@@ -204,11 +132,10 @@ private:
void dispatch_local_connect(int state, const std::string& dev_id, const std::string& msg);
void dispatch_printer_connected(const std::string& dev_id);
void dispatch_message(const std::string& dev_id, const std::string& payload);
void start_status_stream(const std::string& dev_id, ConnectionSettings connection);
void start_status_stream(const std::string& dev_id, const std::string& base_url, const std::string& api_key);
void stop_status_stream();
void run_status_stream(std::string dev_id, ConnectionSettings connection);
void handle_ws_message(std::string dev_id, std::string payload, ConnectionSettings connection);
void refresh_thumbnail_url(const ConnectionSettings& connection);
void run_status_stream(std::string dev_id, std::string base_url, std::string api_key);
void handle_ws_message(const std::string& dev_id, const std::string& payload);
void update_status_cache(const nlohmann::json& updates);
nlohmann::json build_print_payload_locked() const;
@@ -219,28 +146,22 @@ private:
// File upload
bool upload_gcode(const std::string& local_path, const std::string& filename,
const ConnectionSettings& connection,
const std::string& base_url, const std::string& api_key,
OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn);
// Start a print of a previously uploaded G-code file (path relative to the
// Moonraker gcodes root).
bool start_print_file(const ConnectionSettings& connection,
const std::string& filename, std::string& error_msg) const;
// JSON-RPC helper
bool send_jsonrpc_command(const std::string& base_url, const std::string& api_key,
const nlohmann::json& request, std::string& response) const;
// Connection thread management
void perform_connection_async(const std::string& dev_id,
ConnectionSettings connection,
const std::string& base_url,
const std::string& api_key,
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;
// System-specific filament fetch methods
bool fetch_hh_filament_info(const ConnectionSettings& connection, std::vector<AmsTrayData>& trays, int& max_lane_index);
bool fetch_moonraker_filament_data(const ConnectionSettings& connection, std::vector<AmsTrayData>& trays, int& max_lane_index);
bool fetch_hh_filament_info(std::vector<AmsTrayData>& trays, int& max_lane_index);
bool fetch_moonraker_filament_data(std::vector<AmsTrayData>& trays, int& max_lane_index);
// JSON helper methods
static std::string safe_json_string(const nlohmann::json& obj, const char* key);
@@ -266,38 +187,15 @@ private:
mutable std::recursive_mutex payload_mutex;
nlohmann::json status_cache;
// note: guarded by payload_mutex; filled by refresh_thumbnail_url(), empty url = looked up, none found
std::string thumbnail_filename;
std::string thumbnail_url;
mutable std::string webcam_stream_url;
mutable CameraStreamMode webcam_stream_mode = CameraStreamMode::none;
mutable uint64_t webcam_info_last_lookup_ms = 0;
mutable uint64_t webcam_info_generation = 0;
unsigned thumbnail_lookup_attempts = 0;
static constexpr uint64_t WEBCAM_INFO_REFRESH_INTERVAL_MS = 1000;
std::atomic<int> next_jsonrpc_id{1};
std::set<std::string> available_objects; // Track for feature detection
bool assumed_light_on = false;
std::atomic<bool> ws_stop{false};
std::atomic<bool> ws_reconnect_requested{false}; // Flag to trigger reconnection
std::atomic<uint64_t> ws_last_emit_ms{0};
std::thread ws_thread;
// stop_status_stream() invokes ws_abort_io to wake a blocked synchronous
// ws.read()/ws.write()/handshake in run_status_stream(): ws_stop is only
// observed between reads, and Beast's expires_after() does not bound
// synchronous operations.
std::mutex ws_abort_mutex;
std::function<void()> ws_abort_io; // guarded by ws_abort_mutex
// AMS/filament refresh cadence, independent of telemetry dispatch so a steady
// stream of status updates can't starve it (ws_last_emit_ms is reset by those).
static constexpr uint64_t AMS_REFRESH_INTERVAL_MS = 10000;
std::atomic<uint64_t> ams_last_fetch_ms{0};
// Throttling configuration for WebSocket updates
// Critical changes (state transitions) dispatch immediately; telemetry is throttled
static constexpr uint64_t STATUS_UPDATE_INTERVAL_MS = 1000; // 1 update/sec for telemetry
@@ -307,14 +205,7 @@ private:
// Connection thread management
std::atomic<uint64_t> connect_generation{0};
std::thread connect_thread;
mutable std::recursive_mutex connect_mutex;
void run_command_worker();
std::thread cmd_thread;
std::deque<std::function<void()>> cmd_queue;
std::mutex cmd_mutex;
std::condition_variable cmd_cv;
bool cmd_stop = false;
std::recursive_mutex connect_mutex;
};
} // namespace Slic3r
+52 -175
View File
@@ -14,7 +14,6 @@
#include "libslic3r/Preset.hpp"
#include <map>
#include <sstream>
#include <thread>
#include <string>
#include <utility>
#include <vector>
@@ -37,15 +36,6 @@ bool has_visible_base_preset(const PresetCollection& filaments, const std::strin
return false;
}
// RAII decrement for MoonrakerPrinterAgent::filament_fetch_in_flight — guarantees the
// counter drops back down on every exit path (early return or fall-through) inside the
// detached fetch thread below, so ~MoonrakerPrinterAgent()'s wait loop can't stall forever.
struct InFlightGuard
{
std::atomic<int>& counter;
~InFlightGuard() { counter.fetch_sub(1, std::memory_order_relaxed); }
};
} // anonymous namespace
const std::string QidiPrinterAgent_VERSION = "0.0.1";
@@ -59,148 +49,51 @@ AgentInfo QidiPrinterAgent::get_agent_info_static()
return AgentInfo{"qidi", "Qidi", QidiPrinterAgent_VERSION, "Qidi printer agent"};
}
FilamentSyncMode QidiPrinterAgent::get_filament_sync_mode() const
bool QidiPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode /*sync_mode*/)
{
if (GUI::wxGetApp().app_config->get_bool("use_printer_agents"))
return FilamentSyncMode::subscription;
return FilamentSyncMode::pull;
}
std::string error;
bool QidiPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode)
{
if (sync_mode != get_filament_sync_mode())
// 1. Fetch device info and infer series_id
std::string series_id;
{
MoonrakerDeviceInfo info;
if (fetch_device_info(device_info.base_url, device_info.api_key, info, error)) {
series_id = infer_series_id(info.model_id, info.dev_name);
}
}
if (series_id.empty()) {
// Fall back to the configured Orca model if Moonraker doesn't expose a usable identifier.
series_id = infer_series_id(device_info.model_id, device_info.model_name);
}
// 2. Fetch filament dictionary
QidiFilamentDict dict;
if (!fetch_filament_dict(device_info.base_url, device_info.api_key, dict, error)) {
BOOST_LOG_TRIVIAL(warning) << "QidiPrinterAgent::fetch_filament_info: Failed to fetch filament dict: " << error;
}
// 3. Fetch slot info and build AmsTrayData directly
std::vector<AmsTrayData> trays;
int box_count = 0;
if (!fetch_slot_info(device_info.base_url, device_info.api_key, dict, series_id, trays, box_count, error)) {
BOOST_LOG_TRIVIAL(warning) << "QidiPrinterAgent::fetch_filament_info: Failed to fetch slot info: " << error;
return false;
}
// Snapshot only what the fetch needs, rather than reading device_info live from the
// background thread below — device_info can be concurrently rewritten by a reconnect
// on another thread while this fetch is still in flight.
ConnectionSettings connection = get_connection_settings();
std::string model_id = device_info.model_id;
std::string model_name = device_info.model_name;
filament_fetch_in_flight.fetch_add(1, std::memory_order_relaxed);
std::thread([this, connection = std::move(connection), model_id, model_name]() mutable {
InFlightGuard guard{filament_fetch_in_flight};
std::string error;
// 1. Fetch device info and infer series_id
std::string series_id;
{
MoonrakerDeviceInfo info;
if (fetch_device_info(connection, info, error)) {
series_id = infer_series_id(info.model_id, info.dev_name);
}
}
if (series_id.empty()) {
// Fall back to the configured Orca model if Moonraker doesn't expose a usable identifier.
series_id = infer_series_id(model_id, model_name);
}
// 2. Fetch filament dictionary
QidiFilamentDict dict;
if (!fetch_filament_dict(connection, dict, error)) {
BOOST_LOG_TRIVIAL(warning) << "QidiPrinterAgent::fetch_filament_info: Failed to fetch filament dict: " << error;
}
// 3. Fetch slot info and build AmsTrayData directly
std::vector<AmsTrayData> trays;
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;
}
// 4. Build the AMS payload
build_ams_payload(box_count, box_count * 4 - 1, trays);
}).detach();
// 4. Build the AMS payload
build_ams_payload(box_count, box_count * 4 - 1, trays);
return true;
}
bool QidiPrinterAgent::apply_box_mapping(const PrintParams& params) const
{
// enable_box mirrors task_use_ams: engage the multi-color box only when this
// job actually routes filament through it. (See qidi-ams-findings.md §2/§8.3 —
// if firmware treats enable_box as "a box exists" rather than "use it this job",
// switch this gate to HasAms()/box_count instead.)
const int enable = params.task_use_ams ? 1 : 0;
if (!send_gcode(device_info.dev_id, "SAVE_VARIABLE VARIABLE=enable_box VALUE=" + std::to_string(enable))) {
BOOST_LOG_TRIVIAL(error) << "QidiPrinterAgent::apply_box_mapping: failed to set enable_box";
return false;
}
// When the box isn't used this job, leave the existing value_t<tool> slot
// assignments untouched (enable_box=0 is enough to disengage it).
if (!enable)
return true;
if (params.ams_mapping.empty()) {
BOOST_LOG_TRIVIAL(warning) << "QidiPrinterAgent::apply_box_mapping: enable_box set but ams_mapping is empty";
return true;
}
// ams_mapping (v0) is a JSON array indexed by filament/tool; each value is the
// physical box slot (-1 = unmapped). Mirror it onto the printer's value_t<tool>
// variables: SAVE_VARIABLE VARIABLE=value_t<tool> VALUE='slot<n>'.
auto mapping = nlohmann::json::parse(params.ams_mapping, nullptr, /*allow_exceptions*/ false);
if (mapping.is_discarded() || !mapping.is_array()) {
BOOST_LOG_TRIVIAL(error) << "QidiPrinterAgent::apply_box_mapping: invalid ams_mapping: " << params.ams_mapping;
return false;
}
for (size_t tool = 0; tool < mapping.size(); ++tool) {
if (!mapping[tool].is_number_integer())
continue;
const int slot = mapping[tool].get<int>();
if (slot < 0)
continue; // unmapped filament — skip
const std::string gcode = "SAVE_VARIABLE VARIABLE=value_t" + std::to_string(tool) +
" VALUE=\"'slot" + std::to_string(slot) + "'\"";
if (!send_gcode(device_info.dev_id, gcode)) {
BOOST_LOG_TRIVIAL(error) << "QidiPrinterAgent::apply_box_mapping: failed to set value_t" << tool;
return false;
}
}
return true;
}
int QidiPrinterAgent::start_local_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_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)
{
if (!apply_box_mapping(params))
return BAMBU_NETWORK_ERR_PRINT_WR_UPLOAD_FTP_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,
bool QidiPrinterAgent::fetch_slot_info(const std::string& base_url,
const std::string& api_key,
const QidiFilamentDict& dict,
const std::string& series_id,
std::vector<AmsTrayData>& trays,
int& box_count,
std::string& error)
{
std::string url = join_url(connection.base_url, "/printer/objects/query?save_variables=variables");
std::string url = join_url(base_url, "/printer/objects/query?save_variables=variables");
for (int i = 0; i < 16; ++i) {
url += "&box_stepper%20slot" + std::to_string(i) + "=runout_button";
}
@@ -210,9 +103,8 @@ bool QidiPrinterAgent::fetch_slot_info(const ConnectionSettings& connection,
std::string http_error;
auto http = Http::get(url);
configure_http(http, connection);
if (!connection.api_key.empty()) {
http.header("X-Api-Key", connection.api_key);
if (!api_key.empty()) {
http.header("X-Api-Key", api_key);
}
http.timeout_connect(5)
.timeout_max(10)
@@ -237,10 +129,20 @@ bool QidiPrinterAgent::fetch_slot_info(const ConnectionSettings& connection,
return false;
}
nlohmann::json status;
nlohmann::json variables;
if (!parse_slot_response(response_body, status, variables, error))
auto json = nlohmann::json::parse(response_body, nullptr, false, true);
if (json.is_discarded()) {
error = "Invalid JSON response";
return false;
}
if (!json.contains("result") || !json["result"].contains("status") || !json["result"]["status"].contains("save_variables") ||
!json["result"]["status"]["save_variables"].contains("variables")) {
error = "Unexpected JSON structure";
return false;
}
auto& variables = json["result"]["status"]["save_variables"]["variables"];
auto& status = json["result"]["status"];
box_count = variables.value("box_count", 1);
if (box_count < 0) {
@@ -315,45 +217,20 @@ bool QidiPrinterAgent::fetch_slot_info(const ConnectionSettings& connection,
return true;
}
bool QidiPrinterAgent::parse_slot_response(const std::string& response_body,
nlohmann::json& status,
nlohmann::json& variables,
std::string& error)
{
auto json = nlohmann::json::parse(response_body, nullptr, false, true);
if (json.is_discarded()) {
error = "Invalid JSON response";
return false;
}
if (!json.is_object() || !json.contains("result") || !json["result"].is_object() || !json["result"].contains("status") ||
!json["result"]["status"].is_object() || !json["result"]["status"].contains("save_variables") ||
!json["result"]["status"]["save_variables"].is_object() || !json["result"]["status"]["save_variables"].contains("variables") ||
!json["result"]["status"]["save_variables"]["variables"].is_object()) {
// why: Qidi firmware may send null here, but json::value() throws for it.
error = "Unexpected JSON structure: save_variables.variables must be an object";
return false;
}
status = json["result"]["status"];
variables = status["save_variables"]["variables"];
return true;
}
bool QidiPrinterAgent::fetch_filament_dict(const ConnectionSettings& connection,
bool QidiPrinterAgent::fetch_filament_dict(const std::string& base_url,
const std::string& api_key,
QidiFilamentDict& dict,
std::string& error) const
{
std::string url = join_url(connection.base_url, "/server/files/config/officiall_filas_list.cfg");
std::string url = join_url(base_url, "/server/files/config/officiall_filas_list.cfg");
std::string response_body;
bool success = false;
std::string http_error;
auto http = Http::get(url);
configure_http(http, connection);
if (!connection.api_key.empty()) {
http.header("X-Api-Key", connection.api_key);
if (!api_key.empty()) {
http.header("X-Api-Key", api_key);
}
http.timeout_connect(5)
.timeout_max(10)
+3 -19
View File
@@ -3,7 +3,6 @@
#include "IPrinterAgent.hpp"
#include "MoonrakerPrinterAgent.hpp"
#include "nlohmann/json_fwd.hpp"
#include <map>
#include <string>
@@ -23,23 +22,7 @@ public:
// Override filament sync (Qidi-specific implementation)
bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override;
static bool parse_slot_response(const std::string& response_body,
nlohmann::json& status,
nlohmann::json& variables,
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;
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;
private:
// Push enable_box + value_t<tool> SAVE_VARIABLEs before a print starts.
// Returns false if any command fails (caller should abort the print).
bool apply_box_mapping(const PrintParams& params) const;
struct QidiFilamentDict
{
std::map<int, std::string> colors;
@@ -47,13 +30,14 @@ private:
};
// Qidi-specific methods
bool fetch_slot_info(const ConnectionSettings& connection,
bool fetch_slot_info(const std::string& base_url,
const std::string& api_key,
const QidiFilamentDict& dict,
const std::string& series_id,
std::vector<AmsTrayData>& trays,
int& box_count,
std::string& error);
bool fetch_filament_dict(const ConnectionSettings& connection, QidiFilamentDict& dict, std::string& error) const;
bool fetch_filament_dict(const std::string& base_url, const std::string& api_key, QidiFilamentDict& dict, std::string& error) const;
std::string normalize_filament_type(const std::string& filament_type);
std::string infer_series_id(const std::string& model_id, const std::string& dev_name);
std::string normalize_model_key(std::string value);
+105 -162
View File
@@ -7,9 +7,6 @@
#include "nlohmann/json.hpp"
#include <boost/log/trivial.hpp>
#include <chrono>
#include <sstream>
#include <thread>
#include <vector>
#include <string>
#include "libslic3r/Preset.hpp"
@@ -23,13 +20,6 @@ namespace Slic3r {
namespace {
constexpr const char* SNAPMAKER_AGENT_VERSION = "0.0.1";
constexpr int64_t CAMERA_REFRESH_INTERVAL_MS = 300'000;
int64_t now_ms()
{
return std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count();
}
// Safely access a parallel array by index, returning a fallback if out of bounds.
template<typename T>
@@ -86,31 +76,6 @@ std::string find_closest_color_preset_by_vendor_and_type(const PresetCollection&
SnapmakerPrinterAgent::SnapmakerPrinterAgent(std::string log_dir) : MoonrakerPrinterAgent(std::move(log_dir)) {}
void SnapmakerPrinterAgent::start_camera_monitor()
{
enqueue_command([this] {
send_ws_rpc("camera.start_monitor",
{{"domain", "lan"}, {"interval", 0}, {"expect_pw", false}});
});
m_camera_last_fire_ms.store(now_ms());
}
void SnapmakerPrinterAgent::on_status_loop_tick(const std::string& dev_id)
{
(void) dev_id;
const int64_t last = m_camera_last_fire_ms.load();
if (last == 0 || now_ms() - last >= CAMERA_REFRESH_INTERVAL_MS) {
start_camera_monitor();
}
}
int SnapmakerPrinterAgent::command_start_camera(std::string dev_id)
{
(void) dev_id;
start_camera_monitor();
return BAMBU_NETWORK_SUCCESS;
}
AgentInfo SnapmakerPrinterAgent::get_agent_info_static()
{
return AgentInfo{"snapmaker", "Snapmaker", SNAPMAKER_AGENT_VERSION, "Snapmaker printer agent"};
@@ -146,149 +111,127 @@ std::string SnapmakerPrinterAgent::combine_filament_type(const std::string& type
return base;
}
bool SnapmakerPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode)
bool SnapmakerPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode /*sync_mode*/)
{
(void) dev_id;
if (sync_mode != get_filament_sync_mode())
std::string url = join_url(device_info.base_url, "/printer/objects/query?print_task_config&filament_detect");
std::string response_body;
bool success = false;
std::string http_error;
auto http = Http::get(url);
if (!device_info.api_key.empty()) {
http.header("X-Api-Key", device_info.api_key);
}
http.timeout_connect(5)
.timeout_max(10)
.on_complete([&](std::string body, unsigned status) {
if (status == 200) {
response_body = body;
success = true;
} else {
http_error = "HTTP error: " + std::to_string(status);
}
})
.on_error([&](std::string body, std::string err, unsigned status) {
http_error = err;
if (status > 0) {
http_error += " (HTTP " + std::to_string(status) + ")";
}
})
.perform_sync();
if (!success) {
BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: HTTP request failed: " << http_error;
return false;
}
const std::string base_url = device_info.base_url;
const std::string api_key = device_info.api_key;
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 false;
}
filament_fetch_in_flight.fetch_add(1, std::memory_order_relaxed);
// 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 false;
}
std::thread([this, base_url, api_key]() {
struct InFlightGuard
{
std::atomic<int>& counter;
~InFlightGuard() { counter.fetch_sub(1, std::memory_order_relaxed); }
} guard{filament_fetch_in_flight};
auto& ptc = json["result"]["status"]["print_task_config"];
const std::string url = join_url(base_url, "/printer/objects/query?print_task_config&filament_detect");
// Read parallel arrays from print_task_config
auto filament_exist = ptc.value("filament_exist", std::vector<bool>{});
auto filament_type = ptc.value("filament_type", std::vector<std::string>{});
auto filament_sub_type = ptc.value("filament_sub_type", std::vector<std::string>{});
auto filament_color = ptc.value("filament_color_rgba", std::vector<std::string>{});
auto filament_vendor = ptc.value("filament_vendor", std::vector<std::string>{});
std::string response_body;
bool success = false;
std::string http_error;
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 false;
}
auto http = Http::get(url);
if (!api_key.empty()) {
http.header("X-Api-Key", api_key);
}
http.timeout_connect(5)
.timeout_max(10)
.on_complete([&](std::string body, unsigned status) {
if (status == 200) {
response_body = body;
success = true;
// Read NFC filament_detect data for temperature info (optional)
nlohmann::json nfc_info;
if (json["result"]["status"].contains("filament_detect") &&
json["result"]["status"]["filament_detect"].contains("info")) {
nfc_info = json["result"]["status"]["filament_detect"]["info"];
}
static const std::string empty_str;
static const std::string default_color = "FFFFFFFF";
std::vector<AmsTrayData> trays;
trays.reserve(slot_count);
for (int i = 0; i < slot_count; ++i) {
AmsTrayData tray;
tray.slot_index = i;
tray.has_filament = filament_exist[i];
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);
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 {
http_error = "HTTP error: " + std::to_string(status);
}
})
.on_error([&](std::string body, std::string err, unsigned status) {
http_error = err;
if (status > 0) {
http_error += " (HTTP " + std::to_string(status) + ")";
}
})
.perform_sync();
if (!success) {
BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: HTTP request failed: " << http_error;
return;
}
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;
}
// 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;
}
auto& ptc = json["result"]["status"]["print_task_config"];
// Read parallel arrays from print_task_config
auto filament_exist = ptc.value("filament_exist", std::vector<bool>{});
auto filament_type = ptc.value("filament_type", std::vector<std::string>{});
auto filament_sub_type = ptc.value("filament_sub_type", std::vector<std::string>{});
auto filament_color = ptc.value("filament_color_rgba", std::vector<std::string>{});
auto filament_vendor = ptc.value("filament_vendor", std::vector<std::string>{});
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;
}
// Read NFC filament_detect data for temperature info (optional)
nlohmann::json nfc_info;
if (json["result"]["status"].contains("filament_detect") && json["result"]["status"]["filament_detect"].contains("info")) {
nfc_info = json["result"]["status"]["filament_detect"]["info"];
}
static const std::string empty_str;
static const std::string default_color = "FFFFFFFF";
std::vector<AmsTrayData> trays;
trays.reserve(slot_count);
for (int i = 0; i < slot_count; ++i) {
AmsTrayData tray;
tray.slot_index = i;
tray.has_filament = filament_exist[i];
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);
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);
}
// Extract NFC temperature data if available
if (nfc_info.is_array() && i < static_cast<int>(nfc_info.size()) && nfc_info[i].is_object()) {
auto& nfc_slot = nfc_info[i];
std::string vendor = nfc_slot.value("VENDOR", "NONE");
if (vendor != "NONE" && !vendor.empty()) {
tray.bed_temp = nfc_slot.value("BED_TEMP", 0);
tray.nozzle_temp = nfc_slot.value("FIRST_LAYER_TEMP", 0);
}
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);
}
trays.emplace_back(std::move(tray));
// Extract NFC temperature data if available
if (nfc_info.is_array() && i < static_cast<int>(nfc_info.size()) && nfc_info[i].is_object()) {
auto& nfc_slot = nfc_info[i];
std::string vendor = nfc_slot.value("VENDOR", "NONE");
if (vendor != "NONE" && !vendor.empty()) {
tray.bed_temp = nfc_slot.value("BED_TEMP", 0);
tray.nozzle_temp = nfc_slot.value("FIRST_LAYER_TEMP", 0);
}
}
}
build_ams_payload(1, slot_count - 1, trays);
}).detach();
trays.emplace_back(std::move(tray));
}
build_ams_payload(1, slot_count - 1, trays);
return true;
}
FilamentSyncMode SnapmakerPrinterAgent::get_filament_sync_mode() const
{
if (GUI::wxGetApp().app_config->get_bool("use_printer_agents"))
return FilamentSyncMode::subscription;
return FilamentSyncMode::pull;
}
} // namespace Slic3r
@@ -3,8 +3,6 @@
#include "IPrinterAgent.hpp"
#include "MoonrakerPrinterAgent.hpp"
#include <atomic>
#include <cstdint>
#include <string>
namespace Slic3r {
@@ -19,19 +17,10 @@ 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;
int command_start_camera(std::string dev_id) override;
CameraStreamMode get_camera_stream_mode() const override { return CameraStreamMode::http_snapshot; }
std::string get_camera_url() const override { return device_info.base_url + "/server/files/camera/monitor.jpg"; }
private:
// Combine filament_type + filament_sub_type into a unified type string
static std::string combine_filament_type(const std::string& type, const std::string& sub_type);
void start_camera_monitor();
void on_status_loop_tick(const std::string& dev_id) override;
std::atomic<int64_t> m_camera_last_fire_ms{0};
};
} // namespace Slic3r
-1
View File
@@ -24,7 +24,6 @@ add_executable(${_TEST_NAME}_tests
test_plugin_capabilities_in_use.cpp
test_plugin_status.cpp
test_printer_agent.cpp
test_qidi_printer_agent.cpp
test_plugin_install.cpp
test_plugin_lifecycle.cpp
test_plugin_printer_agent.cpp
-146
View File
@@ -1,7 +1,5 @@
#include <catch2/catch_all.hpp>
#include <slic3r/Utils/BBLPrinterAgent.hpp>
#include <slic3r/Utils/MoonrakerPrinterAgent.hpp>
#include <memory>
#include "slic3r/Utils/ICloudServiceAgent.hpp"
#include "slic3r/Utils/IPrinterAgent.hpp"
@@ -16,155 +14,11 @@
#include <pybind11/embed.h>
#include <pybind11/pybind11.h>
#include <atomic>
#include <chrono>
#include <future>
#include <string>
#include <thread>
using namespace Slic3r;
namespace py = pybind11;
class MoonrakerParserProbe : public MoonrakerPrinterAgent
{
public:
using MoonrakerPrinterAgent::parse_nozzle_diameter;
explicit MoonrakerParserProbe(std::string log_dir) : MoonrakerPrinterAgent(std::move(log_dir)) {}
};
TEST_CASE("Moonraker parses nozzle diameter from configfile settings", "[unit][moonraker]")
{
const auto response = nlohmann::json::parse(R"({
"result": {
"status": {
"configfile": {
"settings": {
"extruder": {
"nozzle_diameter": 0.6
}
}
}
}
}
})");
CHECK(MoonrakerParserProbe::parse_nozzle_diameter(response) == Catch::Approx(0.6f));
}
TEST_CASE("Moonraker parses nozzle diameter from raw config and tolerates missing data", "[unit][moonraker]")
{
const auto raw_config_response = nlohmann::json::parse(R"({
"result": {
"status": {
"configfile": {
"config": {
"extruder": {
"nozzle_diameter": "0.8"
}
}
}
}
}
})");
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);
}
// 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]")
{
MoonrakerPrinterAgent agent("");
CHECK(agent.command_ams_refresh_rfid("dev", 123, 1, 0, false) == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
CHECK(agent.command_ams_calibrate("dev", 1, 2, false) == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
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]")
{
CHECK(moonraker_is_light_name("caselight"));
CHECK(moonraker_is_light_name("LED_STRIP"));
CHECK_FALSE(moonraker_is_light_name("beeper"));
CHECK(moonraker_is_light_name("FLASHLIGHT_SWITCH"));
CHECK(moonraker_is_light_name("MODLELIGHT_SWITCH"));
}
// ===========================================================================
// UNIT - handle_request's not-supported default.
// The agent is the only thing that knows what it can translate, so an untranslated
// command has to say so instead of returning success and letting the UI believe the
// control worked. Guards the inverse too: the pushing namespace is genuinely
// satisfied by the websocket status stream, and it re-fires from the keepalive timer
// 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]")
{
MoonrakerPrinterAgent agent("");
CHECK(agent.send_message("dev", R"({"print":{"command":"ams_change_filament"}})", 0, 0) ==
ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
CHECK(agent.send_message("dev", R"({"system":{"command":"set_door_stat"}})", 0, 0) ==
ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
CHECK(agent.send_message("dev", R"({"xcam":{"command":"xcam_control_set"}})", 0, 0) ==
ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
CHECK(agent.send_message("dev", R"({"pushing":{"command":"pushall"}})", 0, 0) == BAMBU_NETWORK_SUCCESS);
CHECK(agent.send_message("dev", R"({"pushing":{"command":"start"}})", 0, 0) == BAMBU_NETWORK_SUCCESS);
// why: malformed input is a different failure than an untranslated command, and the
// default must not swallow it into a misleading not-supported verdict.
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]")
{
class RecordingAgent : public Slic3r::MoonrakerPrinterAgent
{
public:
explicit RecordingAgent(std::string log_dir) : MoonrakerPrinterAgent(std::move(log_dir)) {}
std::atomic<bool> invoked{false};
std::promise<void> release_gate;
std::promise<void> done_promise;
bool fetch_filament_info(std::string /*dev_id*/, FilamentSyncMode /*sync_mode*/ = FilamentSyncMode::pull) override
{
std::thread([this]() {
invoked.store(true);
// Block here until the test explicitly releases us, proving the caller
// (fetch_filament_info) does not wait for this to run.
release_gate.get_future().wait();
done_promise.set_value();
}).detach();
return true;
}
};
auto agent = std::make_shared<RecordingAgent>(std::string{});
auto done_future = agent->done_promise.get_future();
bool immediate_result = agent->fetch_filament_info("test-dev");
// 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.
REQUIRE(immediate_result == true);
REQUIRE(done_future.wait_for(std::chrono::milliseconds(100)) == std::future_status::timeout);
// Now let the background call finish and confirm it actually ran (polymorphic dispatch).
agent->release_gate.set_value();
REQUIRE(done_future.wait_for(std::chrono::seconds(2)) == std::future_status::ready);
REQUIRE(agent->invoked.load() == true);
}
// ===========================================================================
// UNIT - printer-agent registry duplicate handling.
// Confirms a duplicate agent id is rejected so a plugin cannot shadow a built-in
@@ -1,131 +0,0 @@
#include <catch2/catch_all.hpp>
#include <nlohmann/json.hpp>
#include <slic3r/Utils/QidiPrinterAgent.hpp>
#include <string>
using namespace Slic3r;
TEST_CASE("Qidi slot response rejects null variables without throwing", "[QidiPrinterAgent]")
{
const std::string response = R"({
"result": {
"status": {
"save_variables": {
"variables": null
}
}
}
})";
nlohmann::json status;
nlohmann::json variables;
std::string error;
bool parsed = true;
REQUIRE_NOTHROW(parsed = QidiPrinterAgent::parse_slot_response(response, status, variables, error));
CHECK_FALSE(parsed);
CHECK_THAT(error, Catch::Matchers::ContainsSubstring("variables"));
CHECK_THAT(error, Catch::Matchers::ContainsSubstring("object"));
}
TEST_CASE("Qidi slot response rejects missing and non-object fields without throwing", "[QidiPrinterAgent]")
{
std::string response;
SECTION("missing result")
{
response = R"({})";
}
SECTION("non-object result")
{
response = R"({"result":null})";
}
SECTION("missing status")
{
response = R"({"result":{}})";
}
SECTION("non-object status")
{
response = R"({"result":{"status":null}})";
}
SECTION("missing save_variables")
{
response = R"({"result":{"status":{}}})";
}
SECTION("non-object save_variables")
{
response = R"({"result":{"status":{"save_variables":null}}})";
}
SECTION("missing variables")
{
response = R"({"result":{"status":{"save_variables":{}}}})";
}
SECTION("scalar")
{
response = R"({"result":{"status":{"save_variables":{"variables":42}}}})";
}
SECTION("array")
{
response = R"({"result":{"status":{"save_variables":{"variables":[]}}}})";
}
nlohmann::json status;
nlohmann::json variables;
std::string error;
bool parsed = true;
REQUIRE_NOTHROW(parsed = QidiPrinterAgent::parse_slot_response(response, status, variables, error));
CHECK_FALSE(parsed);
}
TEST_CASE("Qidi slot response exposes valid status and variables", "[QidiPrinterAgent]")
{
const std::string response = R"({
"result": {
"status": {
"save_variables": {
"variables": {
"box_count": 2,
"color_slot0": 3
}
},
"box_stepper slot0": {
"runout_button": 0
}
}
}
})";
nlohmann::json status;
nlohmann::json variables;
std::string error;
bool parsed = false;
REQUIRE_NOTHROW(parsed = QidiPrinterAgent::parse_slot_response(response, status, variables, error));
REQUIRE(parsed);
CHECK(status.is_object());
CHECK(variables.is_object());
CHECK(variables.at("box_count") == 2);
CHECK(status.contains("box_stepper slot0"));
}
TEST_CASE("Qidi slot response rejects invalid JSON", "[QidiPrinterAgent]")
{
nlohmann::json status;
nlohmann::json variables;
std::string error;
bool parsed = true;
REQUIRE_NOTHROW(parsed = QidiPrinterAgent::parse_slot_response("{not json", status, variables, error));
CHECK_FALSE(parsed);
CHECK(error == "Invalid JSON response");
}