// 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 #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #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(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 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 g_camera_msg_filter{false}; std::atomic 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 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 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 device_region_device_id; std::optional device_region_client_type; std::optional device_region_country; std::optional 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>.. std::string ft_url_file; std::vector 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"; 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 /certs/.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 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] [--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 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 | 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. bind_detect: LAN only. start_discovery + bambu_network_bind_detect, then exit (no MQTT connect). Pair with tcpdump on udp/2021 and host 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//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 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& 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 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 == "--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 == "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 == "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 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 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 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 lk(m); ready = false; } void signal() { { std::lock_guard lk(m); ready = true; } cv.notify_all(); } bool wait_for(std::chrono::milliseconds dur) { std::unique_lock 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(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 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 fn) { if (fn) fn(); }); } exports.set_server_callback(agent, [](std::string url, int status) { emit_event("server_callback", { {"url", url}, {"status", status} }); }); exports.set_on_ssdp_msg_fn(agent, [](std::string info_json) { emit_event("ssdp_msg", { {"info", info_json} }); }); // Step 5: replicate Studio's init order (GUI_App.cpp:3596-3618). // Order matters — set_extra_http_header lands BEFORE start() so the // X-BBL-* headers are already in the plugin's HTTP client by the time // it does its first connect / cert exchange against the printer. exports.set_config_dir(agent, tmpdir.path); exports.init_log(agent); exports.set_cert_file(agent, cert.folder, cert.filename); { // Studio's init_http_extra_header() — these headers propagate to // every outgoing HTTP request including the LAN /info ping. // X-BBL-Client-Version uses Studio's full SLIC3R_VERSION; we // pretend to be a current build so the API doesn't reject the // request as legacy. std::map hdrs; hdrs["X-BBL-Client-Type"] = "slicer"; hdrs["X-BBL-Client-Name"] = args.client_name; hdrs["X-BBL-Client-Version"] = "02.05.03.99"; hdrs["X-BBL-OS-Type"] = "linux"; hdrs["X-BBL-OS-Version"] = "1.0.0"; hdrs["X-BBL-Language"] = "en"; exports.set_extra_http_header(agent, hdrs); emit_event("extra_http_header", { {"count", hdrs.size()} }); } exports.set_country_code(agent, args.country); int rc_start = exports.start(agent); emit_event("agent_start", { {"rc", rc_start} }); // Step 5.5: post-start init Studio always runs (GUI_App.cpp:3586-3594 // for multi_machine, then start_discovery elsewhere). Both are // best-effort — failures here are not fatal but missing wires can // leave the plugin in a half-initialised state. exports.enable_multi_machine(agent, false); bool disc_ok = exports.start_discovery(agent, true, false); emit_event("post_start_init", { {"multi_machine", false}, {"start_discovery", disc_ok}, }); // Step 6: prime the plugin's user session. Empty string mirrors // Studio's first-frame "no logged-in user" state. A non-empty // --user-info value is forwarded 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 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 || 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 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 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 == "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::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 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 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(); } 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 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 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(); const auto t0 = std::chrono::steady_clock::now(); auto ms_since = [t0] { return std::chrono::duration_cast( std::chrono::steady_clock::now() - t0).count(); }; rc = exports.get_camera_url(agent, key, [st, ms_since](std::string u) { std::lock_guard 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 lk(st->m); st->returned = true; } { std::unique_lock 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 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(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(sym("ft_tunnel_create")); auto set_status = reinterpret_cast(sym("ft_tunnel_set_status_cb")); auto sync_connect = reinterpret_cast(sym("ft_tunnel_sync_connect")); auto job_create = reinterpret_cast(sym("ft_job_create")); auto set_result = reinterpret_cast(sym("ft_job_set_result_cb")); auto set_msg = reinterpret_cast(sym("ft_job_set_msg_cb")); auto start_job = reinterpret_cast(sym("ft_tunnel_start_job")); auto job_release = reinterpret_cast(sym("ft_job_release")); auto shutdown = reinterpret_cast(sym("ft_tunnel_shutdown")); auto tunnel_release = reinterpret_cast(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::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(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(r.bin), r.bin_size); } std::lock_guard 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 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::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; }