Files
Alexey Cluster b974e97e96 local_print: honor cancel during the post-upload MQTT reconnect
ensure_lan_session() can block for a few seconds waiting for CONNACK.
A cancel that arrives in that window must not publish project_file, and
must be reported as a cancel rather than a failed reconnect.
2026-10-10 01:03:31 +02:00

726 lines
31 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// LAN print pipeline.
//
// The flow we implement mirrors what Studio's original plugin does on the
// `connection_type == "lan"` branch of PrintJob::process():
//
// 1. Upload the .3mf (PrintingStageUpload). LAN-only stock on P2S
// reports a single Upload(0,"") for the :6000 path; FTPS (hybrid
// and N7) streams percent + "0.1M/1.1M" size info.
// 2. PrintingStageWaiting, then MQTT project_file.
// 3. Finished countdown "3","2","1" (1 s apart) before return.
//
// We deliberately keep this synchronous: Studio invokes
// start_local_print() from its own PrintJob worker thread, so spawning
// yet another thread here would just complicate cancellation without
// buying us anything.
#include "obn/agent.hpp"
#include "obn/print_job.hpp"
#include "obn/bambu_networking.hpp"
#include "obn/config.hpp"
#include "obn/ftps.hpp"
#include "obn/log.hpp"
#include "obn/mqtt_seq.hpp"
#include "obn/print_params_ftp_prefs.hpp"
#include "obn/tunnel_upload.hpp"
#include <algorithm>
#include <array>
#include <chrono>
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include <system_error>
#include <thread>
namespace obn::print_job {
std::string format_upload_info(std::uint64_t sent, std::uint64_t total)
{
auto one = [](std::uint64_t n) {
char buf[32];
if (n < 100ull * 1024) {
std::snprintf(buf, sizeof(buf), "%.1fK",
static_cast<double>(n) / 1024.0);
} else {
std::snprintf(buf, sizeof(buf), "%.1fM",
static_cast<double>(n) / (1024.0 * 1024.0));
}
return std::string{buf};
};
return one(sent) + "/" + one(total);
}
void emit_finished_countdown(BBL::OnUpdateStatusFn update_fn,
BBL::WasCancelledFn cancel_fn)
{
if (!update_fn) return;
static constexpr const char* kTicks[] = {"3", "2", "1"};
for (int i = 0; i < 3; ++i) {
if (cancel_fn && cancel_fn()) return;
update_fn(BBL::PrintingStageFinished, 0, kTicks[i]);
if (i == 2) break;
for (int s = 0; s < 10; ++s) {
if (cancel_fn && cancel_fn()) return;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
}
// ---------------------------------------------------------------------------
// Remote filename normalisation (plate_0 → plate_1 rename in the name only)
// ---------------------------------------------------------------------------
std::string to_print_basename(std::string fname)
{
{
auto slash = fname.find_last_of("/\\");
if (slash != std::string::npos)
fname = fname.substr(slash + 1);
}
if (fname.empty()) return "print.gcode.3mf";
{
const std::string needle = "plate_0";
auto pos = fname.find(needle);
if (pos != std::string::npos)
fname.replace(pos, needle.size(), "plate_1");
}
auto ends_with_ci = [](const std::string& s, const char* suf) {
std::size_t n = std::strlen(suf);
if (s.size() < n) return false;
for (std::size_t i = 0; i < n; ++i) {
char a = s[s.size() - n + i];
if (a >= 'A' && a <= 'Z') a = static_cast<char>(a - 'A' + 'a');
if (a != suf[i]) return false;
}
return true;
};
if (ends_with_ci(fname, ".gcode.3mf")) return fname;
if (ends_with_ci(fname, ".3mf"))
fname.erase(fname.size() - 4);
return fname + ".gcode.3mf";
}
namespace {
// Strict-enough JSON string escaper. The printer firmware's parser is
// lenient but we still need to handle quotes/backslashes/control bytes
// so that, e.g., project names containing quotes don't break the wire
// format. UTF-8 bytes pass through unchanged.
std::string json_escape(const std::string& in)
{
std::string out;
out.reserve(in.size() + 2);
out.push_back('"');
for (unsigned char c : in) {
switch (c) {
case '"': out += "\\\""; break;
case '\\': out += "\\\\"; break;
case '\b': out += "\\b"; break;
case '\f': out += "\\f"; break;
case '\n': out += "\\n"; break;
case '\r': out += "\\r"; break;
case '\t': out += "\\t"; break;
default:
if (c < 0x20) {
char buf[8];
std::snprintf(buf, sizeof(buf), "\\u%04x", c);
out += buf;
} else {
out.push_back(static_cast<char>(c));
}
}
}
out.push_back('"');
return out;
}
std::string to_bool(bool v) { return v ? "true" : "false"; }
// Studio's SelectMachineDialog::get_ams_mapping_result serializes the
// mapping into `params.ams_mapping` as a JSON array string, e.g.
// "[0,-1,-1,-1]". We must not wrap it again - the firmware's parser
// accepts the array as-is. When the caller left the string empty we
// emit `[]` — stock 02.05.03 / 02.08.02 does that even with
// `task_use_ams=true`, so the old `[0]` fallback was a guess.
// Trim a single leading '/' from `s`. Stock plugin parity: when the
// upload landed in the FTPS root the `print.file` and `print.url`
// fields are bare names (`"foo.gcode.3mf"` and `"ftp://foo.gcode.3mf"`),
// not `"/foo.gcode.3mf"` / `"ftp:///foo..."`. Internally we keep the
// absolute path for FTP I/O — only the wire form drops the slash.
std::string strip_leading_slash(const std::string& s)
{
if (!s.empty() && s.front() == '/') return s.substr(1);
return s;
}
std::string format_ams_mapping(const std::string& mapping)
{
std::string s = mapping;
while (!s.empty() && (s.front() == ' ' || s.front() == '\t' ||
s.front() == '\r' || s.front() == '\n')) s.erase(s.begin());
while (!s.empty() && (s.back() == ' ' || s.back() == '\t' ||
s.back() == '\r' || s.back() == '\n')) s.pop_back();
if (s.empty()) return "[]";
if (s.front() == '[') return s; // already a JSON array
std::string arr = "[";
std::size_t start = 0;
while (start <= s.size()) {
std::size_t end = s.find(',', start);
std::string tok = s.substr(start,
end == std::string::npos ? std::string::npos : end - start);
while (!tok.empty() && (tok.front() == ' ' || tok.front() == '\t')) tok.erase(tok.begin());
while (!tok.empty() && (tok.back() == ' ' || tok.back() == '\t')) tok.pop_back();
if (!tok.empty()) {
if (arr.size() > 1) arr += ',';
arr += tok;
}
if (end == std::string::npos) break;
start = end + 1;
}
arr += "]";
return arr;
}
} // namespace
namespace {
// True when `path` starts with `prefix` followed by `/`. Used to detect
// `/sdcard/...` and `/usb/...` paths that we want to auto-correct
// against the printer's actual FTPS layout.
bool path_under(const std::string& path, const char* prefix)
{
std::size_t plen = std::strlen(prefix);
return path.size() > plen + 1 &&
std::equal(path.begin(), path.begin() + plen, prefix) &&
path[plen] == '/';
}
// Picks the actual storage prefix to STOR under by CWD-probing the
// printer. Mirrors the auto-detection in `abi_ft.cpp` (Send-to-Printer
// fastpath) and `BambuSource.cpp` (PrinterFileSystem CTRL bridge):
// firmware on A1 / A1 mini / P2S exposes the storage mount either as
// `/sdcard`, `/usb`, or as the FTPS root itself - we pick whichever
// answers and rewrite the directory portion of `remote_path` to match.
//
// Probe order keeps the caller-supplied prefix first so X1 / P1 (where
// `/sdcard` really exists) take the fast path with no extra round-trip
// cost beyond the CWD itself. If every probe 550s we leave the path
// untouched so the original STOR error reaches the upper layer with
// the path the caller asked for.
std::string adjust_storage_path(obn::ftps::Client& cli,
const std::string& remote_path)
{
const bool under_sdcard = path_under(remote_path, "/sdcard");
const bool under_usb = path_under(remote_path, "/usb");
if (!under_sdcard && !under_usb) return remote_path;
auto slash = remote_path.find('/', 1);
std::string filename = (slash == std::string::npos)
? std::string{}
: remote_path.substr(slash + 1);
if (filename.empty()) return remote_path;
static const std::array<const char*, 3> kCandidatesSdcard = {"/sdcard", "/usb", "/"};
static const std::array<const char*, 3> kCandidatesUsb = {"/usb", "/sdcard", "/"};
const auto& candidates = under_sdcard ? kCandidatesSdcard : kCandidatesUsb;
for (const char* cand : candidates) {
if (!cli.cwd(cand).empty()) continue;
std::string adjusted =
(std::strcmp(cand, "/") == 0) ? ("/" + filename)
: (std::string{cand} + "/" + filename);
if (adjusted != remote_path) {
OBN_DEBUG("print_job: storage probe rewrote '%s' -> '%s'",
remote_path.c_str(), adjusted.c_str());
}
return adjusted;
}
OBN_WARN("print_job: storage probe found neither /sdcard, /usb nor /; "
"falling back to the caller-supplied path '%s'",
remote_path.c_str());
return remote_path;
}
} // namespace
int ftp_upload(const BBL::PrintParams& p,
const std::string& remote_path,
const std::string& ca_file,
BBL::OnUpdateStatusFn update_fn,
BBL::WasCancelledFn cancel_fn,
int err_code_on_failure,
std::uint64_t& total_bytes_out,
std::string* selected_remote_path,
int progress_stage)
{
std::error_code ec;
auto sz = std::filesystem::file_size(p.filename, ec);
if (ec) {
OBN_ERROR("print_job: stat %s failed: %s", p.filename.c_str(), ec.message().c_str());
if (update_fn) update_fn(BBL::PrintingStageERROR,
BAMBU_NETWORK_ERR_FILE_NOT_EXIST,
"file not found");
return BAMBU_NETWORK_ERR_FILE_NOT_EXIST;
}
total_bytes_out = sz;
constexpr std::uint64_t kOneGB = 1ull * 1024 * 1024 * 1024;
if (sz > kOneGB) {
if (update_fn) update_fn(BBL::PrintingStageERROR,
BAMBU_NETWORK_ERR_PRINT_LP_FILE_OVER_SIZE,
"file over 1 GB");
return BAMBU_NETWORK_ERR_PRINT_LP_FILE_OVER_SIZE;
}
obn::ftps::ConnectConfig cfg;
cfg.host = p.dev_ip;
cfg.port = p.use_ssl_for_ftp ? 990 : 21;
cfg.username = p.username.empty() ? std::string{"bblp"} : p.username;
cfg.password = p.password;
cfg.ca_file = ca_file;
cfg.tls_verify_hostname = p.dev_id;
cfg.use_tls = p.use_ssl_for_ftp;
// Stock emits empty-info Upload ticks while the control channel
// comes up, then switches to "0.0K/1.1M" once STOR has a size.
if (update_fn) update_fn(progress_stage, 0, "");
obn::ftps::Client cli;
if (std::string err = cli.connect(cfg); !err.empty()) {
OBN_ERROR("print_job: ftps connect %s: %s", p.dev_ip.c_str(), err.c_str());
if (update_fn) update_fn(BBL::PrintingStageERROR, err_code_on_failure, err);
return err_code_on_failure;
}
std::string effective_path = adjust_storage_path(cli, remote_path);
if (selected_remote_path) *selected_remote_path = effective_path;
auto progress = [&](std::uint64_t sent, std::uint64_t total) {
if (cancel_fn && cancel_fn()) return false;
if (update_fn) {
int pct = total > 0 ? static_cast<int>(sent * 100 / total) : 0;
if (pct > 100) pct = 100;
update_fn(progress_stage, pct, format_upload_info(sent, total));
}
return true;
};
std::string err = cli.stor(p.filename, effective_path, progress);
cli.quit();
if (!err.empty()) {
OBN_ERROR("print_job: STOR %s failed: %s", effective_path.c_str(), err.c_str());
int code = err == "upload cancelled" ? BAMBU_NETWORK_ERR_CANCELED
: err_code_on_failure;
if (update_fn) update_fn(BBL::PrintingStageERROR, code, err);
return code;
}
return 0;
}
namespace {
// Shared body for the `project_file` MQTT command. Emits cleartext `url` /
// `param`; MQTT publish runs the payload through signing::maybe_sign, which
// replaces both with `url_enc` / `param_enc` when a device pubkey is available.
template <typename Opts>
std::string build_project_file_json_impl(const BBL::PrintParams& p,
const Opts& opts,
const std::string& param_field,
const std::string& url_field)
{
std::string subtask = p.project_name.empty() ? p.task_name : p.project_name;
std::string bed_type = p.task_bed_type.empty() ? "auto" : p.task_bed_type;
std::string ams_mapping = format_ams_mapping(p.ams_mapping);
std::ostringstream os;
os << "{\"print\":{";
os << "\"sequence_id\":" << json_escape(obn::next_mqtt_seq_id());
os << ",\"command\":\"project_file\"";
os << param_field;
os << ",\"project_id\":" << json_escape(opts.project_id);
os << ",\"profile_id\":" << json_escape(opts.profile_id);
os << ",\"task_id\":" << json_escape(opts.task_id);
os << ",\"subtask_id\":" << json_escape(opts.subtask_id);
os << ",\"subtask_name\":" << json_escape(subtask);
#if ABI_VERSION >= 0x020801
// Forwarded verbatim as a string — no parsing, no normalisation — and
// omitted entirely when the caller left it empty. Confirmed against
// stock 02.08.02.54 with sentinel values over both sdcard_print and
// local_print; the same field never reaches POST /my/task. See
// ../research/08.08-print-abi.md §8.8.1.
if (!p.slicer_uid.empty())
os << ",\"slicer_uid\":" << json_escape(p.slicer_uid);
#endif
os << ",\"file\":" << json_escape(strip_leading_slash(opts.file_path));
os << url_field;
os << ",\"md5\":" << json_escape(opts.md5);
os << ",\"bed_type\":" << json_escape(bed_type);
os << ",\"bed_leveling\":" << to_bool(p.task_bed_leveling);
os << ",\"flow_cali\":" << to_bool(p.task_flow_cali);
os << ",\"vibration_cali\":" << to_bool(p.task_vibration_cali);
os << ",\"layer_inspect\":" << to_bool(p.task_layer_inspect);
os << ",\"timelapse\":" << to_bool(p.task_record_timelapse);
os << ",\"use_ams\":" << to_bool(p.task_use_ams);
os << ",\"ams_mapping\":" << ams_mapping;
// Field name mapping (Studio C++ -> firmware JSON) is intentionally
// asymmetric in the upstream; preserved verbatim here so wireshark
// diffs against the stock plugin stay clean:
// PrintParams::ams_mapping2 -> "ams_mapping2"
// PrintParams::auto_bed_leveling -> "auto_bed_leveling"
// PrintParams::auto_offset_cali -> "nozzle_offset_cali"
// PrintParams::extruder_cali_manual_mode -> "extrude_cali_manual_mode"
// Stock plugin parity: `ams_mapping2` is emitted **unconditionally**
// — even when AMS isn't in use the field appears as an empty array
// (`"ams_mapping2": []`). Confirmed via `tools/plugin_runner` against
// the stock libbambu_networking.so on N7 (see
// ../research/12.01-project-file.md §12.3 "Per-PrintParams-field
// mapping" matrix). We feed it
// verbatim from `params.ams_mapping2` (a JSON-array string from
// SelectMachineDialog::get_ams_mapping_result), defaulting to `[]`
// when the caller didn't populate it.
if (p.ams_mapping2.empty())
os << ",\"ams_mapping2\":[]";
else
os << ",\"ams_mapping2\":" << p.ams_mapping2;
if (!p.nozzle_mapping.empty()) {
// It's already a JSON array
os << ",\"nozzle_mapping\":" << p.nozzle_mapping;
}
os << ",\"auto_bed_leveling\":" << p.auto_bed_leveling;
os << ",\"nozzle_offset_cali\":" << p.auto_offset_cali;
#if ABI_VERSION >= 0x020400
// Studio leaves -1 when set_print_config() was never called (e.g.
// headless / SDK paths). Skip the field in that case rather than
// forwarding the sentinel; the firmware then keeps its default PA mode.
if (p.extruder_cali_manual_mode >= 0) {
os << ",\"extrude_cali_manual_mode\":" << p.extruder_cali_manual_mode;
}
#endif
// `cfg` is a string-encoded bitmask the stock plugin builds from
// PrintParams flags that don't have a dedicated MQTT field. Two bits
// are known, and the same bitmask is reused verbatim in the cloud
// POST /my/task body (see cloud_print.cpp):
// bit 0 (value 1) = task_ext_change_assist
// bit 2 (value 4) = use internal storage for timelapse,
// i.e. task_timelapse_use_internal (ABI 02.05.03)
// Bit 1 (value 2) has never appeared: no PrintParams field this ABI
// exposes moves it, try_emmc_print included. `task_record_timelapse`
// is not part of cfg either — it rides in the `timelapse` boolean.
// See ../research/08.08-print-abi.md §8.8.8.
//
// Wire-level parity: the cross-ABI `tools/plugin_runner` matrix
// (02.05.00 -> 02.06.01) showed the stock plugin emits `cfg` in
// `project_file` for **every** ABI we tested — older builds simply
// hardcode `"0"` because the underlying field doesn't exist yet.
// So we emit unconditionally and gate only the *value* on the ABI
// bound that introduced `task_timelapse_use_internal`.
int cfg_bits = 0;
if (p.task_ext_change_assist) cfg_bits |= 1;
#if ABI_VERSION >= 0x020503
if (p.task_timelapse_use_internal &&
!obn::config::current().force_timelapse_external)
cfg_bits |= 4;
#endif
os << ",\"cfg\":\"" << cfg_bits << "\"";
// `extrude_cali_flag` is the wire mirror of `auto_flow_cali`:
// confirmed via `tools/plugin_runner` overlay (`auto_flow_cali=1`
// flipped the field from 0 to 1 across both 02.05.00 and 02.06.01).
// Studio populates this from the user's "Flow dynamics calibration"
// dropdown; the firmware uses it to short-circuit redundant PA
// cali runs. See ../research/12.01-project-file.md §12.3.
os << ",\"extrude_cali_flag\":" << p.auto_flow_cali;
os << "}}";
return os.str();
}
} // namespace
std::string build_project_file_json(const BBL::PrintParams& p,
const ProjectFileOpts& opts)
{
std::string plate_param = "Metadata/plate_" +
std::to_string(p.plate_index <= 0 ? 1 : p.plate_index) +
".gcode";
// sdcard_print has no url/url_enc at all — stock 02.05.00–02.08.02
// omit the key and the firmware looks the file up by basename +
// md5="from_sd_card". An empty opts.url is how we signal that.
const std::string url_field = opts.url.empty()
? std::string{}
: ",\"url\":" + json_escape(opts.url);
return build_project_file_json_impl(
p, opts,
",\"param\":" + json_escape(plate_param),
url_field);
}
} // namespace obn::print_job
namespace obn {
int Agent::run_local_print_job(const BBL::PrintParams& params,
BBL::OnUpdateStatusFn update_fn,
BBL::WasCancelledFn cancel_fn)
{
OBN_INFO("local_print dev=%s ip=%s plate=%d file=%s project=%s use_ams=%d",
params.dev_id.c_str(), params.dev_ip.c_str(), params.plate_index,
params.filename.c_str(), params.project_name.c_str(),
params.task_use_ams ? 1 : 0);
print_params_set_use_ssl_for_ftp(params.use_ssl_for_ftp);
if (params.dev_ip.empty() || params.password.empty()) {
if (update_fn) update_fn(BBL::PrintingStageERROR,
BAMBU_NETWORK_ERR_CONNECTION_TO_PRINTER_FAILED,
"no dev_ip/access_code");
return BAMBU_NETWORK_ERR_CONNECTION_TO_PRINTER_FAILED;
}
if (params.filename.empty()) {
if (update_fn) update_fn(BBL::PrintingStageERROR,
BAMBU_NETWORK_ERR_FILE_NOT_EXIST,
"empty filename");
return BAMBU_NETWORK_ERR_FILE_NOT_EXIST;
}
if (cancel_fn && cancel_fn()) return BAMBU_NETWORK_ERR_CANCELED;
// Stock plugin parity: when `ftp_folder` is empty (which it always
// is — Studio never assigns m_ftp_folder anywhere in the public
// tree, see `3rd_party/BambuStudio/src/slic3r/GUI/Jobs/PrintJob.cpp`)
// the stock plugin uploads the 3mf to the **FTPS root**, not to
// `/cache/`. Confirmed by sniffing a real LAN print on N7 with the
// stock libbambu_networking.so loaded via `tools/plugin_runner`:
// the published `project_file` carries `"file":
// "<project>.gcode.3mf"` (no `/cache/` prefix) and the firmware
// happily accepts it. The `/cache/` path was an earlier guess that
// matched no observed traffic; we keep `ftp_folder` honored
// verbatim so a downstream caller can still target a specific
// directory if needed (e.g. `"sdcard/"` for printers whose
// firmware insists on it). See ../research/12.01-project-file.md §12.3.
std::string remote_folder = params.ftp_folder;
if (!remote_folder.empty() && remote_folder.back() != '/') remote_folder += '/';
if (!remote_folder.empty() && remote_folder.front() == '/') remote_folder.erase(0, 1);
// Normalise plate_0→plate_1 in the remote filename only; the archive
// itself is uploaded verbatim (matching the stock plugin, which never
// rewrites the user's .3mf).
std::string remote_name = print_job::to_print_basename(
print_job::pick_remote_name(params));
std::string remote_path = "/" + remote_folder + remote_name;
std::string ca_file = bambu_ca_bundle_path();
std::uint64_t total = 0;
std::string stored_path;
int rc = 0;
const bool brtc = print_job::use_brtc_cache_upload(params);
OBN_INFO("local_print: upload path=%s (try_emmc_print=%d, force_ftps=%d)",
brtc ? "brtc :6000" : "ftps :990",
params.try_emmc_print ? 1 : 0,
obn::config::current().force_ftps ? 1 : 0);
if (brtc) {
obn::tunnel_upload::ConnectParams cp =
obn::tunnel_upload::connect_params_from_print(
params.dev_ip, params.dev_id, params.password);
obn::tunnel_upload::UploadRequest ureq;
ureq.local_path = params.filename;
ureq.dest_storage = "emmc";
ureq.dest_name = remote_name;
obn::tunnel_upload::UploadCallbacks cb;
cb.cancelled = [&]() {
return cancel_fn && cancel_fn();
};
if (update_fn) update_fn(BBL::PrintingStageUpload, 0, "");
cb.progress = [&](int pct) {
if (update_fn) update_fn(BBL::PrintingStageUpload, pct, "");
};
obn::tunnel_upload::UploadOutcome outcome;
rc = obn::tunnel_upload::upload_file(
cp, ureq, cb, &outcome,
BAMBU_NETWORK_ERR_PRINT_LP_UPLOAD_FTP_FAILED);
if (rc != 0) return rc;
total = outcome.bytes;
stored_path = remote_name;
OBN_INFO("local_print: brtc upload %llu bytes to emmc/%s",
static_cast<unsigned long long>(total), remote_name.c_str());
} else {
rc = print_job::ftp_upload(params, remote_path, ca_file, update_fn, cancel_fn,
BAMBU_NETWORK_ERR_PRINT_LP_UPLOAD_FTP_FAILED, total,
&stored_path);
if (rc != 0) return rc;
}
if (cancel_fn && cancel_fn()) {
if (update_fn) update_fn(BBL::PrintingStageERROR, BAMBU_NETWORK_ERR_CANCELED, "cancelled");
return BAMBU_NETWORK_ERR_CANCELED;
}
if (update_fn) update_fn(BBL::PrintingStageWaiting, 0, "");
print_job::ProjectFileOpts opts;
opts.file_path = stored_path;
if (print_job::use_brtc_cache_upload(params)) {
opts.url = print_job::build_brtc_emmc_url(remote_name);
} else {
opts.url = print_job::build_ftp_url(stored_path);
}
// Stock 02.05.00–02.08.02 always emits the literal "from_sd_card",
// even on the FTPS local_print path and even when ftp_file_md5 is
// filled in. A real file hash never appeared in any captured frame.
opts.md5 = "from_sd_card";
std::string json = print_job::build_project_file_json(params, opts);
OBN_DEBUG("local_print mqtt: %s", json.c_str());
// Uploads can outlive the printer's LAN MQTT session. In particular, some
// A-series firmware closes an otherwise healthy connection while an FTPS
// transfer is in progress. Re-establish it here so a completed upload is
// not reported as a 70% failure merely because project_file had no live
// transport left to publish on. The wait is up to a few seconds, so a
// cancel that arrives during it must win over both the failure report
// and the publish.
const bool lan_up =
ensure_lan_session(params.dev_id, params.dev_ip, params.password);
if (cancel_fn && cancel_fn()) {
if (update_fn) update_fn(BBL::PrintingStageERROR, BAMBU_NETWORK_ERR_CANCELED, "cancelled");
return BAMBU_NETWORK_ERR_CANCELED;
}
if (!lan_up) {
OBN_ERROR("local_print: MQTT reconnect after upload failed");
if (update_fn) update_fn(BBL::PrintingStageERROR,
BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED,
"MQTT reconnect failed after upload");
return BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED;
}
int pub = send_message(params.dev_id, json, /*qos=*/0);
if (pub != 0) {
OBN_ERROR("local_print: publish project_file failed rc=%d", pub);
if (update_fn) update_fn(BBL::PrintingStageERROR,
BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED,
"MQTT publish failed");
return BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED;
}
print_job::emit_finished_countdown(update_fn, cancel_fn);
OBN_INFO("local_print dev=%s: queued for printing (uploaded %llu bytes, plate=%d)",
params.dev_id.c_str(), static_cast<unsigned long long>(total),
params.plate_index);
return 0;
}
int Agent::run_sdcard_print_job(const BBL::PrintParams& params,
BBL::OnUpdateStatusFn update_fn,
BBL::WasCancelledFn cancel_fn)
{
// The file is already on the printer (we listed it via the
// PrinterFileSystem CTRL channel); all we do is tell the printer
// to start a print from that path. Studio routes this through
// "cloud" by design, but Developer Mode printers have no cloud
// route - the command has to go over LAN MQTT.
OBN_INFO("sdcard_print dev=%s ip=%s dst_file=%s plate=%d use_ams=%d",
params.dev_id.c_str(), params.dev_ip.c_str(),
params.dst_file.c_str(), params.plate_index,
params.task_use_ams ? 1 : 0);
print_params_set_use_ssl_for_ftp(params.use_ssl_for_ftp);
if (params.dev_id.empty()) {
if (update_fn) update_fn(BBL::PrintingStageERROR,
BAMBU_NETWORK_ERR_CONNECTION_TO_PRINTER_FAILED,
"no dev_id");
return BAMBU_NETWORK_ERR_CONNECTION_TO_PRINTER_FAILED;
}
if (params.dst_file.empty()) {
if (update_fn) update_fn(BBL::PrintingStageERROR,
BAMBU_NETWORK_ERR_FILE_NOT_EXIST,
"no dst_file");
return BAMBU_NETWORK_ERR_FILE_NOT_EXIST;
}
if (cancel_fn && cancel_fn()) return BAMBU_NETWORK_ERR_CANCELED;
// Stock ignores dst_file on the wire: `file` is the project_name
// basename (pick_remote_name), there is no url/url_enc key, and
// md5 is the literal "from_sd_card". dst_file is still required
// above because that's the ABI contract Studio fills for
// from_sdcard_view — we just don't forward it.
print_job::ProjectFileOpts opts;
opts.file_path = print_job::pick_remote_name(params);
opts.url = "";
opts.md5 = "from_sd_card";
opts.project_id = "0";
opts.profile_id = "0";
opts.task_id = "0";
opts.subtask_id = "0";
std::string json = print_job::build_project_file_json(params, opts);
OBN_DEBUG("sdcard_print mqtt: %s", json.c_str());
if (update_fn) update_fn(BBL::PrintingStageSending, 0, "");
int pub = send_message(params.dev_id, json, /*qos=*/0);
if (pub != 0) {
OBN_ERROR("sdcard_print: publish project_file failed rc=%d", pub);
if (update_fn) update_fn(BBL::PrintingStageERROR,
BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED,
"MQTT publish failed");
return BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED;
}
print_job::emit_finished_countdown(update_fn, cancel_fn);
OBN_INFO("sdcard_print dev=%s: queued for printing (file=%s, plate=%d)",
params.dev_id.c_str(), opts.file_path.c_str(), params.plate_index);
return 0;
}
int Agent::run_send_gcode_to_sdcard(const BBL::PrintParams& params,
BBL::OnUpdateStatusFn update_fn,
BBL::WasCancelledFn cancel_fn)
{
OBN_INFO("send_gcode_to_sdcard dev=%s ip=%s file=%s",
params.dev_id.c_str(), params.dev_ip.c_str(), params.filename.c_str());
print_params_set_use_ssl_for_ftp(params.use_ssl_for_ftp);
if (params.dev_ip.empty() || params.password.empty()) {
if (update_fn) update_fn(BBL::PrintingStageERROR,
BAMBU_NETWORK_ERR_CONNECTION_TO_PRINTER_FAILED,
"no dev_ip/access_code");
return BAMBU_NETWORK_ERR_CONNECTION_TO_PRINTER_FAILED;
}
std::string remote_name = print_job::dest_name_for_send_gcode(params);
if (remote_name.empty()) {
if (update_fn) update_fn(BBL::PrintingStageERROR,
BAMBU_NETWORK_ERR_PRINT_SG_UPLOAD_FTP_FAILED,
"empty project_name");
return BAMBU_NETWORK_ERR_PRINT_SG_UPLOAD_FTP_FAILED;
}
std::string remote_folder = params.ftp_folder;
if (!remote_folder.empty() && remote_folder.back() != '/') remote_folder += '/';
if (!remote_folder.empty() && remote_folder.front() == '/') remote_folder.erase(0, 1);
std::string remote_path = "/" + remote_folder + remote_name;
std::string ca_file = bambu_ca_bundle_path();
std::uint64_t total = 0;
int rc = print_job::ftp_upload(params, remote_path, ca_file, update_fn, cancel_fn,
BAMBU_NETWORK_ERR_PRINT_SG_UPLOAD_FTP_FAILED, total);
if (rc != 0) return rc;
print_job::emit_finished_countdown(update_fn, cancel_fn);
return 0;
}
} // namespace obn