Files
open-bamboo-networking/tools/plugin_runner/main.cpp
T
Alexey Cluster 304fa24777 Clean up
Signed-off-by: Alexey Cluster <cluster@cluster.wtf>
2026-05-20 16:56:16 +02:00

1039 lines
43 KiB
C++
Raw 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.
// plugin_runner: Bambu Studio shim that loads any libbambu_networking.so
// (matching the ABI_VERSION this binary was compiled against), drives the
// minimal init sequence Studio uses, and then triggers one of the four
// LAN print entry points with a user-supplied BBL::PrintParams. Real
// MQTT publishes go straight to the printer; capture them in parallel
// with tools/bambu_mqtt_spy.sh.
//
// CLI shape and field semantics are documented in tools/plugin_runner/README.md.
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <csignal>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <ctime>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <mutex>
#include <optional>
#include <sstream>
#include <string>
#include <thread>
#include <vector>
#include <sys/stat.h>
#include <unistd.h>
#include <nlohmann/json.hpp>
#include "obn/bambu_networking.hpp"
#include "plugin_downloader.hpp"
#include "plugin_loader.hpp"
#include "print_params_io.hpp"
namespace pr = obn::plugin_runner;
using json = nlohmann::json;
// ---------------------------------------------------------------------------
// Logging — every event the plugin pushes to a callback is rendered as one
// JSON line on stdout (and optionally mirrored to --log-out). Keeps the
// post-run analysis trivially `jq`-able without obscuring the wire data.
// ---------------------------------------------------------------------------
namespace {
std::mutex g_log_mu;
std::ofstream g_log_file;
std::string iso8601_now()
{
using namespace std::chrono;
auto now = system_clock::now();
auto secs = system_clock::to_time_t(now);
auto us = duration_cast<microseconds>(now.time_since_epoch()).count() % 1'000'000;
std::tm tm{};
gmtime_r(&secs, &tm);
std::ostringstream os;
os << std::put_time(&tm, "%Y-%m-%dT%H:%M:%S")
<< '.' << std::setw(6) << std::setfill('0') << us << 'Z';
return os.str();
}
void emit_event(const std::string& kind, json payload)
{
payload["_t"] = iso8601_now();
payload["_kind"] = kind;
std::string line = payload.dump();
std::lock_guard<std::mutex> lk(g_log_mu);
std::cout << line << '\n';
std::cout.flush();
if (g_log_file) {
g_log_file << line << '\n';
g_log_file.flush();
}
}
void emit_text(const std::string& kind, const std::string& msg)
{
json j;
j["msg"] = msg;
emit_event(kind, std::move(j));
}
} // namespace
// ---------------------------------------------------------------------------
// CLI parsing — small hand-rolled parser to keep the binary dep-free.
// ---------------------------------------------------------------------------
namespace {
struct CliArgs {
std::string plugin_path;
std::string params_json;
std::string action = "send_gcode_to_sdcard";
std::string gcode_3mf;
// For --action send_raw: path to a JSON file whose contents are
// forwarded verbatim to send_message_to_printer (qos=1, flag=0,
// matching what Studio uses for printer commands). Use this to
// bypass the entire start_*_print flow and reverse the print
// command shape one field at a time, watching the printer's reply
// arrive over local_message.
std::string raw_json;
int raw_qos = 1;
int raw_flag = 0;
int raw_settle_s = 5;
// Repeat the publish N times with this gap. Useful to probe whether
// the LAN MQTT session quietly drops between publishes (e.g. while
// an FTPS upload runs in parallel and the publish channel goes idle).
int raw_repeat = 1;
int raw_repeat_interval_s = 5;
std::string dev_id;
std::string dev_ip;
std::string access_code;
std::string country = "US";
std::string log_out;
std::string cert_file; // path to slicer_base64.cer; auto-resolved if empty
// Optional payload for change_user(). Empty string keeps the
// existing "no logged-in user" behaviour. Pass a JSON blob (or
// @path/to/file) to fake a cloud login session — useful when a
// plugin gates privileged MQTT publishes (project_file, etc.) on
// is_user_login() returning true.
std::string user_info;
// For wire-format reverse-engineering: after a print action
// finishes (Finished or ERROR latched), publish
// {"print":{"command":"stop","param":"","sequence_id":"…"}}
// verbatim via send_message_to_printer. Only works on a printer
// that's in Developer Mode (otherwise the plugin's client-side
// filter drops the print:* publish with rc=-4). The printer flips
// gcode_state to FAILED within ~1s — the goal is to never let an
// experiment leave physical heat-up running.
bool auto_stop = false;
bool use_ssl_mqtt = true;
int timeout_s = 90;
int connect_settle_ms = 800;
bool keep_tmpdir = false;
// If non-empty, use this as the plugin's config dir instead of
// mkdtemp'ing a fresh one. The killer use case: re-using a real
// BambuStudio config dir (typically ~/.config/BambuStudio) so the
// device cert at <config_dir>/certs/<dev_id>.pem (provisioned by
// the real Studio when it bound the printer) is already in place
// — install_device_cert / project_file signing both depend on it,
// and a fresh mkdtemp can't replicate it offline.
std::string data_dir;
// Stock plugins keep their MQTT / asio worker threads alive until
// their static destructors run at process exit; calling
// destroy_agent + disconnect_printer cleanly inside main can block
// up to ~60s waiting for those workers to drain. With --fast-exit
// we just flush logs and `_Exit` straight after the action
// completes. Default: true for --action none (diagnostics, you
// already saw what you cared about), false otherwise.
std::optional<bool> fast_exit;
// --download-only mode: skip the agent flow, just resolve a plugin
// for the given ABI prefix into the cache and print its absolute
// path on stdout. Used by the wrapper script before it picks the
// matching build dir.
bool download_only = false;
std::string abi_prefix; // MM.mm.pp
std::string cache_dir; // override for ~/.cache/obn-plugin-runner
bool force_download = false;
};
[[noreturn]] void usage(int rc)
{
std::fputs(
R"(usage: plugin_runner --plugin-path PATH --params-json FILE --action ACTION
[--gcode-3mf PATH] --dev-id ID --dev-ip IP --access-code CODE
[--country US] [--use-ssl-mqtt 0|1]
[--cert-file PATH] [--timeout SECONDS]
[--connect-settle-ms MS]
[--log-out PATH] [--keep-tmpdir]
plugin_runner --action send_raw --raw-json FILE
--plugin-path PATH --dev-id ID --dev-ip IP --access-code CODE
[--raw-qos 1] [--raw-flag 0] [--raw-settle 5]
plugin_runner --download-only --abi MM.mm.pp [--cache-dir DIR] [--force]
ACTION is one of: send_gcode_to_sdcard | local_print | sdcard_print
| local_print_with_record | send_raw | none
none: skip the print dispatch entirely. Useful for diagnostics: just
init + connect_printer, then sit for --timeout seconds dumping every
callback event, then tear down cleanly.
send_raw: skip start_*_print and call send_message_to_printer directly
with the contents of --raw-json. The killer primitive for reverse-
engineering printer commands: hand-craft a payload, observe the reply
arrive over local_message during --raw-settle seconds, iterate.
--params-json is not required for this action.
Driven from tools/plugin_runner.sh; see tools/plugin_runner/README.md.
)", stderr);
std::exit(rc);
}
std::string require(const std::vector<std::string>& a, size_t i, const std::string& flag)
{
if (i >= a.size()) {
std::fprintf(stderr, "plugin_runner: missing value for %s\n", flag.c_str());
usage(64);
}
return a[i];
}
CliArgs parse_cli(int argc, char** argv)
{
CliArgs c;
std::vector<std::string> a(argv + 1, argv + argc);
for (size_t i = 0; i < a.size(); ++i) {
const std::string& f = a[i];
if (f == "--plugin-path") c.plugin_path = require(a, ++i, f);
else if (f == "--params-json") c.params_json = require(a, ++i, f);
else if (f == "--action") c.action = require(a, ++i, f);
else if (f == "--gcode-3mf") c.gcode_3mf = require(a, ++i, f);
else if (f == "--raw-json") c.raw_json = require(a, ++i, f);
else if (f == "--raw-qos") c.raw_qos = std::stoi(require(a, ++i, f));
else if (f == "--raw-flag") c.raw_flag = std::stoi(require(a, ++i, f));
else if (f == "--raw-settle") c.raw_settle_s = std::stoi(require(a, ++i, f));
else if (f == "--raw-repeat") c.raw_repeat = std::stoi(require(a, ++i, f));
else if (f == "--raw-repeat-interval-s") c.raw_repeat_interval_s = std::stoi(require(a, ++i, f));
else if (f == "--dev-id") c.dev_id = require(a, ++i, f);
else if (f == "--dev-ip") c.dev_ip = require(a, ++i, f);
else if (f == "--access-code") c.access_code = require(a, ++i, f);
else if (f == "--country") c.country = require(a, ++i, f);
else if (f == "--cert-file") c.cert_file = require(a, ++i, f);
else if (f == "--log-out") c.log_out = require(a, ++i, f);
else if (f == "--use-ssl-mqtt") c.use_ssl_mqtt = std::stoi(require(a, ++i, f)) != 0;
else if (f == "--timeout") c.timeout_s = std::stoi(require(a, ++i, f));
else if (f == "--connect-settle-ms") c.connect_settle_ms = std::stoi(require(a, ++i, f));
else if (f == "--keep-tmpdir") c.keep_tmpdir = true;
else if (f == "--data-dir") c.data_dir = require(a, ++i, f);
else if (f == "--user-info") c.user_info = require(a, ++i, f);
else if (f == "--auto-stop") c.auto_stop = true;
else if (f == "--fast-exit") c.fast_exit = true;
else if (f == "--no-fast-exit") c.fast_exit = false;
else if (f == "--download-only") c.download_only = true;
else if (f == "--abi") c.abi_prefix = require(a, ++i, f);
else if (f == "--cache-dir") c.cache_dir = require(a, ++i, f);
else if (f == "--force") c.force_download = true;
else if (f == "-h" || f == "--help") usage(0);
else {
std::fprintf(stderr, "plugin_runner: unknown flag '%s'\n", f.c_str());
usage(64);
}
}
if (c.download_only) {
if (c.abi_prefix.empty()) {
std::fprintf(stderr, "plugin_runner: --download-only requires --abi MM.mm.pp\n");
usage(64);
}
return c;
}
// params_json is only mandatory for actions that consume PrintParams.
// send_raw / none can run without it.
bool needs_params = (c.action != "send_raw" && c.action != "none");
if (c.plugin_path.empty() ||
c.dev_id.empty() || c.dev_ip.empty() || c.access_code.empty()) {
std::fprintf(stderr, "plugin_runner: --plugin-path, --dev-id, --dev-ip "
"and --access-code are all required\n");
usage(64);
}
if (needs_params && c.params_json.empty()) {
std::fprintf(stderr, "plugin_runner: --params-json is required for action '%s'\n",
c.action.c_str());
usage(64);
}
if (c.action == "send_raw" && c.raw_json.empty()) {
std::fprintf(stderr, "plugin_runner: --action send_raw requires --raw-json PATH\n");
usage(64);
}
return c;
}
std::string default_cache_dir()
{
const char* xdg = std::getenv("XDG_CACHE_HOME");
if (xdg && *xdg) return std::string(xdg) + "/obn-plugin-runner";
const char* home = std::getenv("HOME");
if (home && *home) return std::string(home) + "/.cache/obn-plugin-runner";
return "/tmp/obn-plugin-runner";
}
} // namespace
// ---------------------------------------------------------------------------
// Studio-mock environment: ephemeral data_dir with a 3-line BambuStudio.conf
// and a best-effort cert handoff. The stock plugin reads BambuStudio.conf
// during init for country_code/region; the rest of Studio's user state is
// LAN-irrelevant and we deliberately don't fake it.
// ---------------------------------------------------------------------------
namespace {
struct Tmpdir {
std::string path;
bool keep = false;
~Tmpdir() {
if (!keep && !path.empty()) {
std::string cmd = "rm -rf -- '" + path + "'";
int rc = std::system(cmd.c_str());
(void)rc;
}
}
};
Tmpdir make_tmpdir(bool keep)
{
char tmpl[] = "/tmp/obn-plugin-runner-XXXXXX";
if (!mkdtemp(tmpl)) {
throw std::runtime_error(std::string("mkdtemp failed: ") + std::strerror(errno));
}
Tmpdir t;
t.path = tmpl;
t.keep = keep;
return t;
}
void write_min_studio_conf(const std::string& dir, const std::string& country)
{
std::ofstream o(dir + "/BambuStudio.conf");
if (!o) {
throw std::runtime_error("cannot open BambuStudio.conf for writing in " + dir);
}
// Minimal AppConfig the stock plugin reads at init. Mirrors the tiny
// subset of Slic3r::AppConfig that bambu_network_set_config_dir +
// change_user flow actually inspects. Anything missing falls back to
// documented defaults inside the plugin.
o << "{\n"
<< " \"app\": {\n"
<< " \"country_code\": \"" << country << "\",\n"
<< " \"language\": \"en_US\",\n"
<< " \"region\": \"" << country << "\"\n"
<< " }\n"
<< "}\n";
}
struct CertResolution {
std::string folder;
std::string filename;
};
CertResolution resolve_cert(const std::string& cli_path)
{
CertResolution r;
auto split = [&](const std::string& full) {
auto slash = full.rfind('/');
if (slash == std::string::npos) {
r.folder = ".";
r.filename = full;
} else {
r.folder = full.substr(0, slash);
r.filename = full.substr(slash + 1);
}
};
if (!cli_path.empty()) {
split(cli_path);
return r;
}
// slicer_base64.cer is the trust anchor the stock plugin uses to verify
// the printer's self-signed TLS certificate during MQTT handshake. With
// an empty path the LAN MQTT connect deterministically fails with
// status=ConnectStatusFailed, msg="-1" — surfacing as a generic OpenSSL
// verify error inside the (encrypted) plugin log. Search a handful of
// well-known locations so the runner just works on a typical dev
// machine; users with non-standard layouts can still pass --cert-file.
const char* home = std::getenv("HOME");
std::vector<std::string> candidates;
if (home) {
candidates.emplace_back(std::string(home) + "/.config/BambuStudio/cert/slicer_base64.cer");
candidates.emplace_back(std::string(home) + "/BambuStudio/resources/cert/slicer_base64.cer");
candidates.emplace_back(std::string(home) + "/Bambu_Studio/resources/cert/slicer_base64.cer");
}
candidates.emplace_back("/usr/share/BambuStudio/resources/cert/slicer_base64.cer");
candidates.emplace_back("/opt/BambuStudio/resources/cert/slicer_base64.cer");
for (const auto& candidate : candidates) {
struct stat st{};
if (stat(candidate.c_str(), &st) == 0) {
split(candidate);
return r;
}
}
return r;
}
} // namespace
// ---------------------------------------------------------------------------
// Completion latch — the print actions are async; update_fn flips the latch
// when the plugin reports PrintingStageFinished (success) or
// PrintingStageERROR (failure). main waits on this with a deadline.
// ---------------------------------------------------------------------------
namespace {
struct Latch {
std::mutex m;
std::condition_variable cv;
bool done = false;
int last_status = -1;
int last_code = 0;
std::string last_msg;
void mark(int status, int code, std::string msg) {
{
std::lock_guard<std::mutex> lk(m);
last_status = status;
last_code = code;
last_msg = std::move(msg);
done = true;
}
cv.notify_all();
}
bool wait_for(std::chrono::seconds dur) {
std::unique_lock<std::mutex> lk(m);
return cv.wait_for(lk, dur, [&]{ return done; });
}
};
// Tiny one-shot latch used to wait for the LAN MQTT session to come up
// (set_on_local_connect_fn, status=ConnectStatusOk). connect_printer is
// async — the plugin returns rc=0 immediately and the actual TLS
// handshake takes ~5–6s. Publishing a message before this callback
// fires deterministically returns -4 / -4030 inside the plugin (paho
// reports MQTTASYNC_DISCONNECTED). Studio essentially polls
// MachineObject::is_connecting() in its event loop; we just block.
struct ReadyLatch {
std::mutex m;
std::condition_variable cv;
bool ready = false;
void signal() {
{ std::lock_guard<std::mutex> lk(m); ready = true; }
cv.notify_all();
}
bool wait_for(std::chrono::milliseconds dur) {
std::unique_lock<std::mutex> lk(m);
return cv.wait_for(lk, dur, [&]{ return ready; });
}
};
const char* stage_name(int s)
{
using namespace BBL;
switch (s) {
case PrintingStageCreate: return "Create";
case PrintingStageUpload: return "Upload";
case PrintingStageWaiting: return "Waiting";
case PrintingStageSending: return "Sending";
case PrintingStageRecord: return "Record";
case PrintingStageWaitPrinter: return "WaitPrinter";
case PrintingStageFinished: return "Finished";
case PrintingStageERROR: return "ERROR";
default: return "?";
}
}
} // namespace
// ---------------------------------------------------------------------------
// Init order is the verbatim copy of GUI_App.cpp:3596-3618:
// create_agent -> set_config_dir -> init_log -> set_cert_file
// -> install all callbacks -> set_country_code -> start()
// Studio additionally calls change_user("") right after to mark the
// session as logged out — some plugins refuse LAN print until this has
// happened at least once.
// ---------------------------------------------------------------------------
int main(int argc, char** argv)
try {
CliArgs args = parse_cli(argc, argv);
if (args.download_only) {
std::string cache = args.cache_dir.empty() ? default_cache_dir() : args.cache_dir;
auto r = pr::fetch_plugin(args.abi_prefix, cache, args.force_download);
if (!r.ok) {
std::fprintf(stderr, "plugin_runner: download failed: %s\n",
r.error_message.c_str());
return 69; // EX_UNAVAILABLE
}
// stdout: machine-readable single-line JSON for the wrapper to parse.
json out;
out["ok"] = true;
out["so_path"] = r.so_path;
out["version"] = r.version;
out["http"] = r.http_status;
std::cout << out.dump() << '\n';
return 0;
}
if (!args.log_out.empty()) {
g_log_file.open(args.log_out, std::ios::trunc);
if (!g_log_file) {
std::fprintf(stderr, "plugin_runner: cannot open --log-out '%s'\n",
args.log_out.c_str());
return 73;
}
}
emit_event("startup", {
{"plugin_path", args.plugin_path},
{"abi_version", static_cast<unsigned>(ABI_VERSION)},
{"action", args.action},
{"dev_id", args.dev_id},
{"dev_ip", args.dev_ip},
});
// Step 1: dlopen + symbol resolution
pr::PluginExports exports = pr::load(args.plugin_path);
emit_event("plugin_loaded", {
{"version", exports.version},
{"so", exports.so_path},
});
// Step 2: Studio data_dir. Either reuse a real one (--data-dir, e.g.
// ~/.config/BambuStudio so the device cert provisioned by Studio is
// available) or mkdtemp a fresh ephemeral one. We never overwrite
// BambuStudio.conf in a user-supplied dir.
Tmpdir tmpdir;
if (!args.data_dir.empty()) {
tmpdir.path = args.data_dir;
tmpdir.keep = true; // never wipe a user-supplied dir
struct stat st{};
if (stat(tmpdir.path.c_str(), &st) != 0 || !S_ISDIR(st.st_mode)) {
emit_text("fatal", "--data-dir does not exist or is not a "
"directory: " + tmpdir.path);
return 66;
}
} else {
tmpdir = make_tmpdir(args.keep_tmpdir);
write_min_studio_conf(tmpdir.path, args.country);
}
emit_event("data_dir", {
{"path", tmpdir.path},
{"reuse", !args.data_dir.empty()},
});
CertResolution cert = resolve_cert(args.cert_file);
if (cert.filename.empty()) {
emit_text("warning",
"no slicer_base64.cer resolved — LAN MQTT handshake will fail "
"(local_connect status=1, msg=-1). Pass --cert-file PATH or copy "
"BambuStudio/resources/cert/slicer_base64.cer into "
"~/.config/BambuStudio/cert/.");
} else {
emit_event("cert_resolved", {
{"folder", cert.folder}, {"filename", cert.filename},
});
}
// Step 3: create the agent. log_dir == data_dir is what Studio passes in.
void* agent = exports.create_agent(tmpdir.path);
if (!agent) {
emit_text("fatal", "create_agent returned null");
return 70;
}
// RAII for the agent: any subsequent throw still tears down the
// plugin's worker threads. Without this the dlopen'd library's
// global destructors hit joinable std::thread members and abort
// with "terminate called without an active exception".
struct AgentGuard {
pr::PluginExports* ex;
void* a;
~AgentGuard() {
if (ex && a) {
if (ex->disconnect_printer) ex->disconnect_printer(a);
if (ex->destroy_agent) ex->destroy_agent(a);
}
}
};
AgentGuard guard{&exports, agent};
// Step 4: install callbacks (each one just dumps a JSON event).
Latch latch;
ReadyLatch session_ready;
std::atomic<bool> printer_connected{false};
exports.set_on_local_message_fn(agent,
[](std::string dev_id, std::string msg) {
emit_event("local_message", { {"dev_id", dev_id}, {"msg", msg} });
});
exports.set_on_message_fn(agent,
[](std::string dev_id, std::string msg) {
emit_event("message", { {"dev_id", dev_id}, {"msg", msg} });
});
exports.set_on_user_message_fn(agent,
[](std::string dev_id, std::string msg) {
emit_event("user_message", { {"dev_id", dev_id}, {"msg", msg} });
});
exports.set_on_local_connect_fn(agent,
[&printer_connected, &session_ready](int status, std::string dev_id, std::string msg) {
emit_event("local_connect", {
{"status", status}, {"dev_id", dev_id}, {"msg", msg},
});
using namespace BBL;
if (status == ConnectStatusOk) {
printer_connected.store(true);
session_ready.signal();
}
});
exports.set_on_printer_connected_fn(agent,
[&printer_connected, &session_ready](std::string topic) {
emit_event("printer_connected", { {"topic", topic} });
printer_connected.store(true);
session_ready.signal();
});
exports.set_on_server_connected_fn(agent,
[](int rc, int reason) {
emit_event("server_connected", { {"rc", rc}, {"reason", reason} });
});
exports.set_on_http_error_fn(agent,
[](unsigned int status, std::string body) {
emit_event("http_error", { {"status", status}, {"body", body} });
});
exports.set_on_subscribe_failure_fn(agent,
[](std::string topic) {
emit_event("subscribe_failure", { {"topic", topic} });
});
{
// get_country_code is polled by the plugin to refresh region
// routing; we just echo back the CLI value.
std::string country = args.country;
exports.set_get_country_code_fn(agent, [country]() { return country; });
}
if (exports.set_queue_on_main_fn) {
// Inline-execute every "post to main thread" the plugin requests.
// No UI loop here — running on whichever worker thread queued
// the call is safe for our LAN-print scope.
exports.set_queue_on_main_fn(agent, [](std::function<void()> fn) {
if (fn) fn();
});
}
exports.set_server_callback(agent,
[](std::string url, int status) {
emit_event("server_callback", { {"url", url}, {"status", status} });
});
exports.set_on_ssdp_msg_fn(agent,
[](std::string info_json) {
emit_event("ssdp_msg", { {"info", info_json} });
});
// Step 5: replicate Studio's init order (GUI_App.cpp:3596-3618).
// Order matters — set_extra_http_header lands BEFORE start() so the
// X-BBL-* headers are already in the plugin's HTTP client by the time
// it does its first connect / cert exchange against the printer.
exports.set_config_dir(agent, tmpdir.path);
exports.init_log(agent);
exports.set_cert_file(agent, cert.folder, cert.filename);
{
// Studio's init_http_extra_header() — these headers propagate to
// every outgoing HTTP request including the LAN /info ping.
// X-BBL-Client-Version uses Studio's full SLIC3R_VERSION; we
// pretend to be a current build so the API doesn't reject the
// request as legacy.
std::map<std::string, std::string> hdrs;
hdrs["X-BBL-Client-Type"] = "slicer";
hdrs["X-BBL-Client-Name"] = "OpenBambooNetworking";
hdrs["X-BBL-Client-Version"] = "02.05.03.99";
hdrs["X-BBL-OS-Type"] = "linux";
hdrs["X-BBL-OS-Version"] = "1.0.0";
hdrs["X-BBL-Language"] = "en";
exports.set_extra_http_header(agent, hdrs);
emit_event("extra_http_header", { {"count", hdrs.size()} });
}
exports.set_country_code(agent, args.country);
int rc_start = exports.start(agent);
emit_event("agent_start", { {"rc", rc_start} });
// Step 5.5: post-start init Studio always runs (GUI_App.cpp:3586-3594
// for multi_machine, then start_discovery elsewhere). Both are
// best-effort — failures here are not fatal but missing wires can
// leave the plugin in a half-initialised state.
exports.enable_multi_machine(agent, false);
bool disc_ok = exports.start_discovery(agent, true, false);
emit_event("post_start_init", {
{"multi_machine", false}, {"start_discovery", disc_ok},
});
// Step 6: prime the plugin's user session. Empty string mirrors
// Studio's first-frame "no logged-in user" state. A non-empty
// --user-info value is forwarded verbatim — pass a minimal
// {"user_id":"default_user","token":"…"} to convince the plugin
// that a cloud session is active (some builds gate the privileged
// MQTT publishes on is_user_login()).
std::string user_blob = args.user_info;
if (!user_blob.empty() && user_blob.front() == '@') {
std::ifstream uf(user_blob.substr(1));
if (!uf) {
emit_text("fatal", "cannot open --user-info file: " + user_blob.substr(1));
return 66;
}
std::stringstream uss; uss << uf.rdbuf();
user_blob = uss.str();
}
int rc_user = exports.change_user(agent, user_blob);
bool logged = exports.is_user_login ? exports.is_user_login(agent) : false;
emit_event("change_user", {
{"rc", rc_user},
{"bytes", user_blob.size()},
{"is_logged_in", logged},
});
// Step 6.5: bind_detect — the LAN HTTP /info ping Studio runs before
// connect_printer (sLocalBindFunc in GUI_App.cpp:8242). The stock
// plugin needs detectResult cached so its MQTT layer knows whether
// it's talking to a LAN/cloud/farm device. Skipping it makes the
// subsequent local_connect callback fire with status=1, msg="-1"
// even though connect_printer itself returns 0.
{
BBL::detectResult detect{};
int rc_detect = exports.bind_detect(agent, args.dev_ip, "secure", detect);
emit_event("bind_detect", {
{"rc", rc_detect},
{"dev_id", detect.dev_id},
{"dev_name", detect.dev_name},
{"model_id", detect.model_id},
{"version", detect.version},
{"bind_state", detect.bind_state},
{"connect_type", detect.connect_type},
{"command", detect.command},
{"result_msg", detect.result_msg},
});
}
// Step 7: connect the printer over LAN MQTT. connect_printer is
// async — it returns rc=0 immediately and the actual TLS handshake +
// subscribe completes a few seconds later. We must wait for
// local_connect(ConnectStatusOk) before doing anything else.
int rc_conn = exports.connect_printer(agent, args.dev_id, args.dev_ip,
/*username=*/"bblp",
/*password=*/args.access_code,
/*use_ssl=*/args.use_ssl_mqtt);
emit_event("connect_printer_call", { {"rc", rc_conn} });
{
auto cap = std::chrono::milliseconds(std::max(args.connect_settle_ms, 1000));
bool got = session_ready.wait_for(cap);
emit_event("session_ready", { {"got", got}, {"cap_ms", cap.count()} });
if (!got) {
emit_text("warning",
"local_connect ConnectStatusOk did not fire within "
"--connect-settle-ms; LAN MQTT publish will likely fail. "
"Increase --connect-settle-ms or check cert / network.");
}
}
// Step 7.5: replicate Studio's post-connect bring-up. Studio calls
// start_subscribe("app") (cloud telemetry, harmless on LAN) and then
// polls command_request_push_all() in keep_alive() until the printer
// emits its first push_status (m_push_count > 0). The pushall is the
// *only* publish Studio guarantees in the first second — without it
// the printer never spontaneously sends a fresh full state, so we
// can't tell the session is healthy. Doing it ourselves here also
// serves as a smoke test of send_message_to_printer (any non-zero rc
// means the LAN publish channel is broken — fail fast).
{
int rc_sub = exports.start_subscribe(agent, "app");
emit_event("start_subscribe", { {"module", "app"}, {"rc", rc_sub} });
}
{
// Verbatim copy of Studio's MachineObject::command_request_push_all
// payload. sequence_id is a string in the original; we use a
// fixed value because uniqueness only matters for response
// matching, which we don't do.
const std::string pushall =
"{\"pushing\":{\"sequence_id\":\"0\",\"command\":\"pushall\","
"\"version\":1,\"push_target\":1}}";
int rc_push = exports.send_message_to_printer(
agent, args.dev_id, pushall, /*qos=*/1, /*flag=*/0);
emit_event("kickstart_pushall", {
{"rc", rc_push},
{"bytes", pushall.size()},
});
if (rc_push != 0) {
emit_text("warning",
"kickstart pushall publish failed — LAN MQTT publish "
"channel is not actually ready, downstream actions will "
"likely also fail.");
}
// Mirrors MachineObject::command_get_version (publish_json
// {"info":{"sequence_id":"…","command":"get_version"}}, qos=1).
// The plugin doesn't gate anything on the response but Studio
// always sends it, so we replicate to keep the printer's
// observed sequence identical.
const std::string get_version =
"{\"info\":{\"sequence_id\":\"1\",\"command\":\"get_version\"}}";
int rc_ver = exports.send_message_to_printer(
agent, args.dev_id, get_version, /*qos=*/1, /*flag=*/0);
emit_event("kickstart_get_version", {
{"rc", rc_ver}, {"bytes", get_version.size()},
});
// Mirrors MachineObject::command_get_access_code
// (publish_json {"system":{"sequence_id":"…","command":"get_access_code"}}).
const std::string get_ac =
"{\"system\":{\"sequence_id\":\"2\",\"command\":\"get_access_code\"}}";
int rc_ac = exports.send_message_to_printer(
agent, args.dev_id, get_ac, /*qos=*/1, /*flag=*/0);
emit_event("kickstart_get_access_code", {
{"rc", rc_ac}, {"bytes", get_ac.size()},
});
// The keystone of Studio's post-connect bring-up: provision the
// per-printer device cert the plugin uses to RSA-sign privileged
// MQTT commands (project_file, etc.). Skipping this is the
// proximate cause of `start_local_print` returning -4030 ("send
// msg failed") on the Sending stage — every `print:*` payload
// the plugin tries to publish gets pre-flight-rejected with -4
// because there's no cert available to sign it with. lan_only=1
// matches what Studio passes when is_lan_mode_printer() is true
// (no cloud lookup).
if (exports.install_device_cert) {
exports.install_device_cert(agent, args.dev_id, /*lan_only=*/true);
emit_event("install_device_cert", {
{"dev_id", args.dev_id}, {"lan_only", true},
});
} else {
emit_text("warning",
"plugin does not export bambu_network_install_device_cert; "
"privileged MQTT publishes (start_local_print, etc.) will "
"likely fail with -4030.");
}
// Settle so:
// 1) the printer's pushall reply lands in local_message
// 2) install_device_cert finishes any async work (HTTP fetch
// from cloud, on-disk cert import, etc.) before we publish
// a privileged command. Empirically the cert provisioning
// path inside stock plugins can take ~3-5s on first call.
std::this_thread::sleep_for(std::chrono::milliseconds(3500));
}
// Step 8: load PrintParams from JSON, then back-fill the LAN-required
// fields from the CLI so the user doesn't have to encode the printer
// identity in every experiment.json. Skipped for actions that don't
// consume a PrintParams (send_raw, none).
BBL::PrintParams params{};
if (!args.params_json.empty()) {
std::vector<std::string> warns;
pr::load_print_params_from_json(args.params_json, params, warns);
for (auto& w : warns) emit_text("params_warning", w);
}
if (params.dev_id.empty()) params.dev_id = args.dev_id;
if (params.dev_ip.empty()) params.dev_ip = args.dev_ip;
if (params.username.empty()) params.username = "bblp";
if (params.password.empty()) params.password = args.access_code;
params.use_ssl_for_mqtt = args.use_ssl_mqtt;
if (!args.gcode_3mf.empty()) {
if (params.filename.empty()) params.filename = args.gcode_3mf;
// ftp_file is the destination basename on the printer's storage;
// default to the source basename if the user didn't override.
if (params.ftp_file.empty()) {
auto slash = args.gcode_3mf.rfind('/');
params.ftp_file = (slash == std::string::npos)
? args.gcode_3mf : args.gcode_3mf.substr(slash + 1);
}
}
auto on_update = [&latch](int status, int code, std::string msg) {
emit_event("update_status", {
{"status", status}, {"stage", stage_name(status)},
{"code", code}, {"msg", msg},
});
using namespace BBL;
if (status == PrintingStageFinished || status == PrintingStageERROR)
latch.mark(status, code, std::move(msg));
};
auto on_cancel = []() { return false; };
auto on_wait = [](int status, std::string job_info) {
emit_event("wait_status", { {"status", status}, {"info", job_info} });
return true;
};
// Step 9: dispatch the requested action.
int rc_action = -1;
if (args.action == "none") {
emit_text("info", "action=none — sleeping for --timeout seconds and "
"logging callbacks; no print dispatch");
std::this_thread::sleep_for(std::chrono::seconds(args.timeout_s));
emit_event("idle_done", { {"seconds", args.timeout_s} });
bool fast = args.fast_exit.value_or(true);
if (fast) {
emit_event("shutdown", { {"finished", true}, {"fast_exit", true} });
std::cout.flush();
if (g_log_file) g_log_file.flush();
std::_Exit(0);
}
guard.a = nullptr;
exports.disconnect_printer(agent);
exports.destroy_agent(agent);
pr::unload(exports);
emit_event("shutdown", { {"finished", true}, {"fast_exit", false} });
return 0;
} else if (args.action == "send_raw") {
// Read the JSON file verbatim and hand it to the plugin's
// direct-publish primitive. We deliberately don't validate or
// re-serialize: byte-for-byte control is the whole point of
// this mode.
std::ifstream rf(args.raw_json, std::ios::binary);
if (!rf) {
emit_text("fatal", "cannot open --raw-json file: " + args.raw_json);
return 66;
}
std::stringstream rss;
rss << rf.rdbuf();
std::string payload = rss.str();
emit_event("send_raw_call", {
{"path", args.raw_json},
{"bytes", payload.size()},
{"qos", args.raw_qos},
{"flag", args.raw_flag},
{"settle_s", args.raw_settle_s},
{"repeat", args.raw_repeat},
{"interval_s", args.raw_repeat_interval_s},
});
for (int i = 0; i < std::max(args.raw_repeat, 1); ++i) {
int rc = exports.send_message_to_printer(
agent, args.dev_id, payload, args.raw_qos, args.raw_flag);
emit_event("send_raw_rc", { {"iter", i}, {"rc", rc} });
rc_action = rc;
if (i + 1 < args.raw_repeat) {
std::this_thread::sleep_for(
std::chrono::seconds(args.raw_repeat_interval_s));
}
}
// send_message_to_printer is fire-and-forget; loiter for a few
// seconds so the printer's reply (push_status / err) lands in
// local_message before we tear down.
std::this_thread::sleep_for(std::chrono::seconds(args.raw_settle_s));
// Synthesise a "finished" so the standard wait/teardown path
// below doesn't loiter for the full --timeout.
latch.mark(BBL::PrintingStageFinished, rc_action, "send_raw done");
} else if (args.action == "send_gcode_to_sdcard") {
if (!exports.start_send_gcode_to_sdcard) {
emit_text("fatal", "plugin does not export bambu_network_start_send_gcode_to_sdcard");
return 70;
}
rc_action = exports.start_send_gcode_to_sdcard(agent, params, on_update, on_cancel, on_wait);
} else if (args.action == "local_print") {
if (!exports.start_local_print) {
emit_text("fatal", "plugin does not export bambu_network_start_local_print");
return 70;
}
rc_action = exports.start_local_print(agent, params, on_update, on_cancel);
} else if (args.action == "sdcard_print") {
if (!exports.start_sdcard_print) {
emit_text("fatal", "plugin does not export bambu_network_start_sdcard_print");
return 70;
}
rc_action = exports.start_sdcard_print(agent, params, on_update, on_cancel);
} else if (args.action == "local_print_with_record") {
if (!exports.start_local_print_with_record) {
emit_text("fatal", "plugin does not export bambu_network_start_local_print_with_record");
return 70;
}
rc_action = exports.start_local_print_with_record(agent, params, on_update, on_cancel, on_wait);
} else {
emit_text("fatal", "unknown --action '" + args.action + "'");
return 64;
}
emit_event("action_dispatched", { {"action", args.action}, {"rc", rc_action} });
// Step 10: wait for completion or timeout.
bool finished = latch.wait_for(std::chrono::seconds(args.timeout_s));
if (!finished) {
emit_event("timeout", { {"seconds", args.timeout_s} });
} else {
emit_event("finished", {
{"status", latch.last_status},
{"stage", stage_name(latch.last_status)},
{"code", latch.last_code},
{"msg", latch.last_msg},
});
}
// Step 10.5: optionally abort the print so the printer doesn't
// actually start heating / extruding after the experiment. Mirrors
// MachineObject::command_task_abort. Only works in Developer Mode
// (otherwise send_message_to_printer rejects print:* with rc=-4).
// Idempotent — sending stop to an already-FAILED job is a no-op
// on firmware side.
if (args.auto_stop &&
(args.action == "local_print" ||
args.action == "sdcard_print" ||
args.action == "local_print_with_record")) {
const std::string stop_payload =
"{\"print\":{\"command\":\"stop\",\"param\":\"\","
"\"sequence_id\":\"99999\"}}";
int rc_stop = exports.send_message_to_printer(
agent, args.dev_id, stop_payload, /*qos=*/1, /*flag=*/0);
emit_event("auto_stop", {
{"rc", rc_stop},
{"bytes", stop_payload.size()},
});
if (rc_stop != 0) {
emit_text("warning",
"auto_stop publish failed — the printer will keep the "
"job running. Either the printer is not in Developer "
"Mode (the plugin's client-side filter drops print:* "
"publishes) or the LAN MQTT session is gone. Send the "
"stop manually from the printer UI.");
}
// Brief settle so the FAILED state is visible in the next
// local_message before we tear down.
std::this_thread::sleep_for(std::chrono::milliseconds(800));
}
// Step 11: tear down. Default: graceful for real actions (so
// destroy_agent can flush any final MQTT publishes / FTP retries).
// Opt out with --fast-exit if you only care about events already in
// --log-out and don't want to wait ~60s for the worker pool to drain.
int exit_code = (!finished) ? 75
: (latch.last_status == BBL::PrintingStageFinished ? 0 : 1);
bool fast = args.fast_exit.value_or(false);
if (fast) {
emit_event("shutdown", { {"finished", finished}, {"fast_exit", true} });
std::cout.flush();
if (g_log_file) g_log_file.flush();
std::_Exit(exit_code);
}
guard.a = nullptr;
exports.disconnect_printer(agent);
exports.destroy_agent(agent);
pr::unload(exports);
emit_event("shutdown", { {"finished", finished}, {"fast_exit", false} });
return exit_code;
}
catch (const std::exception& e) {
std::fprintf(stderr, "plugin_runner: fatal: %s\n", e.what());
return 70;
}