Working Moonraker and Qidi printer agent transport (#104)

Folds the Qidi AMS box-mapping print
overrides (apply_box_mapping +
start_* wrappers) that the transport
fix builds on.
This commit is contained in:
Ian Chua
2026-06-23 11:27:20 +08:00
parent 7ce26ca8e5
commit 6d25e1777e
4 changed files with 193 additions and 38 deletions

View File

@@ -199,11 +199,15 @@ void MoonrakerPrinterAgent::install_device_cert(std::string dev_id, bool lan_onl
bool MoonrakerPrinterAgent::start_discovery(bool start, bool sending)
{
(void) sending;
if (start) {
announce_printhost_device();
}
return true;
// Discovery is not properly implemented, avoid populating machine list
// with stale device
// (void) sending;
// if (start) {
// announce_printhost_device();
// }
// return true;
return BAMBU_NETWORK_SUCCESS;
}
int MoonrakerPrinterAgent::ping_bind(std::string ping_code)
@@ -344,11 +348,17 @@ int MoonrakerPrinterAgent::start_local_print(PrintParams params, OnUpdateStatusF
if (cancel_fn && cancel_fn()) {
return BAMBU_NETWORK_ERR_CANCELED;
}
// Determine the G-code file to upload
// params.filename may be .3mf, params.dst_file contains actual G-code
// Determine the G-code file to upload.
// - params.dst_file, when set, points directly at the sliced G-code.
// - Otherwise params.filename is the exported .3mf archive; the sliced
// G-code sits next to it with the same stem (".12345.0.3mf" ->
// ".12345.0.gcode"), so swap the extension to upload the actual G-code
// rather than the archive (which Klipper cannot print).
std::string gcode_path = params.filename;
if (!params.dst_file.empty()) {
gcode_path = params.dst_file;
} else if (boost::iends_with(gcode_path, ".3mf")) {
gcode_path.replace(gcode_path.size() - 4, 4, ".gcode");
}
// Check if file exists and has .gcode extension
@@ -359,8 +369,19 @@ int MoonrakerPrinterAgent::start_local_print(PrintParams params, OnUpdateStatusF
return BAMBU_NETWORK_ERR_FILE_NOT_EXIST;
}
// Extract filename for upload (relative to gcodes root)
std::string upload_filename = source_path.filename().string();
// Use the human-readable project name for the uploaded file rather than the
// internal temp G-code name (e.g. ".12345.0.gcode"). Fall back to the source
// file's own name when no project name is available.
std::string upload_filename = params.project_name.empty()
? source_path.filename().string()
: params.project_name;
// SDCARD_PRINT_FILE parses its parameters by whitespace, so the printed
// filename must not contain spaces; collapse any whitespace to underscores.
std::replace_if(
upload_filename.begin(), upload_filename.end(),
[](unsigned char c) { return std::isspace(c) != 0; }, '_');
if (!boost::iends_with(upload_filename, ".gcode")) {
upload_filename += ".gcode";
}
@@ -379,11 +400,12 @@ int MoonrakerPrinterAgent::start_local_print(PrintParams params, OnUpdateStatusF
return BAMBU_NETWORK_ERR_CANCELED;
}
// Start print via gcode script (simpler than JSON-RPC)
// Start print via Moonraker's print API, referencing the file we just uploaded.
if (update_fn)
update_fn(PrintingStageSending, 0, "Starting print...");
std::string gcode = "SDCARD_PRINT_FILE FILENAME=" + upload_filename;
if (!send_gcode(device_info.dev_id, gcode)) {
std::string start_error;
if (!start_print_file(device_info.base_url, device_info.api_key, upload_filename, start_error)) {
return BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED;
}
@@ -1213,7 +1235,8 @@ bool MoonrakerPrinterAgent::send_gcode(const std::string& dev_id, const std::str
bool success = false;
std::string http_error;
auto http = Http::post(join_url(device_info.base_url, "/printer/gcode/script"));
auto full_url = join_url(device_info.base_url, "/printer/gcode/script");
auto http = Http::post(full_url);
if (!device_info.api_key.empty()) {
http.header("X-Api-Key", device_info.api_key);
}
@@ -1785,9 +1808,12 @@ nlohmann::json MoonrakerPrinterAgent::build_print_payload_locked() const
payload["print"]["print_error"] = 0;
// Map homed axes to bit field: X=bit0, Y=bit1, Z=bit2
// WARNING: This only sets bits 0-2, clearing support flags (bit 3+)
// Bit 3 = 220V voltage, bit 4 = auto recovery, etc.
// This is acceptable for Moonraker (no AMS, different feature set)
// NOTE: home_flag is a packed BBL status field. MachineObject::parse_home_flag()
// decodes the storage state from bits 8-9 (get_flag_bits(flag, 8, 2)) and writes it
// to DevStorage. We encode HAS_SDCARD_NORMAL there (bits 8-9 = 01) so the printer
// reports its virtual_sdcard as present and LAN printing is allowed; otherwise the
// bits stay 0 (NO_SDCARD) and printing is blocked. Other support bits (3=220V,
// 4=auto-recovery, etc.) are intentionally left 0 for Moonraker.
int home_flag = 0;
if (status_cache.contains("toolhead") && status_cache["toolhead"].contains("homed_axes")) {
std::string homed = status_cache["toolhead"]["homed_axes"].get<std::string>();
@@ -1798,6 +1824,7 @@ nlohmann::json MoonrakerPrinterAgent::build_print_payload_locked() const
if (homed.find('Z') != std::string::npos)
home_flag |= 4; // bit 2
}
home_flag |= (1 << 8); // bits 8-9 = 01 -> HAS_SDCARD_NORMAL (virtual_sdcard always present)
payload["print"]["home_flag"] = home_flag;
// Moonraker doesn't provide temperature ranges via API - use hardcoded defaults
@@ -2022,41 +2049,82 @@ int MoonrakerPrinterAgent::cancel_print(const std::string& dev_id)
return send_gcode(dev_id, "CANCEL_PRINT") ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_SEND_MSG_FAILED;
}
bool MoonrakerPrinterAgent::send_jsonrpc_command(const std::string& base_url,
const std::string& api_key,
const nlohmann::json& request,
std::string& response) const
bool MoonrakerPrinterAgent::start_print_file(const std::string& base_url,
const std::string& api_key,
const std::string& filename,
std::string& error_msg) const
{
std::string request_str = request.dump();
std::string url = join_url(base_url, "/printer/print/start");
// Start the given file (path relative to the gcodes root). The filename is
// sent both as a query parameter and in the JSON body: Moonraker accepts
// either, and sending a body avoids a body-less POST (which curl would treat
// as a streamed upload and try to read via the file-read callback).
std::string url = join_url(base_url, "/printer/print/start") +
"?filename=" + Http::url_encode(filename);
bool success = false;
std::string http_error;
nlohmann::json payload;
payload["filename"] = filename;
bool success = false;
auto http = Http::post(url);
if (!api_key.empty()) {
http.header("X-Api-Key", api_key);
}
http.header("Content-Type", "application/json")
.set_post_body(request_str)
.set_post_body(payload.dump())
.timeout_connect(5)
.timeout_max(10)
.on_complete([&](std::string body, unsigned status) {
(void) body;
if (status == 200) {
response = body;
success = true;
success = true;
} else {
http_error = "HTTP " + std::to_string(status);
error_msg = "HTTP " + std::to_string(status);
}
})
.on_error([&](std::string body, std::string err, unsigned status) {
(void) body;
error_msg = err;
if (status > 0) {
error_msg += " (HTTP " + std::to_string(status) + ")";
}
})
.on_error([&](std::string body, std::string err, unsigned status) { http_error = err; })
.perform_sync();
if (!success) {
BOOST_LOG_TRIVIAL(error) << "MoonrakerPrinterAgent: JSON-RPC command failed: " << http_error;
if (success) {
return true;
}
return success;
// Moonraker holds the /printer/print/start response until the print actually
// begins, so a slow PRINT_START (heating, homing, bed mesh) can exceed our HTTP
// timeout even though the command was accepted and the print is starting. Don't
// report failure on the HTTP result alone: poll print_stats and treat a
// printing/paused state as success. print_stats.state flips to "printing" as soon
// as the file starts streaming, which is earlier than the held HTTP response.
BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: start print not confirmed over HTTP (" << error_msg
<< "); verifying print_stats state";
for (int attempt = 0; attempt < 10; ++attempt) {
std::this_thread::sleep_for(std::chrono::milliseconds(1500));
nlohmann::json status;
std::string query_err;
if (!query_printer_status(base_url, api_key, status, query_err)) {
continue;
}
std::string state;
if (status.contains("print_stats") && status["print_stats"].contains("state") &&
status["print_stats"]["state"].is_string()) {
state = status["print_stats"]["state"].get<std::string>();
}
if (state == "printing" || state == "paused") {
BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent: print confirmed started (print_stats.state=" << state << ")";
return true;
}
}
BOOST_LOG_TRIVIAL(error) << "MoonrakerPrinterAgent: start print failed: " << error_msg << ", url: " << url;
return false;
}
void MoonrakerPrinterAgent::perform_connection_async(const std::string& dev_id, const std::string& base_url, const std::string& api_key, uint64_t generation)
@@ -2092,7 +2160,7 @@ void MoonrakerPrinterAgent::perform_connection_async(const std::string& dev_id,
}
// Orca todo: disable websocket for now, as we don't use MonitorPanel for Moonraker printers yet
#if 0
#if 1
// Query initial status
nlohmann::json initial_status;
if (query_printer_status(base_url, api_key, initial_status, error_msg)) {

View File

@@ -70,7 +70,7 @@ public:
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; }
FilamentSyncMode get_filament_sync_mode() const override { return FilamentSyncMode::subscription; }
bool fetch_filament_info(std::string dev_id) override;
protected:
@@ -121,6 +121,9 @@ 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;
private:
int handle_request(const std::string& dev_id, const std::string& json_str);
int send_version_info(const std::string& dev_id);
@@ -128,7 +131,6 @@ private:
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(const std::string& dev_id, const std::string& gcode) const;
void announce_printhost_device();
void dispatch_local_connect(int state, const std::string& dev_id, const std::string& msg);
@@ -151,9 +153,10 @@ private:
const std::string& base_url, const std::string& api_key,
OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn);
// 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;
// Start a print of a previously uploaded G-code file (path relative to the
// Moonraker gcodes root).
bool start_print_file(const std::string& base_url, const std::string& api_key,
const std::string& filename, std::string& error_msg) const;
// Connection thread management
void perform_connection_async(const std::string& dev_id,

View File

@@ -74,6 +74,81 @@ bool QidiPrinterAgent::fetch_filament_info(std::string dev_id)
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 std::string& base_url,
const std::string& api_key,
const QidiFilamentDict& dict,

View File

@@ -21,7 +21,16 @@ public:
// Override filament sync (Qidi-specific implementation)
bool fetch_filament_info(std::string dev_id) override;
// 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;
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;