mirror of
https://github.com/ClusterM/open-bamboo-networking.git
synced 2026-10-21 10:39:52 -05:00
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.
726 lines
31 KiB
C++
726 lines
31 KiB
C++
// 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
|