Files
open-bamboo-networking/tools/plugin_runner/main.cpp
T
2026-10-10 01:42:51 +02:00

2739 lines
121 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 <memory>
#include <mutex>
#include <optional>
#include <sstream>
#include <string>
#include <thread>
#include <utility>
#include <vector>
#include <dlfcn.h>
#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;
// Ephemeral data_dir, if any. The fast-exit path below bypasses destructors,
// so it has to remove the directory itself.
std::string g_ephemeral_data_dir;
[[noreturn]] void fast_exit(int code)
{
std::cout.flush();
if (g_log_file) g_log_file.flush();
if (!g_ephemeral_data_dir.empty()) {
std::string cmd = "rm -rf -- '" + g_ephemeral_data_dir + "'";
int rc = std::system(cmd.c_str());
(void)rc;
}
std::_Exit(code);
}
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));
}
// --action camera_url: cloud push_status arrives every second, so only its
// ipcam.tutk_server transitions are logged; every other frame (command
// replies, liveview, security, ...) is logged verbatim.
std::atomic<bool> g_camera_msg_filter{false};
std::atomic<int> g_wait_info_count{0};
std::mutex g_tutk_mu;
std::string g_last_tutk_server;
void emit_cloud_message(const std::string& dev_id, const std::string& msg)
{
// Stock posts dev_id="" + "wait_info" when it lacks this session's
// printer cert (Studio GUI_App::process_network_msg).
if (dev_id.empty() && msg == "wait_info") ++g_wait_info_count;
if (!g_camera_msg_filter) {
emit_event("message", { {"dev_id", dev_id}, {"msg", msg} });
return;
}
json j = json::parse(msg, nullptr, /*allow_exceptions=*/false);
const bool push_status = j.is_object() && j.contains("print") &&
j["print"].is_object() &&
j["print"].value("command", "") == "push_status";
if (!push_status) {
emit_event("message", { {"dev_id", dev_id}, {"msg", msg} });
return;
}
static std::atomic<bool> first_seen{false};
if (!first_seen.exchange(true)) emit_event("push_status_first", { {"dev_id", dev_id} });
const json& print = j["print"];
if (!print.contains("ipcam") || !print["ipcam"].is_object()) return;
const std::string tutk = print["ipcam"].value("tutk_server", "");
if (tutk.empty()) return;
std::lock_guard<std::mutex> lk(g_tutk_mu);
if (tutk == g_last_tutk_server) return;
emit_event("tutk_server", {
{"dev_id", dev_id}, {"from", g_last_tutk_server}, {"to", tutk},
});
g_last_tutk_server = tutk;
}
// Masks every query value of a camera URL (TUTK uid / authkey / passwd)
// down to a 3-char prefix plus its length.
std::string mask_camera_url(const std::string& url)
{
const auto q = url.find('?');
if (q == std::string::npos) return url;
std::string out = url.substr(0, q + 1);
std::stringstream ss(url.substr(q + 1));
std::string kv;
bool first = true;
while (std::getline(ss, kv, '&')) {
if (!first) out += '&';
first = false;
const auto eq = kv.find('=');
if (eq == std::string::npos) { out += kv; continue; }
const std::string v = kv.substr(eq + 1);
out += kv.substr(0, eq + 1);
if (v.size() <= 3) out += v;
else out += v.substr(0, 3) + "***(" + std::to_string(v.size()) + ")";
}
return out;
}
} // 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.
// Required for --action http_probe (Studio envelope or OBN
// obn.auth.json — see load_user_info_blob()).
std::string user_info;
// --action http_probe / mw_probe: cloud probes. No printer.
// Defaults match a recent MITM sample (my/task 1114566547).
std::string task_id = "1114566547";
std::string project_id; // empty OK; stock may still answer
std::string profile_id = "894049654";
int plate_index = 1;
int msg_type = 0;
int msg_after = 0;
int msg_limit = 20;
// mw_probe: MakerWorld rating / For-You. instance_id from my/task.
int instance_id = 390100;
std::string design_id = "478834";
int mw_seed = 0;
int mw_limit = 10;
// put_model_mall_rating is skipped unless --mw-put-rating is set
// (avoids writing junk ratings during MITM discovery).
bool mw_put_rating = false;
int rating_id = 0;
int rating_score = 5;
// --action filament_probe: Filament Manager cloud calls. The slot
// mapping defaults describe one AMS tray; --slot-mappings-json
// overrides the whole array for batch / multi-item captures.
std::string slot_dev_id;
std::string slot_ams_sn;
std::string slot_slot_id = "0";
int slot_spool_id = 0;
std::string slot_rfid;
int slot_ams_id = 0;
int slot_ams_type = 0;
std::string slot_mappings_json;
// Skip the list/config reads and only exercise the sync calls.
bool fil_sync_only = false;
// Also call sync_ams_filaments with the same tray identity, so the two
// serializers can be diffed on the wire. Off by default: it writes
// real mount state onto the cloud catalogue.
bool probe_ams_sync = false;
// AMS soft match (ABI >= 02.08.03). The GET is read-only and always
// runs; each name in --soft-match-actions adds one POST, which is a
// catalogue write, so the list is empty by default. Point the spool ids
// at rows that do not exist to capture the request shape without
// touching the account.
std::string soft_match_dev_id;
std::string soft_match_ams_sn;
std::string soft_match_actions;
int soft_match_spool_id = 0;
int soft_match_target_spool_id = 0;
bool soft_match_only = false;
// --action device_region (ABI >= 02.08.04): one post_device_region
// call. ClientType defaults to Studio's "slicer" when the flag is
// omitted; pass an empty value to send the field blank. user-info is
// optional so a logged-out agent can be captured too.
std::optional<std::string> device_region_device_id;
std::optional<std::string> device_region_client_type;
std::optional<std::string> device_region_country;
std::optional<std::string> device_region_x_client_country;
// --action camera_url: one get_camera_url call with Studio's packed
// "serial|dev_ver|protocols" key (MediaPlayCtrl::Play). The minted URL
// carries TUTK credentials, so events only show a masked copy; the raw
// URL goes to --camera-url-out (mode 0600) for tools/tutk_probe.
std::string camera_dev_ver;
std::string camera_protocols = "\"tutk\",\"agora\"";
std::string camera_url_out;
bool camera_cloud = true;
int camera_settle_s = 5;
// --action ft_job: open one ft_* tunnel on the URL read from
// --ft-url-file (LAN or a TUTK URL from camera_url) and run each
// --ft-job JSON on it in order, the way SendToPrinter runs ability
// then upload. Binary results go to <--ft-out>.<index>.
std::string ft_url_file;
std::vector<std::string> ft_jobs;
std::string ft_out;
int ft_job_timeout_s = 120;
// --action account_bind extras (Studio BindJob defaults).
std::string dev_model = "N7";
std::string timezone = "UTC+02:00";
bool improved = true;
// gap_probe: call user_logout(true) at the end under MITM to capture
// the logout body when refresh_token is present (revokes the session).
bool do_logout = false;
// 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;
// lan_only argument for install_device_cert during LAN bring-up.
// Studio passes is_lan_mode_printer(), so a cloud-paired printer needs
// false: only that branch publishes app_cert_install and gets the
// printer's public key back. With true on a cloud-paired printer the
// plugin has nothing to sign with and every print:* publish fails -4030.
bool cert_lan_only = true;
// X-BBL-Client-Name. Honest by default, but the cloud gates some
// endpoints on the value: POST /my/task answers 403 for anything but
// "BambuStudio", so faithful stock captures need --client-name.
std::string client_name = "OpenBambooNetworking";
// X-BBL-Client-Version (Studio's SLIC3R_VERSION).
std::string client_version = "02.05.03.99";
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] [--cert-lan-only 0|1]
[--client-name NAME] [--client-version VER]
[--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]
plugin_runner --action http_probe --plugin-path PATH
--user-info @session.json [--data-dir DIR]
[--task-id ID] [--project-id ID] [--profile-id ID]
[--plate-index N] [--msg-type N] [--msg-after N]
[--msg-limit N] [--country US]
plugin_runner --action mw_probe --plugin-path PATH
--user-info @session.json [--data-dir DIR]
[--task-id ID] [--instance-id N] [--design-id ID]
[--mw-seed N] [--mw-limit N] [--mw-put-rating]
[--rating-id N] [--rating-score N] [--country US]
plugin_runner --action filament_probe --plugin-path PATH
--user-info @session.json [--data-dir DIR]
[--slot-dev-id ID] [--slot-ams-sn SN] [--slot-id N]
[--slot-spool-id N] [--slot-rfid HEX]
[--slot-ams-id N] [--slot-ams-type N]
[--slot-mappings-json JSON|@path] [--fil-sync-only]
[--soft-match-dev-id ID] [--soft-match-ams-sn SN]
[--soft-match-actions accept,link_other,create_new]
[--soft-match-spool-id N] [--soft-match-target-spool-id N]
[--soft-match-only] [--country US]
plugin_runner --action device_region --plugin-path PATH
[--user-info @session.json] [--data-dir DIR]
[--device-region-device-id ID]
[--device-region-client-type TYPE]
[--device-region-country CC]
[--device-region-x-client-country CC]
[--country US]
plugin_runner --action camera_url --plugin-path PATH
--user-info @session.json --dev-id ID
[--camera-dev-ver VER] [--camera-protocols LIST]
[--camera-url-out PATH] [--camera-cloud 0|1]
[--camera-settle-s N] [--data-dir DIR] [--country US]
plugin_runner --action ft_job --plugin-path PATH
--ft-url-file PATH --ft-job JSON [--ft-job JSON]...
[--ft-out PREFIX] [--ft-job-timeout-s N]
[--data-dir DIR]
plugin_runner --action update_cert --plugin-path PATH
[--user-info @session.json] [--data-dir DIR]
[--country US]
plugin_runner --action query_bind --plugin-path PATH
--user-info @session.json --dev-id ID
[--data-dir DIR] [--country US]
plugin_runner --action auth_probe --plugin-path PATH
--user-info @session.json
[--data-dir DIR] [--country US]
plugin_runner --action bind_detect --plugin-path PATH
--dev-ip IP [--dev-id ID] [--data-dir DIR]
[--country US] [--timeout SECONDS]
plugin_runner --action account_bind --plugin-path PATH
--user-info @session.json
--dev-id ID --dev-ip IP --access-code CODE
[--dev-model MODEL] [--timezone TZ] [--improved 0|1]
[--data-dir DIR] [--country US]
plugin_runner --action gap_probe --plugin-path PATH
--user-info @session.json [--dev-id ID]
[--do-logout] [--data-dir DIR] [--country US]
plugin_runner --action cert_probe --plugin-path PATH
--dev-id ID --dev-ip IP --access-code CODE
[--user-info @session.json] [--data-dir DIR]
[--country US] [--timeout SECONDS]
ACTION is one of: send_gcode_to_sdcard | local_print | sdcard_print
| local_print_with_record | cloud_print | send_raw | none
| http_probe | mw_probe | filament_probe | device_region
| update_cert | query_bind | auth_probe | bind_detect | account_bind
| gap_probe | cert_probe
cloud_print: bambu_network_start_print — the pure-cloud path. Uploads
the 3mf to S3 and dispatches through POST /my/task with
mode=cloud_file, so it needs --user-info; the printer then downloads
from S3 on its own. Still requires --dev-id/--dev-ip/--access-code and
--cert-file: the stock plugin gates the send stage on having the
device certificate and answers -3140 ENC_FLAG_NOT_READY without a live
LAN session. Honours --auto-stop.
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.
http_probe: no printer. change_user(--user-info) then call
get_studio_info_url / get_my_message / check_user_task_report /
get_task_plate_index / get_slice_info and emit JSON events. Run under
HTTPS_PROXY + mitmproxy to capture stock cloud URLs. --user-info may
be a Studio {"data":{"token":…}} envelope or OBN obn.auth.json.
mw_probe: no printer. change_user then call get_subtask /
get_model_mall_detail_url / get_model_mall_rating /
get_mw_user_preference / get_mw_user_4ulist (put_model_mall_rating
only with --mw-put-rating). Use under MITM to capture MakerWorld URLs.
filament_probe: no printer. change_user then call get_filament_spools
and sync_slot_mappings (ABI >= 02.08.02). Studio drives the latter from
the Filament Manager AMS sync: a bind carries spoolId + rfid, an unbind
zeroes both and keeps the pre-eject amsSn/slotId/amsId/amsType. Use
--slot-mappings-json to send a batch or an empty array; --fil-sync-only
skips the catalogue read. Run under MITM to capture the request body.
With ABI >= 02.08.03 it also calls get_soft_match_pending (read-only,
--soft-match-dev-id / --soft-match-ams-sn, falling back to the slot and
device flags). Each name in --soft-match-actions adds one
post_soft_match_pending call — that one writes to the cloud catalogue,
so pass spool ids that do not exist to capture the shape safely.
--soft-match-only skips the catalogue read and the slot-mapping sync.
device_region: no printer. Optional change_user, then one
post_device_region (ABI >= 02.08.04). --device-region is an alias for
this action. ClientType defaults to "slicer" (what Studio sends); the
other three fields default to empty. Pass an empty string to force
ClientType blank. --country still selects the cloud region via
set_country_code. Run under MITM to capture the request.
update_cert: no printer. Mirrors Studio GUI_App::check_cert →
bambu_network_update_cert. Under MITM this should be the shared app
cert fetch (GET …/user/applications/{enc_secret}/cert?aes256=…&ver=1).
--user-info optional (stock still attaches Bearer when logged in).
query_bind: no printer. change_user then bambu_network_query_bind_status
([--dev-id]) and request_bind_ticket. Use under MITM for orphan
query_bind_status URL capture. Do NOT attach strace/gdb — stock
anti-debug aborts with a zenity dialog.
auth_probe: no printer. change_user then bambu_network_get_user_print_info.
Emits is_logged_in, the SDK rc, the HTTP code and the body size — never
the body, device ids or tokens (the body carries the LAN access code).
A 200 only shows the account token can read the device list; it does
not show the printer will accept a signed command. On success the
plugin also subscribes to device/<id>/report unless cloud is blocked.
bind_detect: LAN only. start_discovery + bambu_network_bind_detect,
then exit (no MQTT connect). Pair with tcpdump on udp/2021 and host
<printer> to see SSDP vs any TCP /info.
account_bind: change_user + bind_detect + bambu_network_bind with
OnUpdateStatusFn progress events. Captures device-ticket cloud steps
under MITM; pair with an MQTT logger on device/<id>/request|report.
gap_probe: no printer. change_user then probe build_login_* /
build_login_info, change_user JSON shapes, query_bind_status,
connect_server + start/stop_subscribe("app") + refresh_connection +
enable_multi_machine. Optional --do-logout calls user_logout(true)
(revokes tokens — re-login after). Use under HTTPS_PROXY + mitmproxy;
pair with SSLKEYLOGFILE + tcpdump on *.mqtt.bambulab.com for MQTT wire.
cert_probe: LAN MQTT without the automatic install_device_cert, then
call install_device_cert(lan_only=true) and (lan_only=false). Watch
local_message for security.* / app_cert_install. Pair with tcpdump on
host <printer> port 8883 and optional SSLKEYLOGFILE.
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 == "--cert-lan-only") c.cert_lan_only = std::stoi(require(a, ++i, f)) != 0;
else if (f == "--client-name") c.client_name = require(a, ++i, f);
else if (f == "--client-version") c.client_version = 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 == "--task-id") c.task_id = require(a, ++i, f);
else if (f == "--project-id") c.project_id = require(a, ++i, f);
else if (f == "--profile-id") c.profile_id = require(a, ++i, f);
else if (f == "--plate-index") c.plate_index = std::stoi(require(a, ++i, f));
else if (f == "--msg-type") c.msg_type = std::stoi(require(a, ++i, f));
else if (f == "--msg-after") c.msg_after = std::stoi(require(a, ++i, f));
else if (f == "--msg-limit") c.msg_limit = std::stoi(require(a, ++i, f));
else if (f == "--instance-id") c.instance_id = std::stoi(require(a, ++i, f));
else if (f == "--design-id") c.design_id = require(a, ++i, f);
else if (f == "--mw-seed") c.mw_seed = std::stoi(require(a, ++i, f));
else if (f == "--mw-limit") c.mw_limit = std::stoi(require(a, ++i, f));
else if (f == "--mw-put-rating") c.mw_put_rating = true;
else if (f == "--rating-id") c.rating_id = std::stoi(require(a, ++i, f));
else if (f == "--rating-score") c.rating_score = std::stoi(require(a, ++i, f));
else if (f == "--slot-dev-id") c.slot_dev_id = require(a, ++i, f);
else if (f == "--slot-ams-sn") c.slot_ams_sn = require(a, ++i, f);
else if (f == "--slot-id") c.slot_slot_id = require(a, ++i, f);
else if (f == "--slot-spool-id") c.slot_spool_id = std::stoi(require(a, ++i, f));
else if (f == "--slot-rfid") c.slot_rfid = require(a, ++i, f);
else if (f == "--slot-ams-id") c.slot_ams_id = std::stoi(require(a, ++i, f));
else if (f == "--slot-ams-type") c.slot_ams_type = std::stoi(require(a, ++i, f));
else if (f == "--slot-mappings-json") c.slot_mappings_json = require(a, ++i, f);
else if (f == "--fil-sync-only") c.fil_sync_only = true;
else if (f == "--probe-ams-sync") c.probe_ams_sync = true;
else if (f == "--soft-match-dev-id") c.soft_match_dev_id = require(a, ++i, f);
else if (f == "--soft-match-ams-sn") c.soft_match_ams_sn = require(a, ++i, f);
else if (f == "--soft-match-actions") c.soft_match_actions = require(a, ++i, f);
else if (f == "--soft-match-spool-id")
c.soft_match_spool_id = std::stoi(require(a, ++i, f));
else if (f == "--soft-match-target-spool-id")
c.soft_match_target_spool_id = std::stoi(require(a, ++i, f));
else if (f == "--soft-match-only") c.soft_match_only = true;
else if (f == "--device-region") c.action = "device_region";
else if (f == "--device-region-device-id")
c.device_region_device_id = require(a, ++i, f);
else if (f == "--device-region-client-type")
c.device_region_client_type = require(a, ++i, f);
else if (f == "--device-region-country")
c.device_region_country = require(a, ++i, f);
else if (f == "--device-region-x-client-country")
c.device_region_x_client_country = require(a, ++i, f);
else if (f == "--camera-dev-ver") c.camera_dev_ver = require(a, ++i, f);
else if (f == "--camera-protocols") c.camera_protocols = require(a, ++i, f);
else if (f == "--camera-url-out") c.camera_url_out = require(a, ++i, f);
else if (f == "--camera-cloud") c.camera_cloud = (require(a, ++i, f) != "0");
else if (f == "--ft-url-file") c.ft_url_file = require(a, ++i, f);
else if (f == "--ft-job") c.ft_jobs.push_back(require(a, ++i, f));
else if (f == "--ft-out") c.ft_out = require(a, ++i, f);
else if (f == "--ft-job-timeout-s")
c.ft_job_timeout_s = std::stoi(require(a, ++i, f));
else if (f == "--camera-settle-s")
c.camera_settle_s = std::stoi(require(a, ++i, f));
else if (f == "--auto-stop") c.auto_stop = true;
else if (f == "--dev-model") c.dev_model = require(a, ++i, f);
else if (f == "--timezone") c.timezone = require(a, ++i, f);
else if (f == "--improved") c.improved = (require(a, ++i, f) != "0");
else if (f == "--do-logout") c.do_logout = 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.
const bool cloud_probe =
(c.action == "http_probe" || c.action == "mw_probe" ||
c.action == "update_cert" || c.action == "query_bind" ||
c.action == "auth_probe" ||
c.action == "gap_probe" || c.action == "filament_probe" ||
c.action == "device_region" || c.action == "camera_url" ||
c.action == "ft_job");
const bool bind_detect_only = (c.action == "bind_detect");
const bool account_bind = (c.action == "account_bind");
const bool cert_probe = (c.action == "cert_probe");
bool needs_params = (c.action != "send_raw" && c.action != "none" &&
!cloud_probe && !bind_detect_only && !account_bind &&
!cert_probe);
if (c.plugin_path.empty()) {
std::fprintf(stderr, "plugin_runner: --plugin-path is required\n");
usage(64);
}
if (bind_detect_only) {
if (c.dev_ip.empty()) {
std::fprintf(stderr, "plugin_runner: --action bind_detect requires --dev-ip\n");
usage(64);
}
} else if (account_bind) {
if (c.dev_id.empty() || c.dev_ip.empty() || c.access_code.empty() ||
c.user_info.empty()) {
std::fprintf(stderr, "plugin_runner: --action account_bind requires "
"--dev-id --dev-ip --access-code --user-info\n");
usage(64);
}
} else if (cert_probe) {
if (c.dev_id.empty() || c.dev_ip.empty() || c.access_code.empty()) {
std::fprintf(stderr, "plugin_runner: --action cert_probe requires "
"--dev-id --dev-ip --access-code\n");
usage(64);
}
} else if (!cloud_probe) {
if (c.dev_id.empty() || c.dev_ip.empty() || c.access_code.empty()) {
std::fprintf(stderr, "plugin_runner: --dev-id, --dev-ip and "
"--access-code are all required (except "
"for cloud/bind_detect probes)\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);
}
if ((c.action == "http_probe" || c.action == "mw_probe" ||
c.action == "query_bind" || c.action == "auth_probe" ||
c.action == "gap_probe" ||
c.action == "filament_probe") &&
c.user_info.empty()) {
std::fprintf(stderr, "plugin_runner: --action %s requires "
"--user-info JSON|@path\n", c.action.c_str());
usage(64);
}
if (c.action == "query_bind" && c.dev_id.empty()) {
std::fprintf(stderr, "plugin_runner: --action query_bind requires --dev-id\n");
usage(64);
}
if (c.action == "ft_job" && (c.ft_url_file.empty() || c.ft_jobs.empty())) {
std::fprintf(stderr, "plugin_runner: --action ft_job requires "
"--ft-url-file and at least one --ft-job\n");
usage(64);
}
if (c.action == "camera_url" && (c.dev_id.empty() || c.user_info.empty())) {
std::fprintf(stderr, "plugin_runner: --action camera_url requires "
"--dev-id and --user-info\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 {
// Move-only: a copy would let the source's destructor rm -rf a directory the
// destination still owns.
struct Tmpdir {
std::string path;
bool keep = false;
Tmpdir() = default;
Tmpdir(const Tmpdir&) = delete;
Tmpdir& operator=(const Tmpdir&) = delete;
Tmpdir(Tmpdir&& other) noexcept
: path(std::move(other.path)), keep(other.keep) { other.path.clear(); }
Tmpdir& operator=(Tmpdir&& other) noexcept {
if (this != &other) {
wipe();
path = std::move(other.path);
keep = other.keep;
other.path.clear();
}
return *this;
}
~Tmpdir() { wipe(); }
private:
void wipe() {
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 reset() {
std::lock_guard<std::mutex> lk(m);
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);
g_camera_msg_filter = (args.action == "camera_url");
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);
if (!args.keep_tmpdir) g_ephemeral_data_dir = tmpdir.path;
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_cloud_message(dev_id, 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();
});
ReadyLatch server_ready;
exports.set_on_server_connected_fn(agent,
[&server_ready](int rc, int reason) {
emit_event("server_connected", { {"rc", rc}, {"reason", reason} });
// Stock fires this on both success and auth failure; treat any
// callback as "connect attempt resolved" for gap_probe waits.
server_ready.signal();
});
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.
std::map<std::string, std::string> hdrs;
hdrs["X-BBL-Client-Type"] = "slicer";
hdrs["X-BBL-Client-Name"] = args.client_name;
hdrs["X-BBL-Client-Version"] = args.client_version;
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 as a Studio change_user envelope
// (or converted from OBN obn.auth.json — see below).
auto load_user_info_blob = [](const std::string& raw) -> std::string {
std::string text = raw;
if (!text.empty() && text.front() == '@') {
std::ifstream uf(text.substr(1));
if (!uf) {
throw std::runtime_error("cannot open --user-info file: " +
text.substr(1));
}
std::stringstream uss; uss << uf.rdbuf();
text = uss.str();
}
if (text.empty()) return text;
// OBN on-disk session → Studio HttpServer envelope so stock
// plugins accept change_user the same way Studio's localhost
// login callback does.
try {
auto j = json::parse(text);
if (j.contains("access_token") && !j.contains("data")) {
json env;
env["data"]["token"] = j.value("access_token", "");
env["data"]["refresh_token"] = j.value("refresh_token", "");
env["data"]["expires_in"] = "31536000";
env["data"]["refresh_expires_in"] = "31536000";
json user;
user["uid"] = j.value("user_id", "");
user["name"] = j.value("user_name", "");
user["account"] = j.value("account", "");
user["avatar"] = j.value("avatar", "");
user["nickname"] = j.value("nick_name", "");
env["data"]["user"] = user;
return env.dump();
}
} catch (...) {
// Fall through — stock may accept the raw blob as-is.
}
return text;
};
std::string user_blob;
try {
user_blob = load_user_info_blob(args.user_info);
} catch (const std::exception& e) {
emit_text("fatal", e.what());
return 66;
}
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},
});
const bool http_probe = (args.action == "http_probe");
const bool mw_probe = (args.action == "mw_probe");
const bool update_cert_probe = (args.action == "update_cert");
const bool query_bind_probe = (args.action == "query_bind");
const bool auth_probe = (args.action == "auth_probe");
const bool gap_probe_action = (args.action == "gap_probe");
const bool cert_probe_action = (args.action == "cert_probe");
const bool bind_detect_only = (args.action == "bind_detect");
const bool account_bind_probe = (args.action == "account_bind");
const bool filament_probe = (args.action == "filament_probe");
const bool device_region_probe = (args.action == "device_region");
const bool camera_url_probe = (args.action == "camera_url");
const bool ft_job_action = (args.action == "ft_job");
const bool cloud_probe =
http_probe || mw_probe || update_cert_probe || query_bind_probe ||
auth_probe ||
gap_probe_action || filament_probe || device_region_probe ||
camera_url_probe || ft_job_action;
// account_bind / bind_detect call bind_detect themselves then exit
// (or call bind()); they must not open a competing LAN MQTT session.
const bool skip_lan_mqtt = cloud_probe || bind_detect_only || account_bind_probe;
// Step 6.5–7.5: LAN bring-up. Skipped for cloud probes and for the
// dedicated bind probes (those own their own detect/bind calls).
if (!skip_lan_mqtt) {
// bind_detect — stock probes TCP dev_ip:3000 with framed
// {"login":{"command":"detect",…}} (not HTTP /info). Studio also
// runs this before connect_printer (sLocalBindFunc). The 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. Studio passes
// is_lan_mode_printer() here, mirrored by --cert-lan-only: only the
// lan_only=0 branch publishes app_cert_install and receives the
// printer_cert reply, so a cloud-paired printer needs 0.
// cert_probe owns install_device_cert itself so the LAN bring-up
// path must not auto-provision (would muddy lan_only true/false).
if (!cert_probe_action) {
if (exports.install_device_cert) {
// Cloud-paired printers need both calls in this order:
// lan_only=1 alone never publishes app_cert_install, and
// lan_only=0 alone publishes nothing either. Only the pair
// (as cert_probe issues them) puts app_cert_install on the
// wire and brings printer_cert back, which is what the
// plugin signs privileged publishes with.
if (!args.cert_lan_only) {
exports.install_device_cert(agent, args.dev_id, /*lan_only=*/true);
emit_event("install_device_cert", {
{"dev_id", args.dev_id}, {"lan_only", true}, {"phase", "1"},
});
std::this_thread::sleep_for(std::chrono::seconds(5));
}
exports.install_device_cert(agent, args.dev_id, args.cert_lan_only);
emit_event("install_device_cert", {
{"dev_id", args.dev_id}, {"lan_only", args.cert_lan_only},
});
} 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.");
}
} else {
emit_event("install_device_cert_skipped", {
{"reason", "cert_probe owns explicit lan_only true/false calls"},
});
}
// 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(
cert_probe_action ? 1500 : (args.cert_lan_only ? 3500 : 8000)));
}
} // !skip_lan_mqtt
// 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, http_probe, mw_probe).
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 == "query_bind") {
auto trunc = [](const std::string& s, size_t n = 600) {
if (s.size() <= n) return s;
return s.substr(0, n) + "...";
};
emit_event("query_bind_begin", {
{"dev_id", args.dev_id},
{"have_query_bind_status", exports.query_bind_status != nullptr},
{"have_request_bind_ticket", exports.request_bind_ticket != nullptr},
});
if (!exports.query_bind_status) {
emit_event("query_bind_status", { {"missing", true} });
return 70;
}
std::vector<std::string> qlist{args.dev_id};
unsigned http_code = 0;
std::string http_body;
int rc = exports.query_bind_status(agent, qlist, &http_code, &http_body);
emit_event("query_bind_status", {
{"rc", rc},
{"http_code", http_code},
{"body_bytes", http_body.size()},
{"body", trunc(http_body)},
});
// Brief settle so async HTTPS (if any) lands under MITM.
std::this_thread::sleep_for(std::chrono::seconds(3));
emit_event("query_bind_done", json::object());
bool fast = args.fast_exit.value_or(true);
if (fast) {
emit_event("shutdown", { {"finished", true}, {"fast_exit", true} });
fast_exit(rc == 0 ? 0 : 1);
}
rc_action = rc;
} else if (args.action == "auth_probe") {
// Account-scoped read only. The body carries dev_access_code, so it
// is counted and then dropped.
bool logged = exports.is_user_login ? exports.is_user_login(agent) : false;
if (!exports.get_user_print_info) {
emit_event("get_user_print_info", {
{"missing", true},
{"is_logged_in", logged},
});
return 70;
}
unsigned http_code = 0;
std::string http_body;
int rc = exports.get_user_print_info(agent, &http_code, &http_body);
emit_event("get_user_print_info", {
{"rc", rc},
{"http_code", http_code},
{"body_bytes", http_body.size()},
{"is_logged_in", logged},
});
bool fast = args.fast_exit.value_or(true);
if (fast) {
emit_event("shutdown", { {"finished", true}, {"fast_exit", true} });
fast_exit(rc == 0 ? 0 : 1);
}
rc_action = rc;
} else if (args.action == "bind_detect") {
// Give SSDP a moment after start_discovery before the detect wait.
std::this_thread::sleep_for(std::chrono::milliseconds(500));
auto t0 = std::chrono::steady_clock::now();
BBL::detectResult detect{};
int rc = exports.bind_detect(agent, args.dev_ip, "secure", detect);
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - t0)
.count();
emit_event("bind_detect", {
{"rc", rc},
{"elapsed_ms", ms},
{"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},
});
std::this_thread::sleep_for(std::chrono::seconds(1));
bool fast = args.fast_exit.value_or(true);
if (fast) {
emit_event("shutdown", { {"finished", true}, {"fast_exit", true} });
fast_exit(rc == 0 ? 0 : 1);
}
rc_action = rc;
} else if (args.action == "account_bind") {
if (!exports.bind) {
emit_event("account_bind", { {"missing", true} });
emit_text("fatal", "plugin missing bambu_network_bind");
return 70;
}
// Warm detect so stock has MachineObject-like identity cached.
{
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},
});
}
emit_event("account_bind_begin", {
{"dev_id", args.dev_id},
{"dev_ip", args.dev_ip},
{"dev_model", args.dev_model},
{"timezone", args.timezone},
{"improved", args.improved},
});
auto on_bind_status = [](int stage, int code, std::string info) {
emit_event("bind_status", {
{"stage", stage}, {"code", code}, {"info", info},
});
};
int rc = exports.bind(agent,
args.dev_ip,
args.dev_id,
args.dev_model,
/*sec_link=*/"secure",
args.timezone,
args.improved,
on_bind_status);
emit_event("account_bind", { {"rc", rc} });
std::this_thread::sleep_for(std::chrono::seconds(3));
bool fast = args.fast_exit.value_or(true);
if (fast) {
emit_event("shutdown", { {"finished", true}, {"fast_exit", true} });
fast_exit(rc == 0 ? 0 : 1);
}
rc_action = rc;
} else if (args.action == "gap_probe") {
auto trunc = [](const std::string& s, size_t n = 800) {
if (s.size() <= n) return s;
return s.substr(0, n) + "...";
};
auto emit_login_strings = [&](const char* phase) {
json o{{"phase", phase}};
if (exports.build_login_cmd) {
auto s = exports.build_login_cmd(agent);
o["build_login_cmd"] = trunc(s);
o["build_login_cmd_bytes"] = s.size();
} else {
o["build_login_cmd_missing"] = true;
}
if (exports.build_logout_cmd) {
auto s = exports.build_logout_cmd(agent);
o["build_logout_cmd"] = trunc(s);
o["build_logout_cmd_bytes"] = s.size();
} else {
o["build_logout_cmd_missing"] = true;
}
if (exports.build_login_info) {
auto s = exports.build_login_info(agent);
o["build_login_info"] = trunc(s);
o["build_login_info_bytes"] = s.size();
} else {
o["build_login_info_missing"] = true;
}
if (exports.get_user_id) {
o["get_user_id"] = exports.get_user_id(agent);
}
if (exports.is_user_login) {
o["is_user_login"] = exports.is_user_login(agent);
}
emit_event("login_strings", o);
};
emit_event("gap_probe_begin", {
{"dev_id", args.dev_id},
{"do_logout", args.do_logout},
{"have_connect_server", exports.connect_server != nullptr},
{"have_refresh", exports.refresh_connection != nullptr},
{"have_stop_subscribe", exports.stop_subscribe != nullptr},
{"have_user_logout", exports.user_logout != nullptr},
});
emit_login_strings("after_change_user");
// Catalogue of change_user JSON shapes stock accepts. Restore
// the Studio login_info envelope at the end of this block.
{
json studio_env = json::parse(user_blob);
json data = studio_env.value("data", json::object());
struct Shape {
const char* name;
std::string blob;
};
std::vector<Shape> shapes;
shapes.push_back({"empty_object", "{}"});
shapes.push_back({"empty_string", ""});
shapes.push_back({"token_only",
json{{"data", {{"token", data.value("token", "")}}}}.dump()});
shapes.push_back({"studio_login_info", user_blob});
// WebView path: change_user(j.dump()) where j has command.
json wv = studio_env;
wv["command"] = "user_login";
shapes.push_back({"webview_user_login_wrapper", wv.dump()});
// Flat token at top level (OBN-ish / mistaken).
shapes.push_back({"flat_access_token",
json{{"access_token", data.value("token", "")},
{"refresh_token", data.value("refresh_token", "")},
{"user_id", data.value("user", json::object()).value("uid", "")}}
.dump()});
for (const auto& sh : shapes) {
// Clear first so a no-op / parse-reject cannot leave the
// previous session looking like "accepted".
if (exports.user_logout) {
exports.user_logout(agent, /*request=*/false);
}
bool before =
exports.is_user_login ? exports.is_user_login(agent) : false;
int rc = exports.change_user(agent, sh.blob);
bool logged =
exports.is_user_login ? exports.is_user_login(agent) : false;
emit_event("change_user_shape", {
{"name", sh.name},
{"rc", rc},
{"cleared_before", !before},
{"is_logged_in", logged},
{"bytes", sh.blob.size()},
});
}
// Restore canonical Studio envelope for cloud MQTT / logout.
int rc_restore = exports.change_user(agent, user_blob);
emit_event("change_user_restore", {
{"rc", rc_restore},
{"is_logged_in",
exports.is_user_login ? exports.is_user_login(agent) : false},
});
emit_login_strings("after_restore");
}
if (!args.dev_id.empty() && exports.query_bind_status) {
std::vector<std::string> qlist{args.dev_id};
unsigned http_code = 0;
std::string http_body;
int rc = exports.query_bind_status(agent, qlist, &http_code, &http_body);
emit_event("query_bind_status", {
{"rc", rc},
{"http_code", http_code},
{"body_bytes", http_body.size()},
{"body", trunc(http_body)},
});
}
// Cloud MQTT lifecycle: connect → refresh → subscribe toggles.
if (exports.enable_multi_machine) {
exports.enable_multi_machine(agent, true);
emit_event("enable_multi_machine", {{"enable", true}});
}
if (exports.connect_server) {
server_ready.reset();
int rc = exports.connect_server(agent);
emit_event("connect_server", {{"rc", rc}});
bool got = server_ready.wait_for(std::chrono::seconds(15));
emit_event("server_ready", {
{"got", got},
{"is_server_connected",
exports.is_server_connected
? exports.is_server_connected(agent)
: false},
});
}
if (exports.refresh_connection) {
for (int i = 0; i < 3; ++i) {
int rc = exports.refresh_connection(agent);
emit_event("refresh_connection", {{"iter", i}, {"rc", rc}});
std::this_thread::sleep_for(std::chrono::milliseconds(400));
}
}
if (exports.start_subscribe) {
int rc = exports.start_subscribe(agent, "app");
emit_event("start_subscribe", {{"module", "app"}, {"rc", rc}});
std::this_thread::sleep_for(std::chrono::seconds(3));
}
if (exports.stop_subscribe) {
int rc = exports.stop_subscribe(agent, "app");
emit_event("stop_subscribe", {{"module", "app"}, {"rc", rc}});
std::this_thread::sleep_for(std::chrono::seconds(3));
}
if (exports.start_subscribe) {
int rc = exports.start_subscribe(agent, "app");
emit_event("start_subscribe", {
{"module", "app"}, {"rc", rc}, {"phase", "reenable"},
});
std::this_thread::sleep_for(std::chrono::seconds(2));
}
if (exports.enable_multi_machine) {
exports.enable_multi_machine(agent, false);
emit_event("enable_multi_machine", {{"enable", false}});
std::this_thread::sleep_for(std::chrono::seconds(2));
}
// Extra settle so MITM / SSLKEYLOG see late HTTPS or MQTT.
std::this_thread::sleep_for(std::chrono::seconds(3));
if (args.do_logout && exports.user_logout) {
// Non-empty refresh_token is already in the session from
// change_user — this is the capture that closes §8.5.14 TODO.
int rc = exports.user_logout(agent, /*request=*/true);
emit_event("user_logout", {
{"rc", rc},
{"request", true},
{"is_logged_in",
exports.is_user_login ? exports.is_user_login(agent) : false},
});
emit_login_strings("after_logout_request_true");
std::this_thread::sleep_for(std::chrono::seconds(3));
}
emit_event("gap_probe_done", json::object());
bool fast = args.fast_exit.value_or(true);
if (fast) {
emit_event("shutdown", { {"finished", true}, {"fast_exit", true} });
fast_exit(0);
}
rc_action = 0;
} else if (args.action == "cert_probe") {
if (!exports.install_device_cert) {
emit_event("cert_probe", { {"missing", true} });
emit_text("fatal", "plugin missing bambu_network_install_device_cert");
return 70;
}
emit_event("cert_probe_begin", {
{"dev_id", args.dev_id},
{"logged_in",
exports.is_user_login ? exports.is_user_login(agent) : false},
});
// lan_only=true first (Studio LAN-mode path).
exports.install_device_cert(agent, args.dev_id, /*lan_only=*/true);
emit_event("install_device_cert", {
{"dev_id", args.dev_id}, {"lan_only", true}, {"phase", "1"},
});
std::this_thread::sleep_for(std::chrono::seconds(6));
// lan_only=false — cloud-paired path (may HTTP + MQTT publish).
exports.install_device_cert(agent, args.dev_id, /*lan_only=*/false);
emit_event("install_device_cert", {
{"dev_id", args.dev_id}, {"lan_only", false}, {"phase", "2"},
});
std::this_thread::sleep_for(std::chrono::seconds(8));
emit_event("cert_probe_done", json::object());
bool fast = args.fast_exit.value_or(true);
if (fast) {
emit_event("shutdown", { {"finished", true}, {"fast_exit", true} });
fast_exit(0);
}
rc_action = 0;
} else if (args.action == "update_cert") {
if (!exports.update_cert) {
emit_event("update_cert", { {"missing", true} });
emit_text("fatal", "plugin missing bambu_network_update_cert");
return 70;
}
emit_event("update_cert_begin", json::object());
int rc = exports.update_cert(agent);
// Stock performs the HTTPS cert fetch asynchronously; settle so
// mitmproxy sees the GET …/applications/…/cert before exit.
std::this_thread::sleep_for(std::chrono::seconds(3));
emit_event("update_cert", { {"rc", rc} });
bool fast = args.fast_exit.value_or(true);
if (fast) {
emit_event("shutdown", { {"finished", true}, {"fast_exit", true} });
fast_exit(rc == 0 ? 0 : 1);
}
rc_action = rc;
} else if (args.action == "http_probe") {
auto trunc = [](const std::string& s, size_t n = 400) {
if (s.size() <= n) return s;
return s.substr(0, n) + "...";
};
emit_event("http_probe_begin", {
{"task_id", args.task_id},
{"project_id", args.project_id},
{"profile_id", args.profile_id},
{"plate_index", args.plate_index},
{"msg_type", args.msg_type},
{"msg_after", args.msg_after},
{"msg_limit", args.msg_limit},
{"have_studio_info_url", exports.get_studio_info_url != nullptr},
{"have_my_message", exports.get_my_message != nullptr},
{"have_task_report", exports.check_user_task_report != nullptr},
{"have_plate_index", exports.get_task_plate_index != nullptr},
{"have_slice_info", exports.get_slice_info != nullptr},
});
if (exports.get_studio_info_url) {
std::string url = exports.get_studio_info_url(agent);
emit_event("get_studio_info_url", { {"url", url} });
} else {
emit_event("get_studio_info_url", { {"missing", true} });
}
if (exports.get_my_message) {
unsigned http_code = 0;
std::string http_body;
int rc = exports.get_my_message(agent, args.msg_type, args.msg_after,
args.msg_limit, &http_code, &http_body);
emit_event("get_my_message", {
{"rc", rc}, {"http_code", http_code},
{"body_bytes", http_body.size()},
{"body", trunc(http_body)},
});
} else {
emit_event("get_my_message", { {"missing", true} });
}
if (exports.check_user_task_report) {
int task_id = -1;
bool printable = false;
int rc = exports.check_user_task_report(agent, &task_id, &printable);
emit_event("check_user_task_report", {
{"rc", rc}, {"task_id", task_id}, {"printable", printable},
});
} else {
emit_event("check_user_task_report", { {"missing", true} });
}
if (exports.get_task_plate_index) {
int plate = -999;
int rc = exports.get_task_plate_index(agent, args.task_id, &plate);
emit_event("get_task_plate_index", {
{"rc", rc}, {"task_id", args.task_id}, {"plate_index", plate},
});
} else {
emit_event("get_task_plate_index", { {"missing", true} });
}
if (exports.get_slice_info) {
std::string slice_json;
int rc = exports.get_slice_info(agent, args.project_id, args.profile_id,
args.plate_index, &slice_json);
emit_event("get_slice_info", {
{"rc", rc},
{"project_id", args.project_id},
{"profile_id", args.profile_id},
{"plate_index", args.plate_index},
{"body_bytes", slice_json.size()},
{"body", trunc(slice_json)},
});
} else {
emit_event("get_slice_info", { {"missing", true} });
}
// Brief settle so any async HTTP callbacks finish under MITM.
std::this_thread::sleep_for(std::chrono::seconds(2));
emit_event("http_probe_done", json::object());
bool fast = args.fast_exit.value_or(true);
if (fast) {
emit_event("shutdown", { {"finished", true}, {"fast_exit", true} });
fast_exit(0);
}
guard.a = nullptr;
if (exports.disconnect_printer) 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 == "mw_probe") {
auto trunc = [](const std::string& s, size_t n = 600) {
if (s.size() <= n) return s;
return s.substr(0, n) + "...";
};
emit_event("mw_probe_begin", {
{"task_id", args.task_id},
{"instance_id", args.instance_id},
{"design_id", args.design_id},
{"mw_seed", args.mw_seed},
{"mw_limit", args.mw_limit},
{"have_get_subtask", exports.get_subtask != nullptr},
{"have_detail_url", exports.get_model_mall_detail_url != nullptr},
{"have_get_rating", exports.get_model_mall_rating != nullptr},
{"have_put_rating", exports.put_model_mall_rating != nullptr},
{"have_preference", exports.get_mw_user_preference != nullptr},
{"have_4ulist", exports.get_mw_user_4ulist != nullptr},
});
if (exports.get_subtask) {
pr::ProbeBBLModelTask task;
task.task_id = args.task_id;
bool cb_fired = false;
int rc = exports.get_subtask(
agent, &task,
[&](pr::ProbeBBLModelTask* t) {
cb_fired = true;
if (!t) return;
emit_event("get_subtask_cb", {
{"job_id", t->job_id},
{"design_id", t->design_id},
{"profile_id", t->profile_id},
{"instance_id", t->instance_id},
{"task_id", t->task_id},
{"model_id", t->model_id},
{"model_name", t->model_name},
{"profile_name", t->profile_name},
});
});
// Async callbacks may fire after return; settle briefly.
std::this_thread::sleep_for(std::chrono::seconds(3));
emit_event("get_subtask", {
{"rc", rc}, {"cb_fired", cb_fired},
{"job_id", task.job_id},
{"design_id", task.design_id},
{"instance_id", task.instance_id},
{"model_id", task.model_id},
{"model_name", task.model_name},
{"profile_name", task.profile_name},
});
if (task.instance_id > 0)
args.instance_id = task.instance_id;
if (task.design_id > 0)
args.design_id = std::to_string(task.design_id);
} else {
emit_event("get_subtask", { {"missing", true} });
}
if (exports.get_model_mall_detail_url) {
std::string url;
int rc = exports.get_model_mall_detail_url(agent, &url, args.design_id);
emit_event("get_model_mall_detail_url", {
{"rc", rc}, {"design_id", args.design_id}, {"url", url},
});
} else {
emit_event("get_model_mall_detail_url", { {"missing", true} });
}
if (exports.get_model_mall_rating) {
std::string rating_result;
unsigned http_code = 0;
std::string http_error;
int rc = exports.get_model_mall_rating(
agent, args.instance_id, rating_result, http_code, http_error);
emit_event("get_model_mall_rating", {
{"rc", rc},
{"instance_id", args.instance_id},
{"http_code", http_code},
{"http_error", http_error},
{"body_bytes", rating_result.size()},
{"body", trunc(rating_result)},
});
// Prefer rating id from body for optional put.
try {
auto j = json::parse(rating_result);
if (j.contains("id") && j["id"].is_number() && args.rating_id == 0)
args.rating_id = j["id"].get<int>();
} catch (...) {}
} else {
emit_event("get_model_mall_rating", { {"missing", true} });
}
if (exports.get_mw_user_preference) {
std::string body;
int rc = exports.get_mw_user_preference(
agent, [&](std::string b) { body = std::move(b); });
std::this_thread::sleep_for(std::chrono::seconds(2));
emit_event("get_mw_user_preference", {
{"rc", rc}, {"body_bytes", body.size()}, {"body", trunc(body)},
});
} else {
emit_event("get_mw_user_preference", { {"missing", true} });
}
if (exports.get_mw_user_4ulist) {
std::string body;
int rc = exports.get_mw_user_4ulist(
agent, args.mw_seed, args.mw_limit,
[&](std::string b) { body = std::move(b); });
std::this_thread::sleep_for(std::chrono::seconds(2));
emit_event("get_mw_user_4ulist", {
{"rc", rc}, {"seed", args.mw_seed}, {"limit", args.mw_limit},
{"body_bytes", body.size()}, {"body", trunc(body)},
});
} else {
emit_event("get_mw_user_4ulist", { {"missing", true} });
}
if (args.mw_put_rating && exports.put_model_mall_rating) {
unsigned http_code = 0;
std::string http_error;
std::vector<std::string> images;
int rc = exports.put_model_mall_rating(
agent, args.rating_id, args.rating_score,
"obn mw_probe", images, http_code, http_error);
emit_event("put_model_mall_rating", {
{"rc", rc}, {"rating_id", args.rating_id},
{"score", args.rating_score},
{"http_code", http_code}, {"http_error", http_error},
});
} else {
emit_event("put_model_mall_rating", {
{"skipped", !args.mw_put_rating},
{"missing", exports.put_model_mall_rating == nullptr},
});
}
std::this_thread::sleep_for(std::chrono::seconds(2));
emit_event("mw_probe_done", json::object());
bool fast = args.fast_exit.value_or(true);
if (fast) {
emit_event("shutdown", { {"finished", true}, {"fast_exit", true} });
fast_exit(0);
}
guard.a = nullptr;
if (exports.disconnect_printer) 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 == "device_region") {
#if ABI_VERSION < 0x020804
emit_event("post_device_region", { {"unsupported_abi", true} });
emit_text("fatal",
"post_device_region requires a runner built for ABI >= 02.08.04");
return 70;
#else
if (!exports.post_device_region) {
emit_event("post_device_region", { {"missing", true} });
emit_text("fatal", "plugin missing bambu_network_post_device_region");
return 70;
}
BBL::DeviceRegionParams p;
p.DeviceId = args.device_region_device_id.value_or("");
p.ClientType = args.device_region_client_type.value_or("slicer");
p.country = args.device_region_country.value_or("");
p.XClientCountry = args.device_region_x_client_country.value_or("");
emit_event("post_device_region_request", {
{"DeviceId", p.DeviceId},
{"ClientType", p.ClientType},
{"country", p.country},
{"XClientCountry", p.XClientCountry},
});
std::string body;
int rc = exports.post_device_region(agent, p, &body);
emit_event("post_device_region", {
{"rc", rc},
{"body", body},
});
bool fast = args.fast_exit.value_or(true);
if (fast) {
emit_event("shutdown", { {"finished", true}, {"fast_exit", true} });
fast_exit(rc == 0 ? 0 : 1);
}
guard.a = nullptr;
if (exports.disconnect_printer) exports.disconnect_printer(agent);
exports.destroy_agent(agent);
pr::unload(exports);
emit_event("shutdown", { {"finished", true}, {"fast_exit", false} });
return rc == 0 ? 0 : 1;
#endif
} else if (args.action == "camera_url") {
if (!exports.get_camera_url) {
emit_event("get_camera_url", { {"missing", true} });
emit_text("fatal", "plugin missing bambu_network_get_camera_url");
return 70;
}
// Studio only asks for a remote URL once the printer is online over
// the cloud: connect_server, then add_subscribe for the device.
if (args.camera_cloud) {
if (exports.enable_multi_machine) exports.enable_multi_machine(agent, true);
if (exports.connect_server) {
server_ready.reset();
int rc = exports.connect_server(agent);
bool got = server_ready.wait_for(std::chrono::seconds(15));
emit_event("connect_server", { {"rc", rc}, {"server_ready", got} });
}
if (exports.add_subscribe) {
int rc = exports.add_subscribe(agent, {args.dev_id});
emit_event("add_subscribe", { {"dev_id", args.dev_id}, {"rc", rc} });
}
// Studio's bring-up once the device is subscribed. Stock only
// publishes app_cert_install after it has seen a full status.
if (exports.send_message) {
std::this_thread::sleep_for(std::chrono::seconds(1));
const char* kickstart[] = {
R"({"pushing":{"sequence_id":"20000","command":"pushall","version":1,"push_target":1}})",
R"({"info":{"sequence_id":"20001","command":"get_version"}})",
R"({"system":{"sequence_id":"20002","command":"get_access_code"}})",
};
for (const char* m : kickstart) {
int rc = exports.send_message(agent, args.dev_id, m, /*qos=*/0, /*flag=*/0);
emit_event("send_message", { {"json", m}, {"rc", rc} });
}
}
}
// Studio calls refresh_connection + install_device_cert (lan_only =
// is_lan_mode_printer) once a second for the selected machine. Stock
// get_camera_url answers "wait_info" (no callback) until
// app_cert_install has succeeded this session, so mirror that loop.
std::atomic<bool> keepalive_stop{false};
std::thread keepalive;
if (args.camera_cloud) {
keepalive = std::thread([&] {
while (!keepalive_stop) {
if (exports.refresh_connection) exports.refresh_connection(agent);
if (exports.install_device_cert)
exports.install_device_cert(agent, args.dev_id, /*lan_only=*/false);
std::this_thread::sleep_for(std::chrono::seconds(1));
}
});
std::this_thread::sleep_for(std::chrono::seconds(3));
}
const std::string key =
args.dev_id + "|" + args.camera_dev_ver + "|" + args.camera_protocols;
// Shared so a callback arriving after the timeout cannot touch a
// dead stack frame.
struct CamState {
std::mutex m;
std::condition_variable cv;
bool done = false;
bool returned = false;
bool before_return = false;
long long cb_ms = -1;
std::string url;
};
int rc = -1;
long long ret_ms = -1;
bool got = false;
std::string url;
long long cb_ms = -1;
bool before_return = false;
int attempt = 0;
for (attempt = 1; attempt <= 4 && !got; ++attempt) {
const int wait_info_before = g_wait_info_count.load();
emit_event("get_camera_url_call", {
{"attempt", attempt},
{"dev_id", args.dev_id},
{"dev_ver", args.camera_dev_ver},
{"protocols", args.camera_protocols},
});
auto st = std::make_shared<CamState>();
const auto t0 = std::chrono::steady_clock::now();
auto ms_since = [t0] {
return std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - t0).count();
};
rc = exports.get_camera_url(agent, key, [st, ms_since](std::string u) {
std::lock_guard<std::mutex> lk(st->m);
st->url = std::move(u);
st->cb_ms = ms_since();
st->before_return = !st->returned;
st->done = true;
st->cv.notify_all();
});
ret_ms = ms_since();
{
std::lock_guard<std::mutex> lk(st->m);
st->returned = true;
}
{
std::unique_lock<std::mutex> lk(st->m);
got = st->cv.wait_for(lk, std::chrono::seconds(15), [&] { return st->done; });
url = st->url;
cb_ms = st->cb_ms;
before_return = st->before_return;
}
if (!got) {
emit_event("get_camera_url_no_callback", {
{"attempt", attempt},
{"rc", rc},
{"wait_info", g_wait_info_count.load() > wait_info_before},
});
}
}
std::vector<std::string> query_keys;
if (auto q = url.find('?'); q != std::string::npos) {
std::stringstream ss(url.substr(q + 1));
std::string kv;
while (std::getline(ss, kv, '&')) query_keys.push_back(kv.substr(0, kv.find('=')));
}
emit_event("get_camera_url", {
{"attempts", attempt - 1},
{"rc", rc},
{"return_ms", ret_ms},
{"callback", got},
{"callback_ms", cb_ms},
{"callback_before_return", before_return},
{"url_bytes", url.size()},
{"url_masked", mask_camera_url(url)},
{"query_keys", query_keys},
});
if (!args.camera_url_out.empty() && !url.empty()) {
const mode_t old = ::umask(077);
std::ofstream f(args.camera_url_out, std::ios::trunc);
::umask(old);
f << url << '\n';
emit_event("camera_url_out", {
{"path", args.camera_url_out}, {"ok", static_cast<bool>(f)},
});
}
// Keep the agent alive so any follow-up publish the plugin makes on
// its own (and the printer's reply) still lands in the log.
std::this_thread::sleep_for(std::chrono::seconds(std::max(args.camera_settle_s, 0)));
keepalive_stop = true;
if (keepalive.joinable()) keepalive.join();
const bool ok = rc == 0 && got && !url.empty();
bool fast = args.fast_exit.value_or(true);
if (fast) {
emit_event("shutdown", { {"finished", true}, {"fast_exit", true} });
fast_exit(ok ? 0 : 1);
}
guard.a = nullptr;
if (exports.disconnect_printer) exports.disconnect_printer(agent);
exports.destroy_agent(agent);
pr::unload(exports);
emit_event("shutdown", { {"finished", true}, {"fast_exit", false} });
return ok ? 0 : 1;
} else if (args.action == "ft_job") {
struct FtResult { int ec; int resp_ec; const char* json; const void* bin; uint32_t bin_size; };
struct FtMsg { int kind; const char* json; };
using StatusCb = void (*)(void*, int, int, int, const char*);
using ResultCb = void (*)(void*, FtResult);
using MsgCb = void (*)(void*, FtMsg);
auto sym = [&](const char* n) { return ::dlsym(exports.dl_handle, n); };
auto tunnel_create = reinterpret_cast<int (*)(const char*, void**)>(sym("ft_tunnel_create"));
auto set_status = reinterpret_cast<int (*)(void*, StatusCb, void*)>(sym("ft_tunnel_set_status_cb"));
auto sync_connect = reinterpret_cast<int (*)(void*)>(sym("ft_tunnel_sync_connect"));
auto job_create = reinterpret_cast<int (*)(const char*, void**)>(sym("ft_job_create"));
auto set_result = reinterpret_cast<int (*)(void*, ResultCb, void*)>(sym("ft_job_set_result_cb"));
auto set_msg = reinterpret_cast<int (*)(void*, MsgCb, void*)>(sym("ft_job_set_msg_cb"));
auto start_job = reinterpret_cast<int (*)(void*, void*)>(sym("ft_tunnel_start_job"));
auto job_release = reinterpret_cast<void (*)(void*)>(sym("ft_job_release"));
auto shutdown = reinterpret_cast<int (*)(void*)>(sym("ft_tunnel_shutdown"));
auto tunnel_release = reinterpret_cast<void (*)(void*)>(sym("ft_tunnel_release"));
if (!tunnel_create || !sync_connect || !job_create || !set_result || !start_job) {
emit_text("fatal", "plugin missing ft_* exports");
return 70;
}
std::string url;
{
std::ifstream f(args.ft_url_file);
std::getline(f, url);
}
if (url.empty()) {
emit_text("fatal", "--ft-url-file holds no URL");
return 66;
}
void* tunnel = nullptr;
const int rc_create = tunnel_create(url.c_str(), &tunnel);
emit_event("ft_tunnel_create", {
{"rc", rc_create}, {"url_masked", mask_camera_url(url)},
});
if (rc_create != 0 || !tunnel) return 1;
if (set_status) {
set_status(tunnel, [](void*, int old_s, int new_s, int err, const char* msg) {
emit_event("ft_tunnel_status", {
{"old", old_s}, {"new", new_s}, {"err", err},
{"msg", msg ? msg : ""},
});
}, nullptr);
}
const auto t_conn = std::chrono::steady_clock::now();
const int rc_conn = sync_connect(tunnel);
emit_event("ft_tunnel_sync_connect", {
{"rc", rc_conn},
{"ms", std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - t_conn).count()},
});
struct JobState {
std::mutex mu;
std::condition_variable cv;
bool done = false;
std::string out_path;
};
int failures = rc_conn == 0 ? 0 : 1;
for (std::size_t i = 0; rc_conn == 0 && i < args.ft_jobs.size(); ++i) {
JobState st;
if (!args.ft_out.empty()) st.out_path = args.ft_out + "." + std::to_string(i);
void* job = nullptr;
const int rc_job = job_create(args.ft_jobs[i].c_str(), &job);
emit_event("ft_job_create", { {"index", i}, {"rc", rc_job}, {"params", args.ft_jobs[i]} });
if (rc_job != 0 || !job) { ++failures; continue; }
set_result(job, [](void* user, FtResult r) {
auto* s = static_cast<JobState*>(user);
emit_event("ft_job_result", {
{"ec", r.ec}, {"resp_ec", r.resp_ec},
{"json", r.json ? r.json : ""}, {"bin_size", r.bin_size},
});
if (!s->out_path.empty() && r.bin && r.bin_size) {
std::ofstream f(s->out_path, std::ios::binary | std::ios::trunc);
f.write(static_cast<const char*>(r.bin), r.bin_size);
}
std::lock_guard<std::mutex> lk(s->mu);
s->done = true;
s->cv.notify_all();
}, &st);
if (set_msg) {
set_msg(job, [](void*, FtMsg m) {
emit_event("ft_job_msg", { {"kind", m.kind}, {"json", m.json ? m.json : ""} });
}, nullptr);
}
const auto t_job = std::chrono::steady_clock::now();
const int rc_start = start_job(tunnel, job);
emit_event("ft_tunnel_start_job", { {"index", i}, {"rc", rc_start} });
bool done = false;
{
std::unique_lock<std::mutex> lk(st.mu);
done = st.cv.wait_for(lk, std::chrono::seconds(args.ft_job_timeout_s),
[&] { return st.done; });
}
emit_event("ft_job_done", {
{"index", i}, {"finished", done},
{"ms", std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - t_job).count()},
});
if (!done) ++failures;
if (job_release) job_release(job);
}
if (shutdown) shutdown(tunnel);
if (tunnel_release) tunnel_release(tunnel);
emit_event("shutdown", { {"finished", true}, {"fast_exit", true} });
fast_exit(failures == 0 ? 0 : 1);
} else if (args.action == "filament_probe") {
auto trunc = [](const std::string& s, size_t n = 2000) {
if (s.size() <= n) return s;
return s.substr(0, n) + "...";
};
emit_event("filament_probe_begin", {
{"have_get_spools", exports.get_filament_spools != nullptr},
{"have_get_config", exports.get_filament_config != nullptr},
#if ABI_VERSION >= 0x020801
{"have_sync_ams", exports.sync_ams_filaments != nullptr},
#else
{"have_sync_ams", false},
#endif
#if ABI_VERSION >= 0x020802
{"have_sync_slot_mappings", exports.sync_slot_mappings != nullptr},
#else
{"have_sync_slot_mappings", false},
#endif
#if ABI_VERSION >= 0x020803
{"have_get_soft_match_pending", exports.get_soft_match_pending != nullptr},
{"have_post_soft_match_pending", exports.post_soft_match_pending != nullptr},
#else
{"have_get_soft_match_pending", false},
{"have_post_soft_match_pending", false},
#endif
});
const bool skip_catalogue_reads = args.fil_sync_only || args.soft_match_only;
if (!skip_catalogue_reads && exports.get_filament_spools) {
BBL::FilamentQueryParams q{};
q.offset = 0;
q.limit = 20;
std::string body;
int rc = exports.get_filament_spools(agent, q, &body);
emit_event("get_filament_spools", {
{"rc", rc}, {"body_bytes", body.size()}, {"body", trunc(body)},
});
} else if (!skip_catalogue_reads) {
emit_event("get_filament_spools", { {"missing", true} });
}
#if ABI_VERSION >= 0x020801
// Path A/B counterpart: same devId, same tray identity, so the two
// serializers can be diffed on the wire (notably how each renders
// empty strings and zero ints).
if (args.probe_ams_sync && exports.sync_ams_filaments) {
BBL::AmsSyncParams p{};
p.devId = args.slot_dev_id.empty() ? args.dev_id : args.slot_dev_id;
BBL::AmsSyncItem it{};
it.RFID = args.slot_rfid;
it.amsSn = args.slot_ams_sn;
it.slotId = args.slot_slot_id;
it.amsId = args.slot_ams_id;
it.amsType = args.slot_ams_type;
p.items.push_back(std::move(it));
std::string body;
int rc = exports.sync_ams_filaments(agent, p, &body);
emit_event("sync_ams_filaments", {
{"rc", rc}, {"body_bytes", body.size()}, {"body", trunc(body)},
});
}
#endif
#if ABI_VERSION >= 0x020802
if (args.soft_match_only) {
emit_event("sync_slot_mappings", { {"skipped", true} });
} else if (exports.sync_slot_mappings) {
BBL::SlotMappingsSyncParams p{};
p.devId = args.slot_dev_id.empty() ? args.dev_id : args.slot_dev_id;
// --slot-mappings-json wins; it is the only way to send a batch
// or an intentionally empty array (the negative capture).
bool parsed_json = false;
if (!args.slot_mappings_json.empty()) {
try {
std::string raw = args.slot_mappings_json;
if (!raw.empty() && raw.front() == '@') {
std::ifstream mf(raw.substr(1));
if (!mf) {
throw std::runtime_error(
"cannot open --slot-mappings-json file: " +
raw.substr(1));
}
std::stringstream mss; mss << mf.rdbuf();
raw = mss.str();
}
auto j = json::parse(raw);
if (j.is_object() && j.contains("devId"))
p.devId = j.value("devId", p.devId);
const json& arr = j.is_array() ? j
: (j.contains("mappings") ? j["mappings"] : json::array());
for (const auto& e : arr) {
BBL::SlotMappingItem it{};
it.amsSn = e.value("amsSn", "");
it.slotId = e.value("slotId", "");
it.spoolId = e.value("spoolId", 0);
it.rfid = e.value("rfid", "");
it.amsId = e.value("amsId", 0);
it.amsType = e.value("amsType", 0);
p.mappings.push_back(std::move(it));
}
parsed_json = true;
} catch (const std::exception& e) {
emit_text("fatal", std::string("--slot-mappings-json parse failed: ")
+ e.what());
return 64;
}
}
if (!parsed_json) {
BBL::SlotMappingItem it{};
it.amsSn = args.slot_ams_sn;
it.slotId = args.slot_slot_id;
it.spoolId = args.slot_spool_id;
it.rfid = args.slot_rfid;
it.amsId = args.slot_ams_id;
it.amsType = args.slot_ams_type;
p.mappings.push_back(std::move(it));
}
json sent = json::array();
for (const auto& it : p.mappings) {
sent.push_back({
{"amsSn", it.amsSn}, {"slotId", it.slotId},
{"spoolId", it.spoolId}, {"rfid", it.rfid},
{"amsId", it.amsId}, {"amsType", it.amsType},
});
}
emit_event("sync_slot_mappings_request", {
{"devId", p.devId}, {"mappings", sent},
});
std::string body;
int rc = exports.sync_slot_mappings(agent, p, &body);
emit_event("sync_slot_mappings", {
{"rc", rc}, {"body_bytes", body.size()}, {"body", trunc(body)},
});
} else {
emit_event("sync_slot_mappings", { {"missing", true} });
}
#else
emit_event("sync_slot_mappings", { {"unsupported_abi", true} });
#endif
#if ABI_VERSION >= 0x020803
if (exports.get_soft_match_pending) {
BBL::SoftMatchPendingParams p{};
p.devId = !args.soft_match_dev_id.empty() ? args.soft_match_dev_id
: (!args.slot_dev_id.empty() ? args.slot_dev_id : args.dev_id);
p.amsSn = !args.soft_match_ams_sn.empty() ? args.soft_match_ams_sn
: args.slot_ams_sn;
emit_event("get_soft_match_pending_request", {
{"devId", p.devId}, {"amsSn", p.amsSn},
});
std::string body;
int rc = exports.get_soft_match_pending(agent, p, &body);
emit_event("get_soft_match_pending", {
{"rc", rc}, {"body_bytes", body.size()}, {"body", trunc(body)},
});
} else {
emit_event("get_soft_match_pending", { {"missing", true} });
}
if (exports.post_soft_match_pending && !args.soft_match_actions.empty()) {
std::stringstream as(args.soft_match_actions);
std::string action;
while (std::getline(as, action, ',')) {
if (action.empty()) continue;
BBL::SoftMatchPendingActionParams p{};
p.action = action;
p.spoolId = args.soft_match_spool_id;
p.targetSpoolId = args.soft_match_target_spool_id;
emit_event("post_soft_match_pending_request", {
{"action", p.action}, {"spoolId", p.spoolId},
{"targetSpoolId", p.targetSpoolId},
});
std::string body;
int rc = exports.post_soft_match_pending(agent, p, &body);
emit_event("post_soft_match_pending", {
{"action", p.action}, {"rc", rc},
{"body_bytes", body.size()}, {"body", trunc(body)},
});
}
} else {
emit_event("post_soft_match_pending", {
{"skipped", args.soft_match_actions.empty()},
{"missing", exports.post_soft_match_pending == nullptr},
});
}
#else
emit_event("get_soft_match_pending", { {"unsupported_abi", true} });
#endif
std::this_thread::sleep_for(std::chrono::seconds(2));
emit_event("filament_probe_done", json::object());
bool fast = args.fast_exit.value_or(true);
if (fast) {
emit_event("shutdown", { {"finished", true}, {"fast_exit", true} });
fast_exit(0);
}
guard.a = nullptr;
if (exports.disconnect_printer) 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 == "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} });
fast_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 if (args.action == "cloud_print") {
if (!exports.start_print) {
emit_text("fatal", "plugin does not export bambu_network_start_print");
return 70;
}
rc_action = exports.start_print(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" ||
args.action == "cloud_print")) {
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} });
fast_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;
}