# Bambu Studio Network Plugin — full reference This document describes how Bambu Studio integrates with its proprietary **Network Plugin** (`bambu_networking`) — where it is downloaded from, where it is installed, how it is validated, and the exact C ABI contract it must implement. The goal is to document how the plugin is integrated, loaded, validated and invoked, based purely on the Bambu Studio source code. The reference is derived from three independent sources, none of them involving binary disassembly: 1. A read-through of the upstream [bambulab/BambuStudio](https://github.com/bambulab/BambuStudio) (and the closely related [SoftFever/OrcaSlicer](https://github.com/SoftFever/OrcaSlicer)) trees — every Studio-side claim in this document is backed by a concrete file and line range in those sources. 2. **MITM captures** of the stock `libbambu_networking.so` against `api.bambulab.com`, MakerWorld and the printer's LAN MQTT / FTPS / RTSPS endpoints, used to reverse the wire format the closed-source `bambu_networking` binary actually produces (HTTPS bodies, MQTT JSON envelopes, FTPS dialect quirks, etc.). 3. **Cross-ABI matrix runs** against the stock plugin in versions `02.05.00` … `02.06.01` to track how a given `BBL::PrintParams` value is rendered onto the LAN MQTT `print:project_file` payload, and how that mapping shifts as fields are added to `PrintParams` over time. Where a claim originates from MITM or matrix runs rather than Studio source it is marked accordingly (see "Evidence" tags in §6.10.2 and the per-field tables in §6.8.2). Behaviour that has not been confirmed against either source is flagged as such. **Source path convention:** every `src/slic3r/…` path below is relative to the upstream [bambulab/BambuStudio](https://github.com/bambulab/BambuStudio) tree. These files are **not** vendored in open-bamboo-networking (`3rd_party/` is gitignored local tooling). [SoftFever/OrcaSlicer](https://github.com/SoftFever/OrcaSlicer) shares almost all of the same paths; camera-widget and Windows back-end divergences are called out inline (§7.4). --- ## 1. Architecture overview Bambu Studio is a wxWidgets/C++ application. All networking code (Bambu Lab cloud, MQTT/SSDP to printers, print/upload jobs, authentication, OSS, tracking, and so on) lives in a separate **dynamically-loaded library** (`.dll` / `.so` / `.dylib`). Studio talks to it through a single C ABI whose symbols all start with `bambu_network_…`. Key players: | Role | Source | |------|--------| | C ABI declarations (`dlsym` typedefs) | `src/slic3r/Utils/NetworkAgent.hpp` | | Symbol resolver and method wrappers | `src/slic3r/Utils/NetworkAgent.cpp` | | Shared protocol structures / constants | `src/slic3r/Utils/bambu_networking.hpp` | | `ft_*` File Transfer ABI | `src/slic3r/Utils/FileTransferUtils.{hpp,cpp}` | | Module signature verification | `src/slic3r/Utils/CertificateVerify.{hpp,cpp}` | | Lifecycle (URL, download, install, version) | `src/slic3r/GUI/GUI_App.cpp` | | OTA synchronization | `src/slic3r/Utils/PresetUpdater.cpp` | | UI job "download & install" | `src/slic3r/GUI/Jobs/UpgradeNetworkJob.{hpp,cpp}` | | **`libBambuSource` C ABI** (`Bambu_*`) | `src/slic3r/GUI/Printer/BambuTunnel.h` | | **`libBambuSource` loader / shim** | `src/slic3r/GUI/Printer/PrinterFileSystem.cpp` (`StaticBambuLib`) | | **GStreamer source element (Linux only)** | `src/slic3r/GUI/Printer/gstbambusrc.{c,h}` | | **macOS native player wrapper** | `src/slic3r/GUI/wxMediaCtrl2.mm`, `src/slic3r/GUI/BambuPlayer/BambuPlayer.h` | | **Linux wxMediaCtrl shim (gstbambusrc registration)** | `src/slic3r/GUI/wxMediaCtrl2.{cpp,h}` (`__LINUX__` branch) | | **Windows / Linux camera widget — Studio (current)** | `src/slic3r/GUI/wxMediaCtrl3.{cpp,h}` (BambuStudio commit `94d91be60`, June 2024). Drives `Bambu_*` C ABI directly + decodes via `AVVideoDecoder` (FFmpeg). | | **Windows / Linux camera widget — Orca (and pre-`94d91be6` Studio)** | `src/slic3r/GUI/wxMediaCtrl2.{cpp,h}` Windows branch. Drives wxWidgets DirectShow backend → `bambu:` URL scheme → CLSID `{233E64FB-…}` source filter | | **Camera UI panel** | `src/slic3r/GUI/MediaPlayCtrl.{cpp,h}` | | **File browser UI / CTRL protocol consumer** | `src/slic3r/GUI/Printer/PrinterFileSystem.{cpp,h}`, `src/slic3r/GUI/MediaFilePanel.{cpp,h}` | > Note: the code occasionally refers to two further libraries, **`BambuSource`** and **`live555`**. These are the camera/player and the RTSP stack; they are fetched and installed through the exact same mechanism and live next to the main library. The "Network Plugin" contract proper is `bambu_networking`, but a usable Studio installation ALSO needs a working `libBambuSource` for the camera live view *and* the printer file browser. The `libBambuSource` ABI is its own beast (different symbol prefix `Bambu_*`, different loader, per-platform back-ends) — it is documented separately in **§7**. The current Studio version pinned in sources (tag `v02.06.00.51`) is `SLIC3R_VERSION = "02.06.00.51"` (`version.inc`); the expected agent version is `BAMBU_NETWORK_AGENT_VERSION = "02.06.00.50"` (`src/slic3r/Utils/bambu_networking.hpp:100`). --- ## 2. Where the plugin is downloaded from ### 2.1. Base API The URL is built by `GUI_App::get_http_url` based on the `country_code` stored in `app_config`: ```1469:1505:src/slic3r/GUI/GUI_App.cpp std::string GUI_App::get_http_url(std::string country_code, std::string path) { std::string url; if (country_code == "US") { url = "https://api.bambulab.com/"; } else if (country_code == "CN") { url = "https://api.bambulab.cn/"; } // ENV_CN_DEV -> https://api-dev.bambu-lab.com/ // ENV_CN_QA -> https://api-qa.bambu-lab.com/ // ENV_CN_PRE -> https://api-pre.bambu-lab.com/ // NEW_ENV_DEV_HOST -> https://api-dev.bambulab.net/ // NEW_ENV_QAT_HOST -> https://api-qa.bambulab.net/ // NEW_ENV_PRE_HOST -> https://api-pre.bambulab.net/ else { url = "https://api.bambulab.com/"; } url += path.empty() ? "v1/iot-service/api/slicer/resource" : path; return url; } ``` The resulting base is `https://api.bambulab.com/v1/iot-service/api/slicer/resource` (or its regional equivalent). ### 2.2. Manifest request `GUI_App::get_plugin_url` assembles the query parameter `slicer/plugins/cloud=`: ```1545:1556:src/slic3r/GUI/GUI_App.cpp std::string GUI_App::get_plugin_url(std::string name, std::string country_code) { std::string url = get_http_url(country_code); std::string curr_version = SLIC3R_VERSION; std::string using_version = curr_version.substr(0, 9) + "00"; if (name == "cameratools") using_version = curr_version.substr(0, 6) + "00.00"; url += (boost::format("?slicer/%1%/cloud=%2%") % name % using_version).str(); return url; } ``` For the networking plugin the helper is called with `name == "plugins"`. For `SLIC3R_VERSION = "02.06.00.51"` the request becomes: ``` GET https://api.bambulab.com/v1/iot-service/api/slicer/resource?slicer/plugins/cloud=02.06.00.00 ``` ### 2.3. Response format (JSON manifest) The response is parsed in `GUI_App::download_plugin` (see `src/slic3r/GUI/GUI_App.cpp` around lines 1617–1649). The expected shape: ```json { "message": "success", "resources": [ { "type": "slicer/plugins/cloud", "version": "02.05.03.xx", "description": "…changelog…", "url": "https:////plugin.zip", "force_update": false } ] } ``` Studio consumes only `version`, `description`, `url` and `force_update`. `url` points at a ZIP archive that is fetched next. ### 2.4. Special HTTP headers - **`X-BBL-OS-Type`** is temporarily set to `"windows_arm"` when downloading the plugin on Windows ARM64 and restored to `"windows"` after the request: `src/slic3r/GUI/GUI_App.cpp` 1597–1605, 1665–1672 and `src/slic3r/Utils/PresetUpdater.cpp` 1209–1237. - All other "sticky" headers (User-Agent etc.) are registered through `Slic3r::Http::set_extra_headers` and forwarded into the plugin via `bambu_network_set_extra_http_header`. ### 2.5. Background synchronization (OTA) `PresetUpdater::priv::sync_plugins` hits the same HTTP API, but its purpose is to populate the OTA cache rather than install the plugin immediately: ```1165:1253:src/slic3r/Utils/PresetUpdater.cpp void PresetUpdater::priv::sync_plugins(std::string http_url, std::string plugin_version) { ... std::string using_version = curr_version.substr(0, 9) + "00"; auto cache_plugin_folder = cache_path / PLUGINS_SUBPATH; // data_dir/ota/plugins ... std::map resources { {"slicer/plugins/cloud", { using_version, "", "", "", false, cache_plugin_folder.string()}} }; sync_resources(http_url, resources, true, plugin_version, "network_plugins.json"); ... if (result) { if (force_upgrade) { app_config->set("update_network_plugin", "true"); } else { // push notification BBLPluginUpdateAvailable } } } ``` `sync_resources` builds the final URL like this: ```581:583:src/slic3r/Utils/PresetUpdater.cpp std::string url = http_url; url += query_params; Slic3r::Http http = Slic3r::Http::get(url); ``` i.e. identically to `get_plugin_url`. ### 2.6. Download entry points - **Background**: `GUI_App::on_init` → `CallAfter` → `preset_updater->sync(http_url, lang, network_ver, ...)` (`src/slic3r/GUI/GUI_App.cpp` 1333–1340). - **"Download Bambu Network Plug-in" dialog**: `GUI_App::updating_bambu_networking()` (line 1975) → `DownloadProgressDialog` → `UpgradeNetworkJob::process()` (`src/slic3r/GUI/Jobs/UpgradeNetworkJob.cpp` 48–130). - **Manual trigger from the WebView**: event `begin_network_plugin_download` (`src/slic3r/GUI/GUI_App.cpp` ~4078–4090) and `ShowDownNetPluginDlg`. - User-facing wiki article shown on failure: `https://wiki.bambulab.com/en/software/bambu-studio/failed-to-get-network-plugin` (`src/slic3r/GUI/DownloadProgressDialog.cpp` 32–33). --- ## 3. Where it is stored and how it is installed ### 3.1. Working directory (active plugin) Studio loads the binary from **`/plugins/`**. The file name varies by OS: | Platform | Path | |----------|------| | Windows | `\plugins\bambu_networking.dll` | | Windows | `\plugins\BambuSource.dll` (optional, camera) | | Windows | `\plugins\live555.dll` (RTSP/media) | | macOS | `/plugins/libbambu_networking.dylib` | | macOS | `/plugins/libBambuSource.dylib` | | macOS | `/plugins/liblive555.dylib` | | Linux | `/plugins/libbambu_networking.so` | | Linux | `/plugins/libBambuSource.so` | | Linux | `/plugins/liblive555.so` | On Linux `` is usually `~/.config/BambuStudio/` (wxWidgets XDG path), on macOS `~/Library/Application Support/BambuStudio/`, on Windows `%AppData%\BambuStudio\`. The path is computed in `NetworkAgent::initialize_network_module`: ```183:245:src/slic3r/Utils/NetworkAgent.cpp auto plugin_folder = data_dir_path / "plugins"; if (using_backup) plugin_folder = plugin_folder/"backup"; ... #if defined(_MSC_VER) || defined(_WIN32) library = plugin_folder.string() + "\\" + std::string(BAMBU_NETWORK_LIBRARY) + ".dll"; ... networking_module = LoadLibrary(lib_wstr); #else #if defined(__WXMAC__) library = plugin_folder.string() + "/" + std::string("lib") + std::string(BAMBU_NETWORK_LIBRARY) + ".dylib"; #else library = plugin_folder.string() + "/" + std::string("lib") + std::string(BAMBU_NETWORK_LIBRARY) + ".so"; #endif networking_module = dlopen(library.c_str(), RTLD_LAZY); #endif ``` The constant `BAMBU_NETWORK_LIBRARY = "bambu_networking"` lives in `src/slic3r/Utils/bambu_networking.hpp:97`. ### 3.2. Backup copy After a successful unpack `install_plugin` copies every top-level file from `/plugins/` into **`/plugins/backup/`**. If at startup the primary plugin fails to load or is version-incompatible, Studio makes a second attempt with `using_backup=true` — the path then becomes `/plugins/backup/`: ```1874:1905:src/slic3r/GUI/GUI_App.cpp fs::path dir_path(plugin_folder); if (fs::exists(dir_path) && fs::is_directory(dir_path)) { ... for (fs::directory_iterator it(dir_path); it != fs::directory_iterator(); ++it) { if (it->path().string() == backup_folder) continue; auto dest_path = backup_folder.string() + "/" + it->path().filename().string(); if (fs::is_regular_file(it->status())) { ... CopyFileResult cfr = copy_file(it->path().string(), dest_path, error_message, false); } else { copy_framework(it->path().string(), dest_path); } } } ``` The retry logic is in `GUI_App::on_init_network` (`src/slic3r/GUI/GUI_App.cpp` 3421–3459). ### 3.3. OTA cache (staging) All background downloads land in **`/ota/plugins/`** (the constant `PLUGINS_SUBPATH` defined at `PresetUpdater.cpp:57`). That folder is expected to contain **all three** libraries plus a JSON manifest: ```1137:1160:src/slic3r/Utils/PresetUpdater.cpp network_library = cache_folder.string() + "/bambu_networking.dll"; // or .dylib / .so player_library = cache_folder.string() + "/BambuSource.dll"; live555_library = cache_folder.string() + "/live555.dll"; std::string changelog_file = cache_folder.string() + "/network_plugins.json"; if (fs::exists(network_library) && fs::exists(player_library) && fs::exists(live555_library) && fs::exists(changelog_file)) { has_plugins = true; parse_ota_files(changelog_file, cached_version, force, description); } ``` If any of the files is missing, the cache is considered incomplete. ### 3.4. `network_plugins.json` format The JSON is produced by `sync_resources` after unpacking the archive: ```712:723:src/slic3r/Utils/PresetUpdater.cpp json j; j["version"] = resource_update->second.version; j["description"] = resource_update->second.description; j["force"] = resource_update->second.force; boost::nowide::ofstream c; c.open(changelog_file, std::ios::out | std::ios::trunc); c << std::setw(4) << j << std::endl; ``` Minimal valid file: ```json { "version": "02.06.00.50", "description": "…", "force": false } ``` ### 3.5. The "download -> install" flow 1. `UpgradeNetworkJob` (with `name="plugins"` and `package_name="networking_plugins.zip"`, `src/slic3r/GUI/Jobs/UpgradeNetworkJob.cpp:19-20`) calls: - `GUI_App::download_plugin("plugins", "networking_plugins.zip", ...)` — drops the ZIP into `temp_directory_path()/networking_plugins.zip` (a parallel branch in `WebDownPluginDlg` / `GuideFrame` uses the name `network_plugin.zip`). - `GUI_App::install_plugin("plugins", "networking_plugins.zip", ...)` — extracts the archive into **`/plugins/`** while preserving its internal directory hierarchy. 2. On success a flag is written: `app_config["app"]["installed_networking"] = "1"` (`src/slic3r/GUI/GUI_App.cpp` 1906–1909). 3. `restart_networking()` (`src/slic3r/GUI/GUI_App.cpp` 1914–1957) restarts the agent: it calls `on_init_network(try_backup=true)`, resets `StaticBambuLib`, re-registers callbacks and kicks off discovery. ### 3.6. Applying OTA at startup If `update_network_plugin == "true"`, on the next launch — **before** network initialization — Studio copies the freshly downloaded libraries in: ```3359:3418:src/slic3r/GUI/GUI_App.cpp void GUI_App::copy_network_if_available() { if (app_config->get("update_network_plugin") != "true") return; auto plugin_folder = data_dir_path / "plugins"; auto cache_folder = data_dir_path / "ota" / "plugins"; #if defined(_MSC_VER) || defined(_WIN32) const char* library_ext = ".dll"; #elif defined(__WXMAC__) const char* library_ext = ".dylib"; #else const char* library_ext = ".so"; #endif for (auto& dir_entry : boost::filesystem::directory_iterator(cache_folder)) { if (boost::algorithm::iends_with(file_path, library_ext)) { copy_file(file_path, (plugin_folder / file_name).string(), error_message, false); fs::permissions(dest_path, fs::owner_read|fs::owner_write|fs::group_read|fs::others_read); } } fs::remove_all(cache_folder); app_config->set("update_network_plugin", "false"); } ``` Note: only **top-level files whose extension matches the library extension** are copied. Subdirectories and auxiliary files (e.g. certificates) are ignored. The shipped plugin must therefore be "flat" — just the library binary (`bambu_networking.{dll|so|dylib}`) plus, optionally, `BambuSource` and `live555`. ### 3.7. Removal `GUI_App::remove_old_networking_plugins` wipes the **whole** `/plugins/` tree: ```1959:1973:src/slic3r/GUI/GUI_App.cpp void GUI_App::remove_old_networking_plugins() { auto plugin_folder = data_dir_path / "plugins"; if (boost::filesystem::exists(plugin_folder)) { fs::remove_all(plugin_folder); } } ``` --- ## 4. What the plugin is, physically It is a plain native dynamic library with C exports. The calling convention is `cdecl` on Windows (`FT_CALL __cdecl` in `FileTransferUtils.hpp:15`) and the standard System V AMD64 ABI on Linux/macOS. - The main module is **`bambu_networking`** — it implements the entire networking API (`bambu_network_*`) and the file-transfer ABI (`ft_*`). **Both symbol sets live in the same library**: immediately after loading, `NetworkAgent::initialize_network_module` calls `InitFTModule(networking_module)` (`src/slic3r/Utils/NetworkAgent.cpp:276`). - Optional companion modules Studio knows how to pick up: - `BambuSource` — the wrapper for the printer camera stream. Loaded separately through `NetworkAgent::get_bambu_source_entry()` (`src/slic3r/Utils/NetworkAgent.cpp:523-575`); if it fails to load, `m_networking_compatible = false` is set and the user sees "please update the plugin" (`src/slic3r/GUI/GUI_App.cpp:3430-3437`). - `live555` — the classic RTSP library used internally by `BambuSource`. Studio never calls it directly but requires it to be present in the OTA cache (see § 3.3). The ZIP is usually a few MiB. Studio imposes no formal size limit; `install_plugin` simply extracts every file through `miniz` (`mz_zip_…`). No `plugins.json`/`manifest.xml` inside the archive is required. After extraction Studio only reads: - the library itself — via `LoadLibrary`/`dlopen`; - `network_plugins.json` **in the OTA cache** (not in the installed folder); - the symbol `bambu_network_get_version` to determine the version. --- ## 5. Validation ### 5.1. Studio <-> plugin version compatibility The main check is that the first **8 characters** of the version string match, i.e. `MAJOR.MINOR.PATCH` without the build suffix: ```1982:1998:src/slic3r/GUI/GUI_App.cpp bool GUI_App::check_networking_version() { std::string network_ver = Slic3r::NetworkAgent::get_version(); std::string studio_ver = SLIC3R_VERSION; // "02.06.00.51" if (network_ver.length() >= 8) { if (network_ver.substr(0,8) == studio_ver.substr(0,8)) { // "02.06.00" m_networking_compatible = true; return true; } } m_networking_compatible = false; return false; } ``` For `SLIC3R_VERSION = "02.06.00.51"` the plugin must return **a string starting with `"02.06.00"`** (e.g. `"02.06.00.50"`). Otherwise Studio marks it incompatible, sets `m_networking_need_update=true` and pops up the update dialog. > Observation: on Linux this version check is effectively the **only** formal compatibility gate — see § 5.2, where the signature check is a no-op on that platform. The plugin exposes its version through the symbol `bambu_network_get_version` (`func_get_version` typed as `std::string(*)(void)`). See `NetworkAgent::get_version`: ```583:603:src/slic3r/Utils/NetworkAgent.cpp std::string NetworkAgent::get_version() { bool consistent = true; if (check_debug_consistent_ptr) { #if defined(NDEBUG) consistent = check_debug_consistent_ptr(false); #else consistent = check_debug_consistent_ptr(true); #endif } if (!consistent) return "00.00.00.00"; if (get_version_ptr) return get_version_ptr(); return "00.00.00.00"; } ``` A separate consistency check is `bambu_network_check_debug_consistent(bool is_debug)` — it lets the plugin reject a mismatched debug/release build. If it returns `false`, Studio treats the version as `"00.00.00.00"` and refuses to proceed. ### 5.2. Binary signature Before calling `LoadLibrary`/`dlopen` Studio compares the module's publisher with Studio's own publisher: ```190:267:src/slic3r/Utils/NetworkAgent.cpp std::optional self_cert_summary, module_cert_summary; if (validate_cert) self_cert_summary = SummarizeSelf(); ... if (self_cert_summary) { module_cert_summary = SummarizeModule(library); if (module_cert_summary) { if (IsSamePublisher(*self_cert_summary, *module_cert_summary)) networking_module = LoadLibrary(lib_wstr); // (or dlopen) else BOOST_LOG_TRIVIAL(info) << "module is from another publisher..."; } } else { networking_module = LoadLibrary(lib_wstr); // self cert unknown -> load as is } ``` `IsSamePublisher`: ```294:300:src/slic3r/Utils/CertificateVerify.cpp bool IsSamePublisher(const SignerSummary& a, const SignerSummary& b) { if (!a.team_id.empty() && a.team_id == b.team_id) return true; // macOS TeamID if (a.spki_sha256 == b.spki_sha256) return true; // same SPKI if (a.cert_sha256 == b.cert_sha256) return true; // same certificate return false; } ``` - **Windows**: the Authenticode signature of the main `bambu-studio.exe` and of `bambu_networking.dll` must share either an SPKI or a certificate. If the plugin is unsigned, `SummarizeModule` returns `nullopt`, the "error" branch is logged, `networking_module` stays `nullptr`, and the module **will not be loaded**. - **macOS**: the comparison uses the `team_id` (Developer ID). - **Linux**: `SummarizeSelf` / `SummarizeModule` **always return `std::nullopt`** — see: ```289:291:src/slic3r/Utils/CertificateVerify.cpp #else std::optional SummarizeSelf() { return std::nullopt; } std::optional SummarizeModule(const std::string&) { return std::nullopt; } #endif ``` Therefore on Linux `if (self_cert_summary)` is false and Studio takes the "load as is" branch — **the signature is effectively not verified on Linux**. ### 5.3. Bypassing the signature check `AppConfig` exposes a flag **`ignore_module_cert`**, which is forwarded to the `validate_cert` parameter: ```3423:3423:src/slic3r/GUI/GUI_App.cpp int load_agent_dll = Slic3r::NetworkAgent::initialize_network_module(false, !app_config->get_bool("ignore_module_cert")); ``` Setting `ignore_module_cert = 1` in `BambuStudio.conf` disables the publisher check on Windows/macOS entirely. ### 5.4. What "plugin installed" looks like to Studio - A boolean **`installed_networking`** key in `app_config` (section `app`) — set to `"1"` after a successful `install_plugin` (`src/slic3r/GUI/GUI_App.cpp:1906-1909`). This flag drives the "show install/update dialog" logic. - The actual "the plugin works" check is this chain: 1. `LoadLibrary`/`dlopen` returns non-null; 2. `bambu_network_check_debug_consistent` returns `true` for the appropriate build flavor; 3. `bambu_network_get_version` returns a string at least 8 chars long with the right version prefix; 4. `BambuSource` also loaded successfully. ### 5.5. Archive integrity (MD5/SHA) **Not checked.** There is no hash verification of the ZIP anywhere in `download_plugin` / `install_plugin` / `sync_resources` (`src/slic3r/GUI/GUI_App.cpp`, `src/slic3r/Utils/PresetUpdater.cpp`). The only defense-in-depth measure is the binary's own signature. Error codes of the form `BAMBU_NETWORK_ERR_CHECK_MD5_FAILED` (see `src/slic3r/Utils/bambu_networking.hpp:29, 54, 70`) belong to MD5 checks **inside the plugin** during print-job uploads, not to verification of the plugin itself. --- ## 6. The full C ABI contract All symbols are resolved through `GetProcAddress` (Windows) / `dlsym` (Linux, macOS) in `NetworkAgent::get_network_function`: ```564:581:src/slic3r/Utils/NetworkAgent.cpp void* NetworkAgent::get_network_function(const char* name) { if (!networking_module) return nullptr; #if defined(_MSC_VER) || defined(_WIN32) return GetProcAddress(networking_module, name); #else return dlsym(networking_module, name); #endif } ``` Symbol names are not mangled — every function must be declared `extern "C"`. > ABI note: even though this is a C-style interface, the signatures use C++ types (`std::string`, `std::vector`, `std::map`, `std::function`, and custom structs `PrintParams`/`BBLModelTask`/…). The plugin must therefore be built with the same compiler and libstdc++/libc++ standard-library ABI as Bambu Studio itself. It is **not** a pure C ABI — mixing compilers/linkers (e.g. GCC vs. MSVC) is not safe. ### 6.1. Initialization and lifecycle | Symbol | Typedef | Description | |--------|---------|-------------| | `bambu_network_check_debug_consistent` | `bool(*)(bool is_debug)` | Returns `true` if the plugin build matches Studio's build flavor (debug/release). Called before `get_version`. | | `bambu_network_get_version` | `std::string(*)(void)` | Returns the version formatted as `NN.NN.NN.NN`. The first 8 characters must match `SLIC3R_VERSION`. | | `bambu_network_create_agent` | `void*(*)(std::string log_dir)` | Creates an agent instance and returns an opaque handle (`void* agent`). | | `bambu_network_destroy_agent` | `int(*)(void* agent)` | Destroys the agent. | | `bambu_network_init_log` | `int(*)(void* agent)` | Initializes the internal log. | | `bambu_network_set_config_dir` | `int(*)(void*, std::string)` | Configures directory (equal to `data_dir()`). | | `bambu_network_set_cert_file` | `int(*)(void*, std::string folder, std::string filename)` | Studio passes `resources_dir()/cert` and `slicer_base64.cer`. | | `bambu_network_set_country_code` | `int(*)(void*, std::string)` | `"US"`, `"CN"`, … | | `bambu_network_start` | `int(*)(void*)` | Starts the agent's event loop / worker threads. | #### Initialization sequence The Studio-side call order after `create_agent` is deterministic and lives in `GUI_App::on_init_network` (`src/slic3r/GUI/GUI_App.cpp:3461-3510`): 1. `set_config_dir(data_dir())` 2. `init_log()` 3. `set_cert_file(resources_dir()+"/cert", "slicer_base64.cer")` 4. `init_http_extra_header` → `set_extra_http_header(...)` 5. the full `set_on_*_fn(...)` battery (see § 6.2) 6. `set_country_code(country_code)` 7. `start()` 8. `start_discovery(true, false)` The plugin must tolerate this exact order (in particular, no networking work should happen before `start()`). #### 6.1.1. Certificate files (`set_cert_file`) Studio ships **two PEM files** next to each other under `/cert/` inside the AppImage / install tree: | File | What it is (observed) | Role (reverse-engineered / observed) | |------|------------------------|--------------------------------------| | **`slicer_base64.cer`** | BBL / RapidSSL-style bundle for **Bambu cloud** hostnames (`*.bambulab.com`, MakerWorld HTTPS, etc.). This is the file Studio **names** in the ABI call. | Passed to stock plugin via `set_cert_file`; stock almost certainly uses it for **cloud** TLS. Not referenced for LAN printer TLS in Studio's public sources. | | **`printer.cer`** | BBL **CA** bundle (root + intermediate CAs such as `BBL CA`, `BBL CA2 RSA/ECC`). **Not** the printer's device leaf. Observed **5** CA certs in Studio v02.07.0.55; **does not** include per-model device issuers such as `BBL Device CA N7-V2`. | Shipped alongside `slicer_base64.cer` under `/cert/`. Stock closed-source plugin behaviour for LAN trust is **not** observable from Studio source alone; see LAN observations below. | **Why Studio passes `slicer_base64.cer` in the API:** the stock closed-source plugin uses that second argument as its cloud trust bundle. The ABI is fixed; Studio always calls `set_cert_file(resources_dir()+"/cert", "slicer_base64.cer")`. **LAN TLS — what the printer sends (verified on P2S, firmware 02.07.x):** - TLS handshake carries **one certificate**: the **device leaf** (`subject=CN=`, e.g. `CN=22E8BJ610801473`). - **No DNS/IP SAN** on the leaf; clients connect by LAN IP but must check **CN = serial** (SNI is set to the serial). - Leaf **issuer** on N7/P2S: `CN=BBL Device CA N7-V2` (other models may use other `BBL Device CA …` names). - **`printer.cer` alone does not chain-verify** that leaf on N7/P2S — the issuer CA is missing from the Studio bundle (confirmed with `openssl s_client` / `openssl verify`). **Stock plugin LAN TLS policy:** not reverse-engineered from the closed binary. Any LAN client must cope with leaf-only chains and CN=serial hostname checks as observed above. ### 6.2. Callbacks (registration) All take a `void* agent` and an `std::function<…>`: | Symbol | Callback type (from `bambu_networking.hpp`) | |--------|---------------------------------------------| | `bambu_network_set_on_ssdp_msg_fn` | `OnMsgArrivedFn = std::function` | | `bambu_network_set_on_user_login_fn` | `OnUserLoginFn = std::function` | | `bambu_network_set_on_printer_connected_fn` | `OnPrinterConnectedFn = std::function` | | `bambu_network_set_on_server_connected_fn` | `OnServerConnectedFn = std::function` | | `bambu_network_set_on_http_error_fn` | `OnHttpErrorFn = std::function` | | `bambu_network_set_get_country_code_fn` | `GetCountryCodeFn = std::function` | | `bambu_network_set_on_subscribe_failure_fn` | `GetSubscribeFailureFn = std::function` | | `bambu_network_set_on_message_fn` | `OnMessageFn = std::function` | | `bambu_network_set_on_user_message_fn` | `OnMessageFn` | | `bambu_network_set_on_local_connect_fn` | `OnLocalConnectedFn = std::function` | | `bambu_network_set_on_local_message_fn` | `OnMessageFn` | | `bambu_network_set_queue_on_main_fn` | `QueueOnMainFn = std::function)>` — "run this lambda on the GUI thread" | | `bambu_network_set_server_callback` | `OnServerErrFn = std::function` | ### 6.3. Cloud — connection and subscriptions | Symbol | Signature | |--------|-----------| | `bambu_network_connect_server` | `int(void*)` | | `bambu_network_is_server_connected` | `bool(void*)` | | `bambu_network_refresh_connection` | `int(void*)` | | `bambu_network_start_subscribe` | `int(void*, std::string module)` | | `bambu_network_stop_subscribe` | `int(void*, std::string module)` | | `bambu_network_add_subscribe` | `int(void*, std::vector dev_list)` | | `bambu_network_del_subscribe` | `int(void*, std::vector dev_list)` | | `bambu_network_enable_multi_machine` | `void(void*, bool)` | | `bambu_network_send_message` | `int(void*, std::string dev_id, std::string json_str, int qos, int flag)` — MQTT-style call | > **Dual MQTT (LAN + cloud simultaneously) with a LAN-priority report subscription.** For a cloud-paired printer the stock stack does **not** pick one transport — it opens **both** MQTT sessions at once: the LAN broker (`mqtts://:8883`, user `bblp`, password = access code) **and** the cloud broker (`*.mqtt.bambulab.com:8883`). This was confirmed on-wire: a stock capture shows the two TLS connections established in parallel, with the **LAN** session carrying the bulk of the `push_status` telemetry and the cloud session comparatively quiet. The mechanism is Studio's `DeviceSubscribeManager`, keyed off a `dev_id → {dev_ip, access_code}` cache: it auto-creates the **local** report subscription, and while local telemetry is flowing it **does not subscribe to the cloud `device//report` topic** (a defer-close of the cloud report leg — the cloud MQTT socket stays connected but silent), then **fails back to the cloud report subscription** if the local channel goes silent. So on a healthy LAN both sockets are up but only the LAN one is subscribed to reports; the cloud leg is a warm standby ([issue #49](https://github.com/ClusterM/open-bambu-networking/issues/49)). The plugin-facing primitives are just `add_subscribe` / `del_subscribe` (buffer, apply on the next cloud `CONNACK`); which of them fires when is the caller's (Studio's) policy. #### 6.3.1. Cloud MQTT authentication Two distinct mechanisms exist on the production broker. **This open plugin uses the simpler one**; the stock closed-source plugin uses the other. **Open plugin (implemented — `src/cloud_session.cpp`)** | Field | Value | |-------|-------| | Host | `us.mqtt.bambulab.com:8883` (or `cn.mqtt.bambulab.com` for CN) | | TLS | Server cert verified (system CA store; Windows MVP skips chain verify — see `Agent::connect_cloud()`) | | Username | `u_` | | Password | OAuth access token from `POST /v1/user-service/user/ticket/` | This matches what Home Assistant's `pybambu` integration uses. No client certificate is required for the open plugin path; cloud auth rides on the bearer token in the MQTT password field. > **Plaintext `:1883` alongside `:8883`.** Some firmware exposes an unencrypted MQTT listener on `:1883` next to the TLS `:8883` broker ([issue #49](https://github.com/ClusterM/open-bambu-networking/issues/49)). OBN always uses the TLS port; the plaintext path is documented only so it is not mistaken for a rogue service during packet captures. **Stock plugin (reverse-engineered — not implemented here)** The stock `libbambu_networking.so` obtains a **client certificate chain** from the cloud REST API immediately before connecting and presents it during the MQTT TLS handshake (mutual TLS). The HTTPS call that fetches the cert was confirmed via SSLKEYLOGFILE decryption of the stock plugin's **REST** traffic (not by decrypting the MQTT session itself). **Certificate / app-credential retrieval endpoint (stock; request side verified in OBN):** ``` GET /v1/iot-service/api/user/applications/{enc_secret}/cert?aes256={wrapped_session_key}&ver=1 ``` No `Authorization` bearer is required for this endpoint (live probes return `code:0` anonymously when the envelope is valid). Stock Studio still sends the usual `X-BBL-*` / `User-Agent: bambu_network_agent/…` fingerprint headers; Cloudflare may 403 bare clients without them. The two URL parameters are **wrappers**, not a token and not a raw AES key — see the algorithm below. `ver=1` is the API version. *(RN-3; OBN live verify 2026-07)* **Response (200 OK)** — shape confirmed; private-key finalization is **not** fully reversed: ```json { "message": "success", "code": 0, "error": null, "cert": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n", "crl": ["-----BEGIN X509 CRL-----\n...\n-----END X509 CRL-----\n"], "key": "{base64-encoded encrypted private-key blob}" } ``` The `cert` field is a **shared application certificate chain** (PEM-concatenated), **not** a per-printer / per-install device cert. Observed Studio leaf (2026-07): 1. **App leaf** — subject/O: `GLOF3813734089-836763c70000`, issuer: `GLOF3813734089.bambulab.com` 2. **App intermediate** — subject: `GLOF3813734089.bambulab.com`, issuer: `application_root.bambulab.com` 3. **Application root** — subject: `application_root.bambulab.com`, issuer: `BBL CA` (`cert_id` for MQTT / HTTP PoP is derived from the leaf: lowercase hex serial ‖ issuer RFC4514, e.g. `a4e8faaa…CN=GLOF3813734089.bambulab.com` — see §6.8.) Do **not** confuse this chain with the printer's LAN TLS leaf (`CN=`) or the per-device MQTT signing material discussed elsewhere. **`crl` field:** a fresh CRL issued at request time (array of PEM strings). **`key` field (wire-confirmed size; decrypt algorithm open):** standard base64 of a **704-byte** blob. Layout observed on every successful response: ``` header(28 bytes) || u32le length (= 672) || body(672 bytes) ``` RN-3 documents this as `AES-256-GCM(session_key)` over a PEM private key (`IV(12)||ct||tag(16)`). That recipe **does not work** on live blobs: a PEM PKCS#1 RSA-2048 is ~1679 bytes (DER ~1192), which cannot fit in 672 bytes of body, and AES-GCM / AES-CBC / common KDF variants over the exact request `session_key` all fail (`InvalidTag` / garbage). Stock clients finalize via a native helper (`CM.up1(response_json)` in Bambu Connect; equivalent code inside `libbambu_networking.so`). Until that format is reversed, OBN continues to take `slicer_key.pem` out-of-band. *(OBN live verify 2026-07; also estampo cloud-print-research, bambu_connect_disasm chunk 88)* **What is baked into the stock plugin binary (two pieces, both required for the request):** | Embedded material | Role | |-------------------|------| | `client_auth_secret` | ASCII bootstrap secret. Shape: app-cert CN prefix (`GLOF3813734089-836763c70000`) + 16 hardcoded hex chars (43 bytes total for the current Studio app cert). Proves "I am an official client." | | `server_wrap_key` (RSA-2048 **public** key) | Dedicated cloud wrap key used **only** to PKCS#1 v1.5-encrypt the ephemeral `session_key`. **Not** `application_root` / the returned chain root / the app leaf public key. | Wrapping `session_key` with `application_root` (or any chain cert) yields HTTP 400 `code:101` `"This application is outdated"` even when the secret and encodings are correct — that error is a wrap-key mismatch, not a stale Studio version. The wrap key must be extracted from a live stock plugin (it is not published with the app-cert docs). *(OBN live verify 2026-07)* **Credential rotation — request construction (verified end-to-end):** ```py # Both secrets come from the stock plugin binary (not from the user's login). client_auth_secret = ... # e.g. b"GLOF3813734089-836763c70000" + 16 hex server_wrap_key = load_pem_public_key("server_wrap_key.pem") # RSA-2048 session_key = secure_random(32) # ephemeral; client keeps this for response finalization iv = secure_random(12) ct_tag = AESGCM_Encrypt(session_key, iv, client_auth_secret) # ciphertext||tag enc_secret = Base64url(iv || ct_tag) # path segment wrapped_key = Base64url(RSA_PKCS1v15(server_wrap_key, session_key)) # aes256= query GET {api}/v1/iot-service/api/user/applications/{enc_secret}/cert?aes256={wrapped_key}&ver=1 # Wire encodings are base64url (RFC 4648 §5, padding kept). Standard base64 in the # path segment produces '/' and 404s on the gateway. ``` Conceptually this is only "prove you shipped with the official client → receive the current shared app cert + CRL (+ encrypted private key)". It does **not** authenticate a user account or a printer. The AES/RSA envelope exists so a TLS MITM alone cannot recover `client_auth_secret`, `session_key`, or (once finalization is understood) the app private key. *(RN-3 corrected by OBN: wrap key ≠ root CA)* In Bambu Connect the same envelope is built by native `CM.eak()` → `{encAppKey, aes256}` (path + query), and the JSON response is passed to native `CM.up1(json)` for finalization ([bambu_connect_disasm](https://github.com/Randomblock1/bambu_connect_disasm) chunk 88). `fetch_device_cert()` in `include/obn/cloud_auth.hpp` still documents an older incomplete sketch of this path (`#if 0`); OBN does not fetch or decrypt the app private key from the cloud — `slicer_key.pem` is supplied out-of-band. **Do not confuse with:** the LAN `project_file` device-cert field encryption (`CN=.bambulab.com`, §6.8.2) or the printer's LAN TLS server cert (`CN=`, §6.1.1) — those are separate from this shared app-credential fetch. ### 6.4. Local printer connection (LAN) | Symbol | Signature | |--------|-----------| | `bambu_network_connect_printer` | `int(void*, std::string dev_id, std::string dev_ip, std::string username, std::string password, bool use_ssl)` | | `bambu_network_disconnect_printer` | `int(void*)` | | `bambu_network_send_message_to_printer` | `int(void*, std::string dev_id, std::string json_str, int qos, int flag)` | | `bambu_network_update_cert` | `int(void* agent)` — `func_check_cert`; refreshes certificates at runtime | | `bambu_network_install_device_cert` | `void(void*, std::string dev_id, bool lan_only)` | | `bambu_network_start_discovery` | `bool(void*, bool start, bool sending)` — SSDP | #### 6.4.1. Where the LAN credentials come from (incl. cloud-paired printers) A LAN MQTT session needs two facts per printer: the **IP address** and the **8-character access code** (the LAN broker login is always `user=bblp`, `password=`). A printer in **LAN-Only / Developer Mode** supplies both directly (the code is shown on its screen and typed into Studio). The interesting case is a **cloud-paired** printer that still has both MQTT sessions up (§6.3): the stock stack assembles the same two facts without any manual entry. - **IP address → SSDP.** `bambu_network_start_discovery` runs the SSDP multicast listener on `239.255.255.250:1990`; the printer's periodic `NOTIFY` beacons carry its serial (`dev_id`) and current LAN IP. This yields the `dev_id → dev_ip` half. - **Access code → cloud REST.** When the user is logged in, `get_user_print_info` returns `dev_access_code` in **plaintext** for every cloud-bound device (`GET /v1/iot-service/api/user/print?force=true`, or `GET /v1/iot-service/api/user/bind`; §6.10). This is the **same** 8-hex code shown on the printer display and used as the LAN MQTT / FTPS / `:6000` password. This yields the `dev_id → access_code` half. (Security consequence: any bearer-token holder can retrieve the LAN access codes of all their cloud-bound printers — see the security note in §6.10.) With both halves known, `connect_printer(dev_id, dev_ip, "bblp", , use_ssl=true)` brings the LAN MQTT session up **even though the device is cloud-paired**, which is exactly what the LAN-priority subscription in §6.3 relies on. ```mermaid flowchart LR ssdp["SSDP :1990 NOTIFY"] -->|"dev_id + dev_ip"| cache["dev_id → {dev_ip, access_code}"] rest["GET /user/print?force=true\n(logged-in cloud REST)"] -->|"dev_access_code (plaintext)"| cache cache -->|"connect_printer(bblp, access_code)"| lan["LAN MQTT :8883\n(mqtts, LAN-priority reports)"] ``` ### 6.5. Authentication and user | Symbol | Signature | |--------|-----------| | `bambu_network_change_user` | `int(void*, std::string user_info)` | | `bambu_network_is_user_login` | `bool(void*)` | | `bambu_network_user_logout` | `int(void*, bool request)` | | `bambu_network_get_user_id` | `std::string(void*)` | | `bambu_network_get_user_name` | `std::string(void*)` | | `bambu_network_get_user_avatar` | `std::string(void*)` | | `bambu_network_get_user_nickanme` | `std::string(void*)` *(the "nickanme" typo is part of the actual ABI!)* | | `bambu_network_build_login_cmd` | `std::string(void*)` | | `bambu_network_build_logout_cmd` | `std::string(void*)` | | `bambu_network_build_login_info` | `std::string(void*)` | | `bambu_network_get_my_profile` | `int(void*, std::string token, unsigned int* http_code, std::string* http_body)` | | `bambu_network_get_my_token` | `int(void*, std::string ticket, unsigned int* http_code, std::string* http_body)` | | `bambu_network_get_user_info` | `int(void*, int* identifier)` | > Known Studio bug (`src/slic3r/Utils/NetworkAgent.cpp:368`): the `get_my_token_ptr` pointer is mistakenly resolved via the string `"bambu_network_get_my_profile"` instead of `"bambu_network_get_my_token"`. Studio still tries to read the `bambu_network_get_my_token` symbol as well, so a compatible plugin must export **both**. Through that pointer Studio will in practice execute the `get_my_profile` body — the two functions must therefore share identical signatures, and any real token-fetching logic ends up running from `get_my_profile`. #### 6.5.1. Ticket login — confirmed response shape `POST /v1/user-service/user/ticket/` with body `{"ticket":""}`. Confirmed from SSLKEYLOGFILE capture. The `ticket` is a short alphanumeric code (~6 chars) produced by a separate browser-based OAuth flow outside this library. Response (200 OK): ```json { "accessToken": "AQB...", "refreshToken": "AQB...", "expiresIn": 31536000, "refreshExpiresIn": 31536000, "tfaKey": "", "accessMethod": "ticket", "loginType": "", "firstAppLogin": false } ``` `expiresIn` and `refreshExpiresIn` are both 1 year (31 536 000 seconds) from issue in observed data. `accessToken` and `refreshToken` are the same value on initial issue — they may diverge after a token refresh cycle. `accessMethod` is `"ticket"` for this flow. Token format: `AQA…` / `AQB…` prefix, base64url-encoded opaque token (~100+ chars). #### 6.5.2. User profile — confirmed response shape `GET /v1/user-service/my/profile` with `Authorization: Bearer `. Confirmed from SSLKEYLOGFILE capture. The fields Studio and the plugin actually consume are `uid`/`uidStr`, `name`, `nickname`, `avatar`, and `account`. The full response shape includes additional social/content fields: ```json { "uid": 12345678, "uidStr": "12345678", "account": "", "name": "", "avatar": "", "fanCount": 0, "followCount": 0, "identifier": 1, "productModels": [], "personal": { "bio": "", "links": [], "taskWeightSum": , "taskLengthSum": , "taskTimeSum": , "backgroundUrl": "", "designsInfo": [], "userLevel": { "level": , "gradeType": } }, "isNSFWShown": 0, "favoritesCount": , "defaultLicense": "", "point": 0, "tpModelAccounts": [], "bannedPermission": { "whole": false, "comment": false, "upload": false, "redeem": false }, "MWCount": { "myDesignDownloadCount": 0 }, "certificated": false, "setting": { "isLikeOpen": 0, "isFollowOpen": 0, "isFanOpen": 0, "isFirmwareBetaOpen": false, "recommendStatus": 0 } } ``` `setting.isFirmwareBetaOpen` controls whether the firmware endpoint returns beta-channel builds in the `firmware[]` array (see §6.7). `personal.taskWeightSum` / `taskLengthSum` / `taskTimeSum` are cumulative print statistics. ### 6.6. Binding / bind | Symbol | Signature | |--------|-----------| | `bambu_network_ping_bind` | `int(void*, std::string ping_code)` | | `bambu_network_bind_detect` | `int(void*, std::string dev_ip, std::string sec_link, detectResult& detect)` | | `bambu_network_bind` | `int(void*, std::string dev_ip, std::string dev_id, std::string sec_link, std::string timezone, bool improved, OnUpdateStatusFn update_fn)` | | `bambu_network_unbind` | `int(void*, std::string dev_id)` | | `bambu_network_request_bind_ticket` | `int(void*, std::string* ticket)` — WebView SSO short code (Print History detail, MakerWorld, bind). See SSO note below. | | `bambu_network_query_bind_status` | `int(void*, std::vector query_list, unsigned int* http_code, std::string* http_body)` | **WebView SSO ticket (`request_bind_ticket`, MITM 2026-07).** Studio asks the plugin for a short alphanumeric ticket, then opens the embedded browser at `makerworld.com/api/sign-in/ticket?to=&ticket=`. Stock does **two** cloud REST calls before that navigation: 1. `GET /v1/user-service/user/ticket` (Bearer) → `{"ticket":"ABV5MR","pincode":"ABV5MR"}` — mint an unbound code. 2. `POST /v1/user-service/my/ticket/` with body `{"ticket":""}` (Bearer) → **binds** that code to the logged-in session (empty 200 body). Only after step 2 does `GET …/api/sign-in/ticket?…&ticket=` respond with `Set-Cookie: token=` and redirect to the target (Print History detail, etc.). If step 2 is skipped, MakerWorld still 307s to the target URL but sets an **empty** `token` cookie (`Max-Age=NaN`); the next navigation then 302s to `/sign-in/service` / bambulab login. Step 1 alone is not enough. The `detectResult` struct (`src/slic3r/Utils/bambu_networking.hpp:180-189`): ```cpp struct detectResult { std::string result_msg, command, dev_id, model_id, dev_name, version, bind_state, connect_type; }; ``` ### 6.7. Printer selection and metadata | Symbol | Signature | |--------|-----------| | `bambu_network_get_bambulab_host` | `std::string(void*)` | | `bambu_network_get_user_selected_machine` | `std::string(void*)` | | `bambu_network_set_user_selected_machine` | `int(void*, std::string dev_id)` | | `bambu_network_modify_printer_name` | `int(void*, std::string dev_id, std::string dev_name)` | | `bambu_network_get_printer_firmware` | `int(void*, std::string dev_id, unsigned* http_code, std::string* http_body)` | `get_printer_firmware` is invoked from `MachineObject::get_firmware_info` (`src/slic3r/GUI/DeviceManager.cpp:3764`) on a background thread when the user opens **Device → Update**. A return value `< 0` makes Studio silently hide the firmware list (`m_firmware_valid = false`). Otherwise `http_body` is parsed as JSON with the following schema: ```json { "devices": [{ "dev_id": "", "version": "", "firmware": [ { "version": "01.08.02.00", "force_update": false, "url": "https://public-cdn.bblmw.com/upgrade/device///product//ota-.json.sig", "description": "optional release notes text (plain/markdown)", "status": "release" } ], "ams": [{ "firmware": [ { "version": "00.00.07.89", "force_update": false, "url": "https://.../ams.bin", "description": "...", "status": "release" } ] }] }] } ``` OTA URL format: `https://public-cdn.bblmw.com/upgrade/device///product//ota-_v-.json.sig`. `force_update: true` indicates the printer should refuse to operate until this firmware is applied — Studio greys out all controls and shows only the Update button. `status` is `"release"` for GA firmware or `"beta"` for beta-channel builds (controlled by the `isFirmwareBetaOpen` flag in `GET /v1/user-service/my/profile` → `setting.isFirmwareBetaOpen`). The `version` field at the device level is the currently installed version, not present in the pre-capture schema — Studio derives installed version from MQTT `info.command=get_version` regardless. Studio creates a `FirmwareInfo item` per entry in `firmware[]` / `ams[].firmware[]` and derives the file name from the tail of `url` (`item.name = url.substr(url.find_last_of('/') + 1)`). If the name cannot be extracted, the entry is skipped. The `description` field is the text displayed in the **Release Notes** dialog. Important: Studio does **not** read the currently installed version from this response — that arrives separately, through the MQTT `info.command=get_version` payload (array `info.module[]`, field `sw_ver`) and `push_status.upgrade_state.new_ver_list`. This ABI call answers only "what can be flashed" (plus, optionally, release notes for those versions). The **Update** button ultimately publishes `{"upgrade":{"command":"upgrade_confirm"}}` over LAN MQTT — the printer itself downloads the firmware from the CDN, and Studio uses the URL in `firmware[].url` only for the displayed file name. When `devices[0].firmware[]` is empty (the currently installed firmware is already the newest one known to the printer), the Release Notes dialog opens empty — this is normal stock behaviour, not a bug. ### 6.8. Submitting a print job Types: - `OnUpdateStatusFn = std::function` - `WasCancelledFn = std::function` - `OnWaitFn = std::function` The `PrintParams` struct (`src/slic3r/Utils/bambu_networking.hpp:192-241`) carries these fields: `dev_id`, `task_name`, `project_name`, `preset_name`, `filename`, `config_filename`, `plate_index`, `ftp_folder`, `ftp_file`, `ftp_file_md5`, `nozzle_mapping`, `ams_mapping`, `ams_mapping2`, `ams_mapping_info`, `nozzles_info`, `connection_type`, `comments`, `origin_profile_id`, `stl_design_id`, `origin_model_id`, `print_type`, `dst_file`, `dev_name`, `dev_ip`, `use_ssl_for_ftp`, `use_ssl_for_mqtt`, `username`, `password`, `task_bed_leveling`, `task_flow_cali`, `task_vibration_cali`, `task_layer_inspect`, `task_record_timelapse`, `task_timelapse_use_internal`, `task_use_ams`, `task_bed_type`, `extra_options`, `auto_bed_leveling`, `auto_flow_cali`, `auto_offset_cali`, `extruder_cali_manual_mode`, `task_ext_change_assist`, `try_emmc_print`. | Symbol | Signature | |--------|-----------| | `bambu_network_start_print` | `int(void*, PrintParams, OnUpdateStatusFn, WasCancelledFn, OnWaitFn)` — cloud | | `bambu_network_start_local_print_with_record` | `int(void*, PrintParams, OnUpdateStatusFn, WasCancelledFn, OnWaitFn)` — LAN + metadata upload | | `bambu_network_start_send_gcode_to_sdcard` | `int(void*, PrintParams, OnUpdateStatusFn, WasCancelledFn, OnWaitFn)` — FTPS upload of `filename` to printer storage; **destination name = `project_name`** (sanitized). No MQTT print command. See §6.14.3 for Studio callers | | `bambu_network_start_local_print` | `int(void*, PrintParams, OnUpdateStatusFn, WasCancelledFn)` — LAN only | | `bambu_network_start_sdcard_print` | `int(void*, PrintParams, OnUpdateStatusFn, WasCancelledFn)` | Print-job stages — the `SendingPrintJobStage` enum (`bambu_networking.hpp:146-156`): `Create=0, Upload=1, Waiting=2, Sending=3, Record=4, WaitPrinter=5, Finished=6, ERROR=7, Limit=8`. #### 6.8.0. End-to-end print flows (Studio-side orchestration) This section documents **what Bambu Studio itself calls** when the user starts a print or upload — not what the stock/open plugin does internally ([STATUS.md §6.8](STATUS.md#open-plugin-abi--internal-implementation) covers our plugin). All paths below are traced from `PrintJob.cpp`, `SendJob.cpp`, `SendToPrinter.cpp`, and `SelectMachine.cpp` in upstream BambuStudio. ##### UI entry points → worker classes | User action | Studio dialog / job | `PrintParams.print_type` / notes | |---|---|---| | **Slice → Print plate** (normal send) | `SelectMachineDialog` → `PrintJob` | `"from_normal"` | | **Device → Files → Print** on an existing `.3mf` | `SelectMachineDialog` → `PrintJob` | `"from_sdcard_view"`; `dst_file` = path on printer storage | | **Send to Printer** (upload to cache/USB, no print) | `SendToPrinterDialog` | **`ft_*`** when `is_support_brtc` + LAN/TUTK tunnel (`SendToPrinter.cpp:947`); else fallback **`SendJob`** → `start_send_gcode_to_sdcard` (FTPS, pre-brtc printers) | | **Access-code / IP validation** | `PrintJob` preflight, `SendJob` check mode (`ReleaseNote.cpp` IP dialog) | Same ABI with `project_name="verify_job"` and a tiny temp file — see §6.14.3 | Studio wraps every `bambu_network_start_*` call through `NetworkAgent::{start_print,start_local_print,…}` (`NetworkAgent.cpp:1363-1425`), which forwards to the loaded plugin unchanged. ##### Printer capability flags Studio does **not** pass these booleans into `PrintParams`. They live on `MachineObject` (and sub-objects like `DevPrintOptions`, `DevAxis`, `DevStorage`), parsed from MQTT `push_status.print` on every status update. They **gate UI and orchestration** before any plugin call. All bit indices below are zero-based. Hex-string fields (`fun`, `fun2`, `cfg`, `aux`, `stat`) are parsed via `get_flag_bits(hex_str, bit)` which converts the hex string to a 64-bit integer first. Integer fields (`home_flag`, `flag3`, `xcam.cfg`) use direct bit shifts. ###### `print.fun` — hex string, up to 64 bits Parsed in `DeviceManager.cpp` (`parse_new_info`), `DevPrintOptions.cpp` (`ParseCapabilityV1_0`), and `DevAxis.cpp` (`ParseAxis`). | Bit | Studio variable | Description | |-----|-----------------|-------------| | 1 | `is_support_agora` | Agora-based video streaming; when set, disables `is_support_tunnel_mqtt` | | 2 | `is_220V_voltage` | Printer operates on 220 V mains (affects bed temp limits) | | 6 | `is_support_flow_calibration` | Flow-rate calibration support (force-disabled on O-series) | | 7 | `is_support_pa_calibration` | Pressure-advance calibration support (force-disabled on P-series) | | 8 | `m_allow_prompt_sound_detection.is_support` | Firmware can toggle completion/error beep | | 9 | `m_filament_tangle_detection.is_support` | Filament tangle detection capability | | 10 | `is_support_motor_noise_cali` | Motor vibration compensation / noise calibration | | 11 | `is_support_user_preset` | Printer can store user presets on-device | | 12 | `is_support_door_open_check` | Door-open safety check during print | | 13 | `m_nozzle_blob_detection.is_support` | Nozzle blob / residue detection capability | | 14 | `is_support_upgrade_kit` | Supports hardware upgrade kit (e.g. P1S+) | | 28 | `is_support_internal_timelapse` | Internal (eMMC) timelapse storage | | 31 | `is_support_brtc` | TCP `:6000` file-transfer tunnel (`ft_*` ABI) and `brtc://` URLs | | 32 | `m_is_support_mqtt_homing` | MQTT-based axis homing command support *(DevAxis)* | | 38 | `m_is_support_mqtt_axis_ctrl` | MQTT-based manual axis movement *(DevAxis)* | | 39 | `m_support_mqtt_bet_ctrl` | MQTT-based bed temperature control | | 40 | `m_calib.support_new_auto_calib` | New auto-calibration flow *(DevCalib)* | | 42 | `m_spaghetti_detection.is_support` | AI spaghetti detection capability *(DevPrintOptions)* | | 43 | `m_purgechutepileup_detection.is_support` | Purge-chute pileup detection capability | | 44 | `m_nozzleclumping_detection.is_support` | Nozzle clumping detection capability | | 45 | `m_airprinting_detection.is_support` | Air-printing (extrusion in empty space) detection | | 46 | `SetSupportCoolingFilter` | Cooling filter hardware present *(DevFan)* | | 48 | `is_support_ext_change_assist` | External spool change assistant | | 49 | `is_support_partskip` | Part-skip (selective object cancellation) | | 60 | `SetSupportNozzleRack` | Multi-nozzle rack hardware *(DevNozzleSystem)* | | 62 | `m_idel_heating_protect_detection.is_support` | Idle heating protection (auto-cooldown) | Bits not read by Studio: 0, 3–5, 15–27, 29–30, 33–37, 41, 47, 50–59, 61, 63. ###### `print.fun2` — hex string, variable length Parsed via `get_flag_bits_no_border` which handles arbitrarily long hex strings. | Bit | Studio variable | Description | |-----|-----------------|-------------| | 0 | `is_support_print_with_emmc` | Can print from eMMC without SD card | | 2 | `m_buildplate_align_detection.is_support` | Buildplate offset alignment detection *(DevPrintOptions)* | | 3 | `is_support_pa_mode` | Pressure-advance tuning mode | | 4 | `m_purify_air_at_print_end.is_support` | Air purification after print *(DevPrintOptions)* | | 5 | `is_support_remote_dry` | Remote filament drying command | | 6 | `is_support_update_remain_hide_display` | Can hide remaining-time display | | 7 | `m_firmware_support_print_tpu_left` | TPU-left-in-extruder print support (conditional on printer type) | | 8 | `is_support_active_arc_fitting` | Arc-fitting (G2/G3) motion planning | | 13 | `m_fod_check_detection.is_support` | Foreign-object-debris detection *(DevPrintOptions)* | | 14 | `m_displacement_detection.is_support` | Layer displacement detection *(DevPrintOptions)* | | 17 | `is_support_model_internal_storage` | Internal model-cache storage tab | | 19 | `is_support_check_track_switch_match_slice_printer` | AMS track-switch / slicer-printer match check | | 21–22 | `ams_preload_version` | AMS preload protocol version (2-bit field: 0–3) | ###### `home_flag` — integer (decimal) A single integer read by three independent parsers. Reported in legacy (non-NP) `push_status`. **DevAxis** (`DevAxis.h`) — homing state: | Bit | Field | Description | |-----|-------|-------------| | 0 | X homed | X axis is at home position | | 1 | Y homed | Y axis is at home position | | 2 | Z homed | Z axis is at home position | **DevPrintOptions** (`DevPrintOptions.cpp:ParseDetectionV1_0`) — detection toggles: | Bit | Field | Description | |-----|-------|-------------| | 4 | `auto_recovery` current value | Auto-recovery-on-step-loss enabled | | 17 | `prompt_sound` current value | Completion/error beep enabled | | 18 | `prompt_sound` is_support | Firmware supports beep toggle | | 19 | `filament_tangle` is_support | Firmware supports tangle detection | | 20 | `filament_tangle` current value | Tangle detection enabled | | 24 | `nozzle_blob` current value | Nozzle blob detection enabled | | 25 | `nozzle_blob` is_support | Firmware supports blob detection | **MachineObject** (`DeviceManager.cpp:parse_home_flag`) — capabilities & state: | Bit | Field | Description | |-----|-------|-------------| | 3 | `is_220V_voltage` | 220 V mains (same as `fun` bit 2) | | 5 | `camera_recording` | Camera is currently recording | | 7 | AMS detect-remain | AMS remaining-filament detection enabled | | 8–9 | SD card state | `SdcardState` enum (2-bit field, see below) | | 10 | AMS auto-refill | AMS automatic filament refill enabled | | 15 | `is_support_flow_calibration` | Flow calibration support (force-disabled on O-series) | | 16 | `is_support_pa_calibration` | PA calibration support (force-disabled on P-series) | | 21 | `is_support_motor_noise_cali` | Motor noise calibration support | | 22 | `is_support_user_preset` | User-preset storage support | | 26 | `installed_upgrade_kit` | Upgrade kit physically installed | | 27 | `is_support_upgrade_kit` | Upgrade kit supported by hardware | | 28 | `ams_air_print_status` | AMS air-print detection status | | 29 | `is_support_air_print_detection` | Air-print detection support (disabled for AMS2/AMSHT firmware) | | 30 | `is_support_agora` | Agora video support (same as `fun` bit 1) | **`SdcardState` enum** (`DevStorage.h`): | Value | Name | Meaning | |-------|------|---------| | 0 | `NO_SDCARD` | No SD card inserted | | 1 | `HAS_SDCARD_NORMAL` | SD card present, healthy | | 2 | `HAS_SDCARD_ABNORMAL` | SD card present, errors detected | | 3 | `HAS_SDCARD_READONLY` | SD card present, read-only (write-protected or full) | ###### NP (New Protocol) bitmask fields NP is activated when the printer sends all four hex-string fields: `cfg`, `fun`, `aux`, `stat` in `push_status.print`. When NP is active, legacy integer fields like `home_flag` and `xcam.cfg` are **not** sent; their information is encoded in the NP fields instead. The hex-bitmask nature of all four fields is independently confirmed in [issue #49](https://github.com/ClusterM/open-bambu-networking/issues/49) (symbol-bearing `.so` rodata + live `02.06.00.50` / `02.07` captures). > **`support_*` capability map — flow→PA mapping quirk.** Alongside the NP bitmasks, `push_status` carries a `support_*` boolean capability map plus AMS per-tray `state`, `DevInf → connection_name`, and a (usually blanked) `dev_signal`. When decoding `support_*`, note that the **flow-calibration** capability and the **pressure-advance (PA)** capability are cross-wired in at least one firmware generation (a `support_flow_*` flag actually gates PA, or vice-versa); do not assume the flag name maps 1:1 to the feature. Flagged in [issue #49](https://github.com/ClusterM/open-bambu-networking/issues/49); decode against live hardware before gating UI on it. `upgrade_state.new_ver_list[]` / `sw_new_ver` are the source `get_printer_firmware` re-synthesises "available update" state from (per-entry shape confirmed in #49). **`print.cfg`** — hex string, configuration/settings state: | Bit | Field | Description | |-----|-------|-------------| | 0 | AMS detect-on-insert | AMS detects filament on tray insertion | | 1 | AMS detect-on-powerup | AMS detects filament on power-up | | 3 | `camera_recording_when_printing` | Camera records during print | | 4 | camera resolution | 0 = 720p, 1 = 1080p | | 5 | `camera_timelapse` | Timelapse recording enabled | | 6 | `tutk_state` | 1 = TUTK disabled | | 7 | chamber light | 1 = on, 0 = off *(DevLamp)* | | 8–10 | speed level | Print speed level (3-bit, `DevPrintingSpeedLevel`) *(DevPrintOptions)* | | 12 | first-layer inspection | First-layer inspector enabled *(DevPrintOptions)* | | 13–14 | AI monitoring sensitivity | 0 = never halt, 1 = low, 2 = medium, 3 = high *(DevPrintOptions)* | | 15 | AI monitoring | AI print monitoring enabled *(DevPrintOptions)* | | 16 | auto recovery | Auto recovery on step-loss enabled *(DevPrintOptions)* | | 17 | AMS detect-remain | AMS remaining-filament detection enabled | | 18 | AMS auto-refill | AMS automatic refill enabled | | 19 | `xcam__save_remote_print_file_to_storage` | Save cloud-print file to local storage | | 20–21 | `xcam_door_open_check` | Door-open check state (2-bit, `DoorOpenCheckState`) | | 22 | prompt sound | Completion/error beep enabled *(DevPrintOptions)* | | 23 | filament tangle | Tangle detection enabled *(DevPrintOptions)* | | 24 | nozzle blob | Nozzle blob detection enabled *(DevPrintOptions)* | | 25 | `installed_upgrade_kit` | Upgrade kit installed | | 32–33 | idle heating protect | Idle heating protection level (2-bit) *(DevPrintOptions)* | | 36–37 | purify air | Air purification after print (2-bit) *(DevPrintOptions)* | | 38–39 | snapshot detection | Snapshot detection state (2-bit) *(DevPrintOptions)* | | 42 | `is_support_liveview_preview` | Liveview preview image support | **`print.aux`** — hex string, auxiliary state: | Bit | Field | Description | |-----|-------|-------------| | 12–13 | SD card state | `SdcardState` enum (2-bit, same values as `home_flag` bits 8–9) | | 26 | `m_has_timelapse_kit` | External timelapse hardware kit present | **`print.stat`** — hex string, runtime status: | Bit | Field | Description | |-----|-------|-------------| | 7 | `camera_recording` | Camera is currently recording | | 36 | lamp close recheck | Chamber light close-recheck flag *(DevLamp)* | ###### `print.flag3` — integer (decimal) | Bit | Studio variable | Description | |-----|-----------------|-------------| | 3 | `is_support_filament_setting_inprinting` | Filament settings editable mid-print | | 9 | `is_enable_ams_np` | AMS New Protocol enabled | | 10–12 | E3D flow type | Nozzle flow-type indicator (3-bit, passed to `DevNozzleSystemParser`) | | 13 | `is_support_fila_change_abort` | Filament-change abort command supported | | 16 | `is_support_ext_change_assist_old` | External spool change assistant (legacy flag, see also `fun` bit 48) | | 17 | `is_support_filament_32_colors` | 32-color filament mapping support | ###### `print.xcam.cfg` — integer (decimal, **not** hex) AI detection settings with per-feature enable + sensitivity. Only present in legacy (non-NP) payloads. Each detection feature uses a 1-bit enable followed by a 2-bit sensitivity level. | Bit | Field | Description | |-----|-------|-------------| | 7 | spaghetti enable | Spaghetti detection on/off | | 8–9 | spaghetti sensitivity | 0 = low, 1 = medium, 2 = high | | 10 | pileup enable | Purge-chute pileup detection on/off | | 11–12 | pileup sensitivity | 0 = low, 1 = medium, 2 = high | | 13 | clumping enable | Nozzle clumping detection on/off | | 14–15 | clumping sensitivity | 0 = low, 1 = medium, 2 = high | | 16 | air-print enable | Air-printing detection on/off | | 17–18 | air-print sensitivity | 0 = low, 1 = medium, 2 = high | | 20 | buildplate align | Buildplate offset alignment on/off | | 21 | FOD check | Foreign-object-debris check on/off | | 22 | displacement | Layer displacement detection on/off | When `xcam.cfg` is present, `m_ai_monitoring_detection.is_support_detect` is set to `true` unconditionally. Legacy protocol also reads `xcam.printing_monitor` (bool) and even older `xcam.spaghetti_detector` (bool) + `xcam.print_halt` (bool) as fallbacks. Other legacy `xcam.*` JSON fields: | Field | Type | Description | |-------|------|-------------| | `xcam.halt_print_sensitivity` | string | AI monitoring sensitivity override (`"low"` / `"medium"` / `"high"`) | | `xcam.first_layer_inspector` | bool | First-layer inspector enabled | | `xcam.buildplate_marker_detector` | bool | Buildplate marker detection (also sets `is_support_detect`) | ###### `ipcam.*` — camera configuration (JSON object) | Field | Type | Description | |-------|------|-------------| | `ipcam.ipcam_record` | string | `"enable"` / `"disable"` — record during print | | `ipcam.timelapse` | string | `"enable"` / `"disable"` — timelapse recording | | `ipcam.ipcam_dev` | string | `"1"` = camera present | | `ipcam.resolution` | string | Current resolution (`"720p"`, `"1080p"`) | | `ipcam.resolution_supported` | string[] | List of supported resolutions | | `ipcam.liveview.local` | string | `"none"` / `"disabled"` / `"local"` / `"rtsps"` / `"rtsp"` | | `ipcam.liveview.remote` | string | `"none"` / `"tutk"` / `"agora"` / `"tutk_agaro"` | | `ipcam.file.local` | string | `"none"` / `"local"` | | `ipcam.file.remote` | string | `"none"` / `"tutk"` / `"agora"` / `"tutk_agaro"` | | `ipcam.file.model_download` | string | `"enabled"` / `"disabled"` | | `ipcam.virtual_camera` | string | `"enabled"` / `"disabled"` | | `ipcam.rtsp_url` | string | Local RTSP URL (overrides `liveview.local` when present) | | `ipcam.tutk_server` | string | TUTK server state (`"disable"` etc.) | ###### `lights_report[]` — chamber light state (JSON array) Each entry has `node` (string) and `mode` (string). Studio reads `node == "chamber_light"` and parses `mode` as `"on"` / `"off"` / `"flashing"`. ###### `online.*` — connectivity state (JSON object) | Field | Type | Description | |-------|------|-------------| | `online.ahb` | bool | AHB (cloud heartbeat) connected | | `online.rfid` | bool | RFID reader online | | `online.version` | int | Online protocol version number | ###### `hms[]` — Health Management System (JSON array) Each entry has `attr` (unsigned int) and `code` (unsigned int). **`attr` bitmask:** | Bits | Field | Description | |------|-------|-------------| | 31–24 | `module_id` | Module identifier (`ModuleID` enum, 0x00–0x0F) | | 23–16 | `module_num` | Module instance number | | 15–8 | `part_id` | Part identifier within module | | 7–0 | `reserved` | Reserved | **`code` bitmask:** | Bits | Field | Description | |------|-------|-------------| | 31–16 | `msg_level` | `HMSMessageLevel`: 1 = Fatal, 2 = Serious, 3 = Common, 4 = Info | | 15–0 | `msg_code` | Error/warning code | **`ModuleID` enum values:** 0x03 = MC, 0x05 = Mainboard, 0x07 = AMS, 0x08 = TH, 0x0C = XCam. The long error code is formatted as `MMNNPPRRLLCCCC` (hex) where MM = module_id, NN = module_num, PP = part_id, RR = reserved, LL = msg_level, CCCC = msg_code. ###### `upgrade_state.*` — firmware upgrade (JSON object) Studio reads `upgrade_state.ams_new_version_number` (string, not used in NP mode) and `upgrade_state.ahb_new_version_number` (string) to detect available firmware updates. Detailed upgrade parsing is handled by `DevUpgrade`. ###### `support_*` JSON booleans These are standalone JSON fields in `push_status.print`, separate from the bitmask fields. They provide an alternative way for firmware to advertise capabilities (some overlap with `fun`/`fun2`/`home_flag` bits). | JSON field | Studio variable | Description | |------------|-----------------|-------------| | `support_tunnel_mqtt` | `is_support_tunnel_mqtt` | MQTT tunnel for video (disabled when `is_support_agora` is set) | | `support_flow_calibration` | `is_support_pa_calibration` | **Note:** despite the name, Studio writes this to `is_support_pa_calibration` (probable naming error) | | `support_send_to_sd` | `is_support_send_to_sdcard` | "Send to Printer" feature available | | `support_filament_backup` | `is_support_filament_backup` | Filament backup/spool-data storage | | `support_update_remain` | `is_support_update_remain` | Remaining-time update display (force-enabled for AMS2/AMSHT) | | `support_bed_leveling` | `is_support_bed_leveling` | Bed leveling calibration (int, not bool) | | `support_ams_humidity` | `is_support_ams_humidity` | AMS humidity display | | `support_1080dpi` | `is_support_1080dpi` | 1080p camera resolution | | `support_cloud_print_only` | `is_support_cloud_print_only` | Printer only accepts cloud prints | | `support_command_ams_switch` | `is_support_command_ams_switch` | AMS switch MQTT command | | `support_mqtt_alive` | `is_support_mqtt_alive` | MQTT keep-alive mechanism | | `support_motor_noise_cali` | `is_support_motor_noise_cali` | Motor noise calibration | | `support_timelapse` | `is_support_timelapse` | Timelapse recording | | `support_user_preset` | `is_support_user_preset` | User preset storage | | `support_refresh_nozzle` | `is_support_refresh_nozzle` | Nozzle refresh command | | `support_build_plate_marker_detect` | `m_buildplate_mark_detection.is_support` | Buildplate marker detection *(DevPrintOptions)* | | `support_build_plate_marker_detect_type` | `m_plate_maker_detect_type` | Buildplate marker detection type (int enum) *(DevPrintOptions)* | | `support_auto_recovery_step_loss` | `m_auto_recovery_detection.is_support` | Auto-recovery on step loss *(DevPrintOptions)* | | `support_prompt_sound` | `m_allow_prompt_sound_detection.is_support` | Prompt sound toggle *(DevPrintOptions)* | | `support_filament_tangle_detect` | `m_filament_tangle_detection.is_support` | Filament tangle detection *(DevPrintOptions)* | ###### Notes on overlap and quirks - **Legacy vs NP duplication:** Some capabilities are reported in both legacy fields and NP fields. For example, `home_flag` bit 3 = `fun` bit 2 = `is_220V_voltage`. When NP is active, `home_flag` is not sent; the same information is encoded in `cfg`/`aux`/`stat` instead. - **`support_flow_calibration` naming error:** The JSON field `support_flow_calibration` is mapped to `is_support_pa_calibration` in Studio, not `is_support_flow_calibration`. This appears to be a copy-paste error that was never corrected. - **Series-specific overrides:** `is_support_flow_calibration` is force-disabled for O-series (H2D), and `is_support_pa_calibration` is force-disabled for P-series, regardless of what the firmware reports. - **AMS firmware overrides:** `is_support_air_print_detection` is force-disabled when AMS2/AMSHT firmware is active. `is_support_update_remain` is force-enabled for the same firmware. **What "brtc" means here.** In Studio sources the name covers two layers that appeared together (~2025-10, commits `76e45bde2` / `662b7fdac`, §6.14.3): 1. **Upload wire** — `FileTransferTunnel` / `ft_tunnel_*` on TCP port **`:6000`** (LAN IP or TUTK relay), chunked `cmd_type=5` (§6.14.2). 2. **Print-start URL** — MQTT `project_file` with `url` like `brtc://emmc/` so firmware resolves the file in model cache (§6.8.2 **URL schemes**). P2S-class printers set **`is_support_brtc=true`**; classic LAN printers often do not and keep the FTPS **`SendJob`** fallback. ##### `SendToPrinterDialog` branch (uses `is_support_brtc`, not `PrintJob`) When the user clicks **Send** in Send to Printer, Studio picks the upload channel **before** any `bambu_network_start_*` call: ```mermaid flowchart TD S[SendToPrinterDialog::send_job] --> B{is_support_brtc AND\nm_tcp_try_connect OR m_tutk_try_connect?} B -->|yes| F["CreateUploadFileJob → ft_tunnel_* + ft_job cmd_type=5\n(:6000 cache upload, §6.14.2)"] B -->|no| L["SendJob → start_send_gcode_to_sdcard\n(full .3mf over FTPS, legacy)"] L --> C{LAN-only printer?} C -->|yes| P["SendJob check mode first\n(project_name=verify_job probe)"] C -->|no| R["SendJob start"] ``` On dialog open, brtc printers **prefer TCP/TUTK** over FTP for the connection handshake (`SendToPrinter.cpp:1304-1342`): if `is_support_brtc && !m_ftp_try_connect`, Studio calls `GetConnection()` instead of showing the old FTP-only ready state. **Contrast with `PrintJob` LAN preflight** (`PrintJob.cpp:224-252`): always runs **`start_send_gcode_to_sdcard(verify_job)`** (FTPS write probe). Optionally **also** opens a `:6000` tunnel when `could_emmc_print` (`is_support_print_with_emmc`), but that flag is independent of `is_support_brtc`. Normal slice→print ends in **`start_local_print`** / hybrid cloud paths — **not** the Send-to-Printer `ft_*` pipeline (see decision tree below). ##### Decision tree: `PrintJob::process()` (`PrintJob.cpp:149-624`) **Note:** The Send-to-Printer mermaid above is upload-only. Print jobs use the tree below. Studio sets `try_emmc_print = could_emmc_print` for **every** print path the same way; which LAN transport the **stock plugin** then uses for the model body is decided **inside the plugin by ABI entry point**, not by that flag alone (wire-confirmed 2026-07 — see §6.8.1.2). After `SelectMachineDialog::on_send_print()` exports the plate `.3mf` (and, for cloud-bound printers, a config `.3mf`), it fills `PrintParams` and starts the background `PrintJob`. ```mermaid flowchart TD subgraph pre [Preflight — LAN normal print only] A["connection_type==lan && from_normal"] --> B["FileTransferTunnel :6000 sync_connect\n(could_emmc_print)"] A --> C["start_send_gcode_to_sdcard\nproject_name=verify_job"] B --> D{emmc_ok OR ftp_ok?} C --> D D -->|no| E["abort: invalid access code"] end D -->|yes| F{m_print_type?} F -->|from_sdcard_view| G["start_sdcard_print"] F -->|from_normal| H{connection_type?} H -->|lan| I{has_sdcard OR could_emmc_print?} I -->|yes| J["start_local_print"] I -->|no| K["abort: no storage"] H -->|cloud / farm| L{lan_mode_only?} L -->|yes| M["start_local_print_with_record"] L -->|no| N{IP+password+has_sdcard?} N -->|yes| O["start_local_print_with_record"] O -->|fail| P["start_print — cloud fallback"] N -->|no| P ``` **`connection_type` is not “LAN reachable”.** It is the printer’s own mode advertisement, carried in the SSDP header `devconnect.bambu.com` and forwarded by the plugin as `connect_type` (`src/ssdp.cpp` → `DeviceManager::on_machine_alive`). Studio stores it on `MachineObject` (`DevInfo::SetConnectionType`); `connection_type()` / `is_lan_mode_printer()` just read that field (`DevInfo.cpp`). | Value | Meaning | Typical Studio behaviour when printing | |---|---|---| | `"lan"` | Printer is in **LAN Only Mode** (cloud disabled on the device) | Opens LAN MQTT via `connect_printer`; print via `start_local_print` | | `"cloud"` | Printer is cloud-paired / not in LAN Only | Selects device via `set_user_selected_machine`; print prefers `start_local_print_with_record` when LAN credentials exist, else `start_print` | | `"farm"` | Farm mode (treated like LAN for path selection; SSDP may rewrite farm→lan) | Same family as LAN | A cloud-paired printer can still be on the same LAN (SSDP IP + access code known) while `connection_type` remains `"cloud"`. Access code does **not** set `connection_type` — it only enables LAN MQTT/FTPS/:6000 once Studio (or the plugin) decides to use the LAN channel. **Inputs Studio fills into `PrintJob` before the tree above** (`SelectMachine.cpp:3133-3178`): | Field | Source | Role in the tree | |---|---|---| | `connection_type` | `MachineObject::connection_type()` (SSDP / bind) | Top-level LAN vs cloud branch | | `cloud_print_only` | MQTT/capability `support_cloud_print_only` | Forces pure `start_print` (skips LAN-with-record) | | `has_sdcard` | `DevStorage::HAS_SDCARD_NORMAL` | Required for LAN-with-record upload storage | | `could_emmc_print` | `is_support_print_with_emmc` | Allows pure LAN print without SD; copied into `try_emmc_print` for the plugin | | `dev_ip` / `password` | SSDP IP + access code (UI / cloud `dev_access_code`) | Without both, Studio skips LAN-with-record | | `lan_mode_only` | Studio `app_config` | Force LAN-with-record even for cloud-typed devices | **ABI entry points the tree resolves to:** 1. `bambu_network_start_local_print` — pure LAN (`connection_type=="lan"`). No cloud `/my/task`. 2. `bambu_network_start_local_print_with_record` — hybrid / “LAN with cloud record” (`mode=lan_file` on `/my/task`). Used for cloud-typed printers when IP+code+storage are available. 3. `bambu_network_start_print` — pure cloud (`mode=cloud_file`). Either immediately (missing LAN prerequisites / `cloud_print_only`) or as Studio’s **own** fallback when `_with_record` returns `< 0`. ##### Three upload/print channels Studio actually uses | Channel | Who calls it | Upload transport | Print start | Starts a print? | |---|---|---|---|:---:| | **`bambu_network_start_*`** | `PrintJob` (print paths), `SendJob` (Send-to-Printer fallback only) | Inside plugin (stock: S3 ± FTPS / `:6000` by entry point — §6.8.1.2) | Pure LAN: plugin MQTT `project_file`. Cloud / hybrid: `POST /my/task` (cloud dispatch); upload-only / probe: none | PrintJob yes; SendJob / verify_job no | | **`ft_*` ABI** | `SendToPrinterDialog`, `FileTransferTunnel` in `PrintJob` preflight | Studio → `ft_tunnel_*` + `ft_job_create` / `ft_tunnel_start_job` (`FileTransferUtils.cpp`) | **Never** — upload completes with `get_send_finished_event()` | No | | **`bambu_network_send_message_to_printer`** | `MachineObject::publish_json()` | N/A (opaque JSON passthrough) | Timelapse preflight, AMS queries, user commands — **not** the print job itself | No | **Important:** On **brtc-capable** printers (P2S, etc.) the **Send to Printer** dialog uploads the `.3mf` via **`ft_*` `cmd_type=5`** when `MachineObject::is_support_brtc` and the dialog connected over LAN TCP or TUTK (`SendToPrinter.cpp:947-962`). That path does not call `start_send_gcode_to_sdcard` for the model. **`start_send_gcode_to_sdcard`** is still the FTPS upload ABI; on current Studio it is mostly invoked with **`project_name="verify_job"`** (write probe) plus a rare **`SendJob` fallback** when `!is_support_brtc` (`SendToPrinter.cpp:977-1027`). The plugin must **not** treat `"verify_job"` as magic — it is only the destination filename Studio set (§6.14.3). ##### Scenario reference | Scenario | Plugin calls (in order) | Required for firmware to start printing | Tracking / UI only | |---|---|---|---| | **LAN print** (`connection_type=="lan"`, normal) | Preflight: optional `:6000` tunnel test + `start_send_gcode_to_sdcard(verify_job)` → **`start_local_print`** | Plugin LAN upload + MQTT `project_file` (§6.8.2). On P2S stock: model over **`:6000`**, URL `brtc://emmc/…` (§6.8.1.2) | Preflight probes; `update_fn` progress | | **Cloud print** (no LAN credentials) | **`start_print`** | Cloud S3 upload of config + full `.3mf` + `POST /my/task` (`mode=cloud_file`); **cloud** dispatches the print (no LAN model upload) | `wait_fn` waits for `job_id` / printing status; `Record` stage = cloud task metadata | | **Hybrid** (cloud device + LAN IP/code) | **`start_local_print_with_record`** → on failure **`start_print`** | Stock ABI name is misleading: dual FTPS+S3 upload, final project URL is **S3**, printer **re-downloads from cloud** (`mode=lan_file` — §6.8.1 / §6.8.1.2). Not a real LAN print. | `wait_fn`; Studio download % after start; `params.comments` on fallback | | **`lan_mode_only` config** | **`start_local_print_with_record`** only | Same hybrid path as above | Same as hybrid | | **Print existing file** (`from_sdcard_view`) | **`start_sdcard_print`** | MQTT `project_file` with `url=file:///` | UI labels it “cloud service” but plugin call is the same ABI | | **Upload only** (Send to Printer, brtc printers) | `ft_tunnel_*` + `ft_job` **`cmd_type=7`** → **`cmd_type=5`** | Nothing | Progress via `ft_job` `msg_cb` | | **Upload only** (Send to Printer, **no** `is_support_brtc`) | `verify_job` probe → **`SendJob`** → **`start_send_gcode_to_sdcard`** (full `.3mf` over FTPS) | Nothing | Legacy fallback; not P2S | ##### Before `PrintJob` — Studio-side steps (no print ABI) These run in `SelectMachineDialog` **before** any `bambu_network_start_*` call: 1. **Export plate `.3mf`** — `Plater::send_gcode()` packs the sliced plate (`on_send_print`, `SelectMachine.cpp:3005`). 2. **Export config `.3mf`** — `Plater::export_config_3mf()` for **non-LAN** printers only (`SelectMachine.cpp:3026`) — feeds cloud `create_task` / project APIs inside `start_print` / `_with_record`. 3. **AMS / nozzle mapping** — MQTT queries on `MachineObject` (e.g. `get_auto_nozzle_mapping`); results copied into `PrintParams.ams_mapping*` / `nozzle_mapping`. Not part of the print-plugin ABI. 4. **Timelapse storage preflight** (optional) — when timelapse is enabled and `is_support_internal_timelapse`, Studio sends `camera.ipcam_get_media_info` via **`bambu_network_send_message_to_printer`** (LAN) or **`bambu_network_send_message`** (cloud) **before** `on_send_print()` (§6.8.3). Failure or timeout → print proceeds anyway. ##### Callback contract Studio passes into every print job All five `bambu_network_start_*` symbols share the same callback typedefs (§6.8 above). Studio builds them in `PrintJob::process()` (`PrintJob.cpp:405-548`): | Callback | Passed to | Purpose | |---|---|---| | **`OnUpdateStatusFn update_fn`** | All print paths except `verify_job` preflight (`nullptr`) | Drive progress bar + status text. **`stage`** = `SendingPrintJobStage`; **`code`** = 0–100 upload percent when `stage==Upload` or `Record`; negative / `>100` = error code (`BAMBU_NETWORK_ERR_*`). **`info`** = auxiliary string (upload size hint, or countdown seconds on `Finished`). | | **`WasCancelledFn cancel_fn`** | All paths that show cancel | Polls `PrintJob::was_canceled()`. Plugin must abort and return `BAMBU_NETWORK_ERR_CANCELED`. | | **`OnWaitFn wait_fn`** | **`start_print`**, **`start_local_print_with_record`** only | **Not** passed to `start_local_print`, `start_sdcard_print`, or `start_send_gcode_to_sdcard`. After plugin returns success, plugin may invoke `wait_fn(state, job_info_json)`; Studio parses `job_id` and polls **`MachineObject::job_id_`** / **`print_status`** for up to `PRINT_JOB_SENDING_TIMEOUT` seconds (`PrintJob.cpp:497-547`). Pure LAN print skips this — Studio navigates away on `Finished` alone. | **Stage → progress bar mapping** (`PrintJob.cpp:392-399`): `Create=20%`, `Upload=30%`, `Waiting=70%`, `Record=75%`, `Sending=97%`, `Finished=100%`. Upload/Record sub-percent interpolates from `code`. **Success contract:** plugin returns `0` and fires `update_fn(PrintingStageFinished, 0, "")` where `` is the post-send countdown (stock uses `"3"`). Studio then posts `get_print_finished_event()` and jumps to the device page. ##### After the job — monitoring (separate from print submission) Print submission and ongoing status are **different ABI groups**: | Need | Studio mechanism | Plugin ABI | |---|---|---| | Live progress, temperatures, HMS | `MachineObject` state updated from MQTT **`push_status`** | **`bambu_network_connect_printer`** + **`set_on_printer_connected_fn`** / **`set_on_message_fn`** (LAN), or cloud **`set_user_message_fn`** + implicit subscribe from **`get_user_print_info`** | | Match cloud task to printer | `wait_fn` + `job_id_` from `push_status` | Only during `start_print` / `_with_record` | | Thumbnail / subtask panel | **`bambu_network_get_subtask_info`** | After print starts; unrelated to `start_*` return | | Cancel / pause | `MachineObject::command_task_*` → **`send_message_to_printer`** | Not via print-job API | The LAN MQTT session is opened through **`connect_printer`** — not inside `PrintJob` — and `project_file` is published on that **already-connected** session (`send_message_to_printer` under the hood on LAN). This is not limited to LAN-Only devices: for a **cloud-paired** printer the stock stack still brings up a LAN session (using the IP from SSDP and the `dev_access_code` fetched from the cloud REST API, §6.4.1), which is the same session the LAN-priority report subscription in §6.3 uses. So "has a LAN session" and "is cloud-paired" are orthogonal — a cloud device can have both transports live at once. ##### `PrintParams` fields Studio sets vs what the plugin must consume Studio fills `PrintParams` in `PrintJob::process()` (`PrintJob.cpp:214-386`) from the select-machine dialog checkboxes and `MachineObject` capabilities. Fields that **directly affect the print wire** end up in MQTT `project_file` (§6.8.2) or the cloud REST bodies (§6.8.1). Fields Studio sets but **does not embed in `project_file`**: `nozzles_info`, `ams_mapping_info`, `extra_options`, `task_ext_change_assist`, `try_emmc_print`, `comments` — they feed cloud task creation or diagnostics only. Notable `PrintParams` cases: - **`project_name="verify_job"`** — Studio's name for the FTPS write probe (`PrintJob.cpp:236`, `SendJob.cpp:134`). Same ABI as any other upload; not a separate plugin code path. - **`print_type=="from_sdcard_view"`** — sets **`dst_file`** instead of local **`filename`**; triggers **`start_sdcard_print`** only. - **`ftp_folder`** — passed through but **never assigned by Studio** in the public tree (`PrintJob.cpp:256` reads `obj_->get_ftp_folder()` which is usually empty); stock plugin uploads to FTPS root when empty. - **`try_emmc_print` / `could_emmc_print`** — Studio copies `could_emmc_print` into `try_emmc_print` for **all** print paths and uses it to gate the `:6000` **preflight** tunnel test. It does **not** appear on the MQTT wire. Stock’s choice of FTPS vs `:6000` for the **model body** is not “`try_emmc_print` ⇒ brtc”; it follows the ABI entry point (§6.8.1.2). Open-plugin mapping of each ABI entry point: [STATUS.md §6.8 — Open plugin: ABI → internal implementation](STATUS.md#open-plugin-abi--internal-implementation). #### 6.8.1. Cloud upload flow (stock plugin, MITM) Studio exposes two cloud-facing print entry points — `bambu_network_start_print` and `bambu_network_start_local_print_with_record`. Despite the name, **stock `_with_record` is not a real LAN print** on current plugin builds (P2S, Studio/plugin ~02.08; also reproduced on 02.04 — MITM + LAN pcap, 2026-07): it still uploads the **full** print-ready `.3mf` to S3, ends with a project URL that is **https (S3)**, and the printer **re-downloads from the cloud** after `/my/task`. The FTPS leg + first `ftp://` PATCH look like leftover “lan+record” staging that the second PATCH immediately undoes. §6.8.1.2. Open-plugin mapping of each ABI symbol is out of scope here — see [STATUS.md §6.8](STATUS.md#open-plugin-abi--internal-implementation). ##### Stock `start_local_print_with_record` — observed order (MITM + LAN) Exact HTTPS order from stock hybrid MITM (relative to `POST …/user/project`): 1. **`POST /v1/iot-service/api/user/project`** `{"name":""}` → `project_id`, `model_id`, `profile_id`, first presigned `upload_url`, `upload_ticket`. 2. **`PUT `** — small **config/profile** `.3mf` (history / preview; Studio’s `export_config_3mf`). 3. **`PUT /v1/iot-service/api/user/notification`** + poll `GET …/notification?action=upload&ticket=…` until `"message":"success"`. 4. **LAN FTPS `:990`** — full print-ready `.3mf` `STOR` to the printer (not visible in mitmproxy; ~1.2 MB on the LAN pcap). Stock uses **FTPS here, never `:6000`/brtc**, on this ABI. 5. **`PATCH …/user/project/`** — first project register, **`url":"ftp://.gcode.3mf"`** Example body: `{"profile_id":"…","profile_print_3mf":[{"md5":"…","plate_idx":1,"url":"ftp://….gcode.3mf"}]}` 6. **`GET /v1/iot-service/api/user/upload?models=__.3mf`** → second presigned URL for the **full** model. 7. **`PUT `** — **full** print-ready `.3mf` to S3 (~1.2 MB again — same file already STORed over FTPS). 8. **`PATCH …/user/project/`** — **second** register, **`url":"https://s3…/.3mf?…"`** (overwrites step 5). 9. **`POST /v1/user-service/my/task`** with **`mode":"lan_file"`** — print trigger; cloud dispatches to the printer. Plugin does **not** need a local MQTT `project_file` (§6.8.1.1). Pure **`start_print`** skips steps 4–5 (no FTPS, no `ftp://` PATCH); step 9 uses `mode":"cloud_file"`. Steps 2–3 / 6–8 are the same shape. **Why this is “fake” lan+record.** After step 9 the printer pulls the model from **cloud object storage** (Studio shows a post-start **download** percent). Blocking the printer’s WAN (while Studio↔API still works) fails the job (`fail_reason` / download errors such as `50348044`) even though FTPS already placed a copy. The early `ftp://` PATCH has **no lasting effect** once the S3 PATCH lands. Uploading only the full model to S3 + the https PATCH reproduces the same print start; the FTPS prologue is redundant for the body the firmware actually opens. **Config vs main (steps 2–3).** Skipping the small config upload still allows `/my/task` to start the print, but Print History is incomplete (no thumbnail — generic logo / “Gcode” badge). Stock always does 2–3. **Cloud-dispatched URL schemes.** For jobs started via `/my/task`, the project / task URL the cloud hands the printer appears limited to **`https://…` (S3)** and **`ftp://…`**. There is no evidence the cloud path accepts or forwards **`brtc://…`**; stock never registers a brtc URL on `_with_record` / `start_print`. BRTC remains a **pure-LAN MQTT** scheme (`start_local_print` only — §6.8.1.2). **Printer → cloud status after start.** Once the job is running, the **printer itself** keeps reporting print state upstream to Bambu’s cloud (almost certainly over the same cloud MQTT session the device already holds — `device//report` / related telemetry, not a slicer HTTPS poll). The slicer plugin does not have to push progress for these cloud-side features. Observed / product uses of that uplink include at least: - problem / HMS-style **notifications in the mobile app**; - keeping **Print History** status current (running / finished / canceled / failed); - **MakerWorld** prompts to rate a finished model after a successful cloud-linked print. Pure LAN-only prints (`start_local_print`, no `/my/task`) do not create that cloud task record, so those cloud UX hooks do not apply the same way. Terminology note: - ABI names still use `OSS` in several places (`bambu_network_get_oss_config`, `...UPLOAD_3MF_TO_OSS...` error codes), but the observed cloud print upload transport is a presigned object-storage `PUT` URL, not a fixed OSS endpoint. The print-upload presigned URLs are **S3 signature V2** query-auth (`?AWSAccessKeyId=…&Expires=…&Signature=…`) — **on-wire confirmed** against genuine `POST /v1/iot-service/api/user/project` traffic on a live account (`s3.us-west-2.amazonaws.com`, 2026-07). This is **not** SigV4 (no `X-Amz-Algorithm` / `X-Amz-Signature`); issue #48's SigV4 claim was **not reproduced** here and may be region- or snapshot-dependent. The **cloud presigns** the URL; the uploading client just does an opaque `PUT` of the body and never recomputes the signature. What the presigner signs (AWS S3 V2 query-string auth): ```py StringToSign = VERB + "\n" + # "PUT" Content-MD5 + "\n" + # usually empty Content-Type + "\n" + # EMPTY on the print-upload leg (see below) Expires + "\n" + # the ?Expires= epoch seconds CanonicalizedResource # "//" (+ any sub-resources) Signature = Base64(HMAC-SHA1(secret_access_key, StringToSign)) # URL query carries: AWSAccessKeyId, Expires, Signature (URL-encoded) ``` Protocol consequence: because the presigner signs an **empty `Content-Type`**, the client must **omit `Content-Type`** on the `PUT` — adding one changes the StringToSign and yields `403 SignatureDoesNotMatch`. The scheme is purely transport-level for this leg (no per-request client crypto). **Distinct leg:** the credential-based direct-OSS path used by rating-picture upload (`get_oss_config` → `put_rating_picture_oss`) *does* sign client-side, and *that* leg uses AWS SigV4 (S3) or Aliyun OSS V1 (CN) — see §6.12 `get_oss_config`. ##### 6.8.1.1. Cloud print authorization (OBN findings, on hardware 2026-07) Getting a **cloud** print (or "local print with record") to actually start from OBN on a stock, non-Developer-Mode P2S required more than the upload sequence above. The findings below were established on live hardware and drive OBN's current cloud-print implementation (`src/cloud_print.cpp`, `run_cloud_print_job`); the MVP is working end-to-end. **1. `POST /my/task` is the trigger, not the MQTT `project_file`.** For a cloud-connected printer the Bambu cloud dispatches the job to the printer itself the moment `/my/task` succeeds. OBN therefore **does not** publish an MQTT `project_file` on the cloud channel — doing so makes the printer reject the second, duplicate job with **`0500_4004`** ("The printer can't receive new print jobs while printing"). The MQTT `project_file` (§6.8.2) is the print trigger **only** on the pure-LAN path (`start_local_print`). **2. Required headers on `/my/task`** (beyond the `Authorization: Bearer` token): - **`X-BBL-Client-Name: BambuStudio`** and a matching **`X-BBL-OS-Type`** — mandatory; a wrong/missing value 403s. See the header block in §6.10.1 for the full write-up and the `client_name` config knob. - **`x-bbl-app-certification-id`** and **`x-bbl-device-security-sign`** — the HTTP half of command-security for a *secured* printer, signed with the user-supplied `slicer_key.pem`. Serialization: `x-bbl-app-certification-id = issuer + ":" + serial.lower()` (a **different** serialization from the MQTT `cert_id`, which is `serial + issuer` with no separator — getting this wrong 403s), and `x-bbl-device-security-sign = base64(RSA_PKCS1v15(app_priv, utf8(unix_ms)))` over the current time in **milliseconds** (13 digits; a nanosecond timestamp fails signature recency). Both are best-effort: absent a configured slicer key OBN omits them rather than sending blanks. See the *Command-security key & certificate flow* subsection later in §6.8 and [issue #47](https://github.com/ClusterM/open-bamboo-networking/issues/47). **3. `create_task` must hard-fail.** `/my/task` is what authorizes the printer to fetch the uploaded content, so OBN treats any non-2xx as fatal (`BAMBU_NETWORK_ERR_PRINT_WR_POST_TASK_FAILED`) instead of the old soft-fail that continued with `task_id=0` and stalled the printer on "failed to download". **4. The duplicate-submission pitfall (`start_local_print_with_record` → `start_print` fallback).** Studio's `PrintJob::process()` tries **`start_local_print_with_record` first** and only falls back to **`start_print`** if it returns `< 0`. Both entry points create a cloud task and hit `POST /my/task`, which is what actually dispatches the job. So a plugin that lets the "record" entry point create the cloud task **and then** return `< 0` (e.g. because a later LAN MQTT leg failed) triggers Studio's fallback into `start_print`, which fires a **second** `/my/task`. The printer, already printing the first job, then rejects the duplicate with `0500_4004` / `0300_400C` ("Printing was cancelled"). The invariant a print implementation must preserve: **exactly one `/my/task` per user action** — either succeed and return `0` from the first entry point, or fail *before* creating the cloud task so the fallback starts cleanly. **5. Studio-side crash caveat (not OBN).** After a print, if the printer reports **any** non-zero `print_error` (including a stale/latched one), Studio's `StatusPanel::update_error_message()` opens a `DeviceErrorDialog`, whose `RequestUserAttention → gtk_widget_get_realized` path **segfaults under GTK/Wayland** (`GLib-CRITICAL: Source ID … was not found`). This is a Studio/GTK bug, independent of the plugin; the workaround is to launch Studio with **`GDK_BACKEND=x11`**. ##### 6.8.1.2. Stock print transports: FTPS vs BRTC (wire-confirmed, P2S, 2026-07) Studio sets `try_emmc_print = could_emmc_print` the same way for LAN and hybrid; that flag alone does **not** select the model transport. Stock picks by **ABI entry point** (MITM + LAN pcap on P2S, 2026-07): | Studio / plugin entry | Cloud HTTPS | LAN model upload (stock) | Winning model URL / who prints | |---|---|---|---| | `bambu_network_start_local_print` | none | Full `.3mf` over **TLS `:6000` (brtc)** | LAN MQTT `project_file` with `brtc://emmc/` — **only** honest LAN path | | `bambu_network_start_local_print_with_record` | Config + **full** `.3mf` to S3; **two** project PATCHes (`ftp://` then **https S3**) | Full `.3mf` over **FTPS `:990` only** (never brtc on this ABI) | **Not LAN:** cloud `/my/task` `mode=lan_file` → printer **re-downloads S3**. Name “local … with record” is misleading on current stock. | | `bambu_network_start_print` | Config + full `.3mf` to S3; PATCH → S3 | none | Cloud `/my/task` `mode=cloud_file` → S3 download | **Stock hybrid always FTPS for the LAN side-trip.** On `_with_record`, stock does **not** use `:6000`/brtc for the model body — only FTPS — then still pushes the same file to S3. BRTC appears on **pure** `start_local_print` and on Send-to-Printer cache upload (§6.14), not on cloud-dispatched hybrid. **Cloud dispatch vs BRTC.** Cloud-started jobs (`/my/task`) appear to understand project URLs of **`https://…`** and **`ftp://…` only**. Stock never PATCHes or tasks a `brtc://` URL on the cloud path; putting the file only in the brtc cache while advertising `ftp://` or relying on cloud dispatch does not match stock and fails unless a same-named file already exists on the FTPS volume (cache coincidence). Firmware **on the printer** still resolves `brtc://emmc/` for **LAN MQTT** `project_file` (§6.8.2); that is a different channel than the cloud forwarder. **Two PATCHes are the smoking gun.** Captured bodies on the same `project_id`: 1. `"url":"ftp://….gcode.3mf"` 2. `"url":"https://s3.us-west-2.amazonaws.com/…/_1.3mf?…"` (wins) So stock performs a theatrical lan+record prologue, then converts the job into a cloud download. Whether this is an intentional product change or a regression is unclear from the wire; functionally **`start_local_print_with_record` is not a LAN print anymore**. **“Block cloud” vs Studio↔cloud MQTT.** Blocking the **printer’s** WAN while Studio still reaches `api.bambulab.com` does **not** make stock print the FTPS copy. Plugin returns success after FTPS+S3+`/my/task`; printer fails on cloud download. Only `start_local_print` keeps model + trigger entirely on LAN. #### 6.8.2. The MQTT `project_file` command (wire format) The print-job submission ends with a single MQTT command published to the printer's request channel (`device//request` on LAN, identical envelope on the cloud forwarder). The frame the firmware actually parses lives under the `print` object. The example below was captured on a real P2S running stock firmware paired with the stock Studio + plugin; X1 / P1S share the schema with a few extra optional members. ```json { "print": { "sequence_id": "1749823456789", "command": "project_file", "param": "Metadata/plate_1.gcode", "project_id": "0", "profile_id": "0", "task_id": "0", "subtask_id": "0", "subtask_name": "test", "file": "test.gcode.3mf", "url_enc": "bcXiq4/uHGqgb4DXVihrpQOR…", "md5": "7947606528CE6E00219496B51D5D13D1", "bed_type": "textured_plate", "bed_leveling": false, "flow_cali": false, "vibration_cali": false, "layer_inspect": true, "timelapse": true, "use_ams": true, "ams_mapping": [3,-1,-1], "ams_mapping2": [{"ams_id":0,"slot_id":3},{"ams_id":255,"slot_id":255},{"ams_id":255,"slot_id":255}], "auto_bed_leveling": 0, "cfg": "4", "extrude_cali_flag": 0, "extrude_cali_manual_mode":0, "nozzle_offset_cali": 2 } } ``` | Field | Type | Source in `PrintParams` (Studio -> ABI) | Notes | |---|---|---|---| | `sequence_id` | string (decimal) | See *MQTT `sequence_id`* below | Per-command unique id; the printer echoes it on the matching ack | | `command` | string | constant `"project_file"` | Selects the firmware handler. | | `param` | string | derived from `plate_index` | Always `Metadata/plate_.gcode`; resolves to a path inside the uploaded 3mf. | | `project_id`, `profile_id`, `task_id`, `subtask_id` | strings | cloud task IDs from `POST /v1/user-service/my/task` (cloud) or `"0"` placeholder (LAN / Developer Mode) | Sent as **strings**, not numbers. | | `subtask_name` | string | `project_name` (falls back to `task_name`) | Shown on the printer screen. | | `file` | string | `opts.file_path` | Basename or path of the `.3mf` on the printer side. Usually mirrors the trailing segment of `url` (without the scheme). See **URL schemes** below. | | `url` / `url_enc` | string | `opts.url` | Tells firmware **how to locate** the `.3mf`. Cleartext `url` uses one of several schemes (`ftp://`, `brtc://emmc/`, `file://`, presigned `https://…` for cloud); `url_enc` is the same URL RSA-encrypted with the **printer's device-cert public key** using **PKCS#1 v1.5** padding (direct RSA, no AES session-key wrap — the observed ≤~245-byte ciphertext equals the RSA-2048 PKCS#1 v1.5 ceiling of `256−11=245` and rules out OAEP-SHA1, which would cap at 214; independently cross-validated in [issue #47](https://github.com/ClusterM/open-bamboo-networking/issues/47), on-wire confirmation of the padding still pending). Non-Developer-Mode firmware requires `url_enc`; Developer Mode accepts plain `url`. Full scheme semantics: see **URL schemes** below. | | `md5` | string | `opts.md5` (uppercase hex) | Printer cross-checks the 3mf integrity before slicing it. | | `bed_type` | string | `task_bed_type` (defaults to `"auto"`) | One of the `MachineBedTypeString` values. | | `bed_leveling`, `flow_cali`, `vibration_cali`, `layer_inspect`, `timelapse`, `use_ams` | bool | matching `task_*` flags | Per-print toggles from the `SelectMachineDialog` checkboxes. | | `ams_mapping` | int array | `ams_mapping` (Studio passes a JSON array string `[0,-1,...]`) | Index = filament slot in the 3mf, value = AMS tray id (`-1` = no mapping, `255` = virtual tray). Legacy single-AMS shape. | | `ams_mapping2` | object array | `ams_mapping2` (Studio passes a JSON-array string of `{ams_id, slot_id}` objects) | Newer multi-AMS schema, one entry per filament. `255/255` means "external spool / not via AMS". Required for printers with >1 AMS unit; firmware that supports both fields prefers `ams_mapping2` over `ams_mapping`. **Note the cloud `POST /my/task` body uses a *different* serialization** — camelCase keys (`amsId`/`slotId`) and the external-spool sentinel is `{amsId:255,slotId:0}` (slot `0`, **not** `255`); the endpoint 400s on the wrong shape. Cross-validated in [issue #48](https://github.com/ClusterM/open-bamboo-networking/issues/48); our `/my/task` derive-from-flat path in `src/cloud_print.cpp` emits `slotId:0`. | | `nozzle_mapping` | int array | `nozzle_mapping` (Studio passes a JSON-array string of ints, e.g. `"[0,1]"`) | Index = filament slot in the 3mf, value = physical nozzle/extruder position id. **Emitted only on multi-extruder printers** — Studio gates every assignment to `task_nozzle_mapping` on `MachineObject::GetNozzleRack()->IsSupported()` (see `SelectMachine.cpp:3107` and `CalibUtils.cpp:2225`/`2358`), so on single-nozzle hardware (P2S, X1C, A1, etc.) the field is omitted entirely from `project_file`. The Studio-side value is sourced verbatim from the printer's reply to the `get_auto_nozzle_mapping` MQTT query (`DevMappingNozzle.cpp:277-282` deserialises the `mapping` field into `std::vector`), so the type is **always** a flat `int` array — no objects, no string-wrapped numbers, no `[0,-1,…]` sentinels analogous to `ams_mapping`. Stock-plugin observation: when Studio hands it a string with whitespace (e.g. `"[0,1, 2]"`), the stock plugin re-emits it as canonical `"[0,1,2]"`; this normalization is cosmetic and not required for firmware acceptance. | | `auto_bed_leveling` | int | `auto_bed_leveling` | Bed-leveling option as an int (0 = off, 1 = on, 2 = auto). Coexists with the boolean `bed_leveling`: the boolean is the user toggle, the int is the resolved policy after taking firmware capabilities into account. | | `nozzle_offset_cali` | int | `auto_offset_cali` (**name mismatch is intentional in upstream**) | Nozzle-offset calibration option (0 = off, 2 = auto). | | `extrude_cali_manual_mode` | int | `extruder_cali_manual_mode` (**`extrude` vs `extruder` asymmetry is intentional in upstream**) | PA calibration mode (0 = automatic, 1 = manual). **Field is gated on the value, not the ABI** — the stock plugin emits it only when `extruder_cali_manual_mode != -1` (the `PrintParams` default). With the default sentinel it is omitted entirely from `project_file`. Confirmed across ABI `02.05.00`, `02.05.03`, `02.06.01`. | | `cfg` | string (decimal int) | derived from `task_timelapse_use_internal` (other bits unknown) | Bitmask the stock plugin builds from `PrintParams` flags that don't have a dedicated MQTT field. **Bit 2 (`0x4`) = use internal storage for timelapse**, driven by `task_timelapse_use_internal` (`task_timelapse_use_internal=true` -> `"4"`, `false` -> `"0"`). Always emitted as a string, not a number. **Field is present in `project_file` for *every* observed ABI from `02.05.00` upwards** — but on builds older than `02.05.03` (where `PrintParams::task_timelapse_use_internal` does not exist yet) the value is permanently `"0"` and the plugin has no way to surface a `"4"`. Cross-ABI confirmation: `02.05.00`/`02.05.01`/`02.05.02` always emit `cfg="0"`; `02.05.03`/`02.06.00`/`02.06.01` emit `cfg="4"` when `task_timelapse_use_internal=true` and `cfg="0"` otherwise. **`task_record_timelapse` does NOT influence `cfg`** — only the `timelapse` boolean in the same payload. No other bit values have been observed in the wild yet — a future capture showing e.g. `"1"`, `"2"` or `"5"` would identify another flag's meaning. | | `extrude_cali_flag` | int | derived from `auto_flow_cali` | Stock-plugin-only field present in every observed `project_file`. **The value is taken straight from `PrintParams::auto_flow_cali`** and is a **multi-valued enum, not a bool**: `0` = off, `1` = requested, and `2` (= auto) has been observed in genuine traffic ([issue #48](https://github.com/ClusterM/open-bamboo-networking/issues/48)); further values may exist. OBN passes the int through verbatim, so no code change is needed. Captured Studio sessions almost always ship `auto_flow_cali=0`, which is why the field looks hardcoded at first glance. Likely a "PA cali requested" guard the firmware uses to short-circuit redundant calibration runs. | ##### MQTT `sequence_id` — per-command unique id (three assignment sources) `sequence_id` is the **unique identifier of a single MQTT command** — a decimal string the sender picks so the printer (and Studio) can match an outgoing request to its ack/report echo. It is **not** a session id and **not** reset on MQTT connect; only the assignment algorithm differs by who built the frame. The stock plugin **never rewrites** an id Studio already embedded. | Source | When | Algorithm | Typical value | Studio-side check | |--------|------|-----------|---------------|-------------------| | **Stock plugin** | The plugin **constructs the MQTT frame itself** (e.g. `project_file` from the print-job ABI, or any other command the plugin assembles before publish) | **Wall-clock epoch milliseconds** at generation time: `str(unix_epoch_ms)` (13-digit decimal string). A fresh value on every frame; **not** reset on MQTT connect/reconnect. | `"1749823456789"` | — | | **Bambu Studio** | Studio builds the JSON and hands it to the plugin via `bambu_network_send_message` / `send_message_to_printer`; the plugin forwards (and may sign) **without replacing** the id | **Process-wide monotonic counter:** `std::to_string(MachineObject::m_sequence_id++)`. Initialized once per Studio launch to **`STUDIO_START_SEQ_ID` = 20000**; increments for *every* Studio-originated command (`print`, `system`, `info`, `camera`, `pushing`, …). Valid range **20000–29999** (`STUDIO_END_SEQ_ID` = 30000 is the exclusive upper bound). **Never reset** on MQTT connect — only a Studio restart resets back to 20000. | `"20014"`, `"20021"`, … | `DevUtil::is_studio_cmd(seq)` → `seq >= 20000 && seq < 30000` *(AGPL: `DeviceCore/DevUtil.h`, `DeviceManager.hpp`)* | | **Cloud dispatch** | Commands injected by Bambu cloud MQTT (cloud print dispatch, cloud-side control) | **Constant `"0"`** (`CLOUD_SEQ_ID`) for every cloud-originated command. | `"0"` | `DevUtil::is_cloud_cmd(seq)` → `seq == 0` *(AGPL: `DeviceCore/DevUtil.h`)* | ##### Cloud-start extras in `project_file` (verify on stock firmware) The **cloud** variant of `project_file` (job started through `/my/task`, not LAN) carries extra fields beyond the table above, per genuine captures cross-validated in [issue #48](https://github.com/ClusterM/open-bamboo-networking/issues/48): - `job_id` — **int** (unlike `task_id`/`subtask_id`, which are strings), echoing the id minted by `POST /v1/user-service/my/task`; - `job_type: 1` — constant in observed cloud starts; - `design_id` — the MakerWorld design id when the print originates from a downloaded model (`0`/absent otherwise); - a trailing `"reason":"success","result":"success"` pair — present in the *published command* itself in some captures (not only in the printer's ack echo). OBN's cloud `project_file` builder does not emit these yet; they are documented as a **possible cloud-start gap** — whether stock firmware requires them (or silently tolerates their absence) still needs verification on hardware. ##### `/my/task` body: `amsDetailMapping[]` element schema Besides `amsMapping`/`amsMapping2` (see the `ams_mapping2` row above for the camelCase/sentinel caveat), the cloud `POST /v1/user-service/my/task` body carries a **per-filament detail array** `amsDetailMapping[]` with elements of the shape ([issue #48](https://github.com/ClusterM/open-bamboo-networking/issues/48)): ```json { "ams": 0, "filamentId": "GFA00", "filamentType": "PLA", "nozzleId": 0, "sourceColor": "#FFFFFFFF", "targetColor": "#FFFFFFFF" } ``` Colors are **RGBA hex strings with a leading `#`** (8 hex digits). Studio hands the plugin the source data via `PrintParams::ams_mapping_info`; the stock plugin re-serializes it into this schema for the task body. ##### URL schemes for model location (`file` / `url`) The MQTT command is always `project_file`; there is **no** separate `storage: emmc|udisk` field that selects where the model lives. Instead, firmware interprets the **`url` prefix** (and the matching `file` basename/path). Observed / reverse-engineered on **P2S** (May 2026): | URL prefix | Example | Firmware behaviour (inferred) | |---|---|---| | `ftp://` | `ftp://skadis_spool-Body.gcode.3mf` | **FTPS / sdcard-style volume**, and one of the two URL schemes cloud dispatch appears to accept (with `https://`). Stock `_with_record` briefly PATCHes this, then **overwrites** with S3 https (§6.8.1). Older N7-class pure-LAN FTPS legs use it for real. A `:6000` cache file of the same name does **not** satisfy `ftp://`. | | `ftp:///` (three slashes) | `ftp:///skadis_spool-Body.gcode.3mf` | **Offline / pre-pushed variant of the row above, not a defect.** Genuine traffic shows both spellings and they are **scenario-dependent**: two slashes when the plugin has just FTPS-uploaded the file, three slashes (empty authority = "local", absolute path) when the file was pushed to the printer earlier and the job starts offline against the already-present copy. Firmware accepts both. Cross-validated in [issue #48](https://github.com/ClusterM/open-bamboo-networking/issues/48). | | `brtc://emmc/` | `brtc://emmc/skadis_spool-Body.gcode.3mf` | **LAN MQTT model-cache lookup** after `:6000` `cmd_type=5`. Stock P2S **`start_local_print`** and Send-to-Printer use this. **Not** used on stock `_with_record` / `start_print`; cloud `/my/task` path shows no support for registering or dispatching `brtc://` (§6.8.1.2). Despite the `emmc` token, firmware searches udisk cache then emmc (§6.14.2). | | `file://` | `file:///media/usb0/cache/skadis_spool-Body.gcode.3mf` | **Explicit filesystem path.** No cache heuristics — open exactly that file. Typical for **Print from Device** when the `.3mf` is already on storage. Paths observed under `/media/usb0/` with or without a `/cache/` segment (e.g. `file:///media/usb0/skadis_spool-Body.gcode.3mf`). | | `https://` | presigned object URL | **Cloud print.** Printer downloads from Bambu object storage; unrelated to local emmc/udisk caches. | **Upload vs print-start:** choosing Cache vs External in Send to Printer sets `dest_storage` (`"emmc"` / `"udisk"`) in the `:6000` `cmdtype=5` upload (§6.14.2) — that decides **where the file is written** in the model cache. The `url` scheme at print-start decides **how firmware finds** the file afterward (`brtc://emmc/` = search both caches; `ftp://` = FTPS volume; `file://` = fixed path). Hybrid stock print writes via FTPS and starts with `ftp://` — not via the Send-to-Printer cache path (§6.8.1.2). **Not model storage:** the `cfg` bitmask (`"4"` when `task_timelapse_use_internal=true`) and the `ipcam_get_media_info` preflight (§6.8.3) govern **timelapse video recording** destination, not which `.3mf` is printed. Sibling `header` object the stock plugin wraps the command in (cloud and LAN alike when paired against signature-checking firmware): ```json { "header": { "cert_id": "a4e8faaa…CN=GLOF3813734089.bambulab.com", "payload_len": 1313, "sign_alg": "RSA_SHA256", "sign_string": "ycWyeOUZFB…==", "sign_ver": "v1.0" }, "print": { … } } ``` `payload_len` is the UTF-8 byte length of the **exact signed byte string** (defined precisely in the *SignMessage* pseudocode below — it is the `{`-prefixed `print` section, wrapper included, **byte-for-byte as it appears on the wire**, not the inner `print` object alone); `sign_string` is the Base64 RSA-SHA256 (PKCS#1 v1.5) signature of that exact byte string; `sign_ver` labels the scheme (`v1.0` observed). **No JSON canonicalization is applied or required** — keys need not be sorted and whitespace need not be stripped (see the "what is actually signed" note below). Non-Developer-Mode firmware rejects unsigned `project_file` (and other privileged) commands with `MQTT Command verification failed` (error range `84033543`–`84033548`; `…543` = "verification failed", `…545` = "need reset device pub key" → triggers a device-cert re-issue). Developer Mode bypasses the check entirely (see "Developer Mode requirement" in `README.md`), making the `header` envelope optional. **`cert_id` is a cloud-issued, _shared_ application certificate — not a per-install _device_ certificate** (correction cross-validated in [issue #47](https://github.com/ClusterM/open-bamboo-networking/issues/47) against a symbol-bearing `libbambu_networking.so`, a live VMProtect memory dump, and decrypted secured-printer captures). The example above, `a4e8faaa…CN=GLOF3813734089.bambulab.com`, is the **app** cert (subject/issuer under `GLOF{…}.bambulab.com`), *not* a device CN. Two distinct keys are in play, and they must not be confused: - **App private key → signs.** The `sign_string` and the HTTP command-security headers (below) are produced with the application private key. That key is **shared across Studio installs** (same app leaf CN `GLOF3813734089-…`), not per-install: the stock plugin fetches it at runtime from `GET /v1/iot-service/api/user/applications/{enc_secret}/cert?aes256={wrapped_key}&ver=1` (§6.3). The plugin binary hardcodes only the bootstrap `client_auth_secret` and the cloud `server_wrap_key` public key used to build that request; the returned `key` blob's decrypt recipe is still open (native `CM.up1` / equivalent). `cert_id` is derived from the returned cert (issuer + serial). In OBN, `slicer_key.pem` is supplied out-of-band until response finalization is reversed. - **Device-cert public key → encrypts.** The printer's device-certificate public key is used **only** for field encryption (`url_enc` / `param_enc`), never for signing. **HTTP half of command-security** (same app key, on secured-printer REST writes such as `POST /my/task`): the plugin adds `x-bbl-device-security-sign = base64(RSA_PKCS1v15(app_priv, utf8(unix_ms)))` (a raw PKCS#1 v1.5 signature over the current time in milliseconds — the server recovers the timestamp and checks recency for replay protection) and `x-bbl-app-certification-id = issuer + ":" + serial.lower()`. Note this is a **different serialization** from the MQTT `cert_id` (which concatenates serial + issuer without a separator). **Provisioning surface (`security` MQTT namespace, wire-unconfirmed).** `app_cert_install` (`app_cert`, `crl`) / `app_cert_list` install and enumerate the app cert on the printer; this is the recovery path behind a `…545` rejection. Whether the `install_device_cert` ABI export maps to this provisioning flow or to a TLS-leaf TOFU snapshot (`/certs/.pem`) is **not yet wire-confirmed** — flagged as a joint-capture item in issue #47. ##### Command-security key & certificate flow Two independent RSA credentials drive command-security: a **shared app key** that signs, and a **per-printer device-cert public key** that encrypts. Each line below reads as **inputs → where/when it is used → output**. **Sources.** Most of the authorization-control detail below is not our own capture — it is drawn from third-party reverse-engineering, cross-checked against our code and issue #47. Primary references, cited inline per group: - **RN-3** — reverse-networking, [3. Credential Rotation.md](https://f.sfconservancy.org/j4k0xb/reverse-networking/src/branch/authorization-control/Authorization%20Control/3.%20Credential%20Rotation.md) (cloud cert endpoint, `client_auth_secret`, AES/RSA envelope). **Correction (OBN 2026-07):** the RSA wrap key is a dedicated `server_wrap_key` embedded in the plugin, **not** the `application_root` public key; RN-3's response-key AES-GCM recipe does not match live 704-byte blobs. - **RN-4** — reverse-networking, [4. App Certificates.md](https://f.sfconservancy.org/j4k0xb/reverse-networking/src/branch/authorization-control/Authorization%20Control/4.%20App%20Certificates.md) (app cert chain + CRL are shared, public, not per-device). - **RN-5** — reverse-networking, [5. MQTT.md](https://f.sfconservancy.org/j4k0xb/reverse-networking/src/branch/authorization-control/Authorization%20Control/5.%20MQTT.md) (`app_cert_install` / `app_cert_list`, `flag3` bit 16, `SignMessage`/`EncryptField` middleware, cleartext-when-no-cert rule). - **RN-6** — reverse-networking, [6. HTTP.md](https://f.sfconservancy.org/j4k0xb/reverse-networking/src/branch/authorization-control/Authorization%20Control/6.%20HTTP.md) (`x-bbl-*` command-security headers). - **#47** — [ClusterM/open-bambu-networking issue #47](https://github.com/ClusterM/open-bamboo-networking/issues/47) (shared-app-cert correction, PKCS#1 v1.5 sizing, verified against a symbol-bearing `libbambu_networking.so` + VMProtect dump + decrypted secured-printer captures). - **OBA** — [Doridian/OpenBambuAPI `mqtt.md`](https://github.com/Doridian/OpenBambuAPI/blob/main/mqtt.md) (base MQTT topic/command model). - **OBN** — our own code (`src/signing.cpp`, `src/agent.cpp`, `src/cloud_print.cpp`, `src/cloud_auth.cpp`) and the ABI harness captures described elsewhere in this document; live credential-rotation request probes (2026-07). **Credential acquisition (once, at startup / login)** — sources: RN-3 (corrected), RN-4, #47, OBN live verify. - Stock plugin embeds **`client_auth_secret`** + **`server_wrap_key`** (RSA public) → builds `enc_secret` / `aes256` per §6.3 → `GET …/applications/{enc_secret}/cert?aes256=…&ver=1` (no user bearer required) → response with `cert` (shared app chain), `crl`, and encrypted `key` (704 B). *(OBN; RN-3)* - `cert` + `crl` → **`slicer_cert.pem`** / **`slicer_crl.pem`**; leaf issuer+serial → **`cert_id`** / `slicer_cert_id.txt`. The request side is reproducible once both embedded secrets are known. *(OBN, RN-4, #47)* - Encrypted `key` blob → **finalization algorithm still open** (documented AES-GCM/`session_key` fails; native `CM.up1` / plugin equivalent). Until reversed, OBN takes **`slicer_key.pem`** out-of-band (e.g. memory extract from stock). The resulting app private key is shared across Studio installs. *(OBN; RN-3 claim superseded; #47)* **Printer device-cert public key acquisition (per printer, lazily before a secured print)** — sources: RN-5 (`app_cert_install` reply); OBN for the TLS-leaf TOFU path (`capture_peer_cert_pem`, `harvest_security_report`). - Printer IP + LAN TLS handshake to `printer_ip:8883` → `capture_peer_cert_pem` on a direct LAN connection → printer device cert (`CN=`) cached on disk (`/certs/.pem`); its **public key** is the TOFU/LAN-direct source. *(OBN)* - `app_cert_install` request (see below) → printer replies on the report topic with `printer_cert` → device cert parsed by `harvest_security_report` → **printer public key** cached in memory (authoritative source, works for cloud/proxy transports too). *(RN-5; RN-5 explicitly recommends always retrieving via this flow, even when a LAN TLS cert exists)* **Provisioning: teaching the printer to trust the app cert** — sources: RN-5; hardware notes below are wire/firmware observations. - App cert chain + CRL → published as `security.app_cert_install` → printer stores them in its trust store and returns `printer_cert` on the report topic. *(RN-5)* - **Volatility (hardware):** the installed app cert / CRL live in **printer RAM**, not persistent storage. A power cycle or reboot clears the trust store completely, so `app_cert_install` must be repeated after every reboot / fresh session. Treating install as a one-time bind-time step is incorrect. - (no inputs) → `security.app_cert_list` query → printer returns the `cert_ids` it currently trusts (observability only; confirms whether a given `cert_id` is already installed). *(RN-5)* **Signing / encryption applied to each critical command** — sources: RN-5 (`SignMessage`, `EncryptField`), RN-6 (HTTP headers), #47 (PKCS#1 v1.5 padding + ≤~245-byte sizing). - App private key (`slicer_key.pem`) + serialized `print` object → `SignMessage` RSA-SHA256 PKCS#1 v1.5 on every privileged MQTT command → **`header.sign_string`** (+ `payload_len`, `sign_alg`, `sign_ver`). *(RN-5)* - `cert_id` → copied verbatim into every signed MQTT command → **`header.cert_id`** (serial + issuer concatenated, no separator). *(RN-5, #47)* - Printer public key + cleartext `url` (or `param`) → RSA PKCS#1 v1.5 encrypt, only for `project_file`/`gcode_line` **and only once a device cert is known** (otherwise the field keeps cleartext) → **`url_enc`** / **`param_enc`** **replacing** the cleartext field (on the wire the cleartext `url`/`param` is dropped once the `_enc` form exists — verified on hardware). *(RN-5 for the encrypt-when-cert-known rule; #47 for PKCS#1 v1.5; the cleartext-replacement is on-hardware)* - App private key + current time in ms (as UTF-8 string) → RSA PKCS#1 v1.5 sign, on secured-printer REST writes (e.g. `POST /my/task`) → **`x-bbl-device-security-sign`** header (server recovers the timestamp for replay protection). *(RN-6)* - `cert_id` reformatted as `issuer + ":" + serial.lower()` → same REST writes → **`x-bbl-app-certification-id`** header (note: different serialization from the MQTT `cert_id`). *(RN-6, #47)* **Verification (consumers)** — sources: RN-5 (firmware signature/CRL check, error semantics), RN-6 (cloud REST check); Developer-Mode bypass is OBN-observed and documented in `README.md`. - `header.sign_string` + `header.cert_id` (+ trust store from `app_cert_install`, incl. CRL) → **printer firmware** checks the signature against the trusted app cert and that `cert_id` is not revoked, on every incoming critical command → accept, or reject with `MQTT Command verification failed` (`84033543`–`84033548`; `…545` = "need reset device pub key"). *(RN-5; error range cross-validated in #47)* - `url_enc` / `param_enc` → **printer firmware** decrypts with its own device private key → recovered `url` / `param`. *(RN-5)* - `x-bbl-device-security-sign` + `x-bbl-app-certification-id` → **Bambu cloud REST** validates the write. *(RN-6)* - Developer Mode ON → **printer firmware** bypasses the signature check entirely: plain `url` accepted, `header` optional, `url_enc` unnecessary. This is why LAN printing works today without ever touching the cloud cert endpoint, provided the operator supplied the app key out-of-band. *(OBN; see "Developer Mode requirement" in `README.md`)* Note where the **CRL** actually lands: it is never read by the plugin's signing/encryption path — it is only shipped inside `app_cert_install` into the printer's trust store, where the firmware consults it while verifying `header.sign_string`. With an empty revocation list and no rotation it is effectively a formality; it becomes load-bearing only if Bambu revokes/rotates the shared app cert. *(RN-5; RN-4 for the observed empty, ~30-day CRL)* **CRL validity is not enforced by the printer (hardware 2026-07):** the official current CRL from Bambu's cert endpoint is already **past its `nextUpdate`**, yet firmware still accepts `app_cert_install` and uses the CRL body for serial/issuer revocation checks. An older CRL that does not yet list a revoked app-cert leaf can therefore still be installed; revocation only applies if the CRL last installed on the printer actually contains that leaf. ##### Command-security algorithms (pseudocode) The flow list above says *which* key touches *which* field; the pseudocode below is the exact wire recipe, so a command can be signed/encrypted without opening the source references. It is transcribed from RN-5 (MQTT middleware) and RN-6 (HTTP headers); primitives are OpenSSL-equivalent (`RSA_Sign` = `EVP_DigestSign` with SHA-256 + PKCS#1 v1.5; `RSA_Encrypt` = `RSA_public_encrypt` with `RSA_PKCS1_PADDING`). **1. `SignMessage` — RSA-SHA256 over the `print` section** (every privileged MQTT command): ```py def SignMessage(app_private_key, print_text): # print_text is the serialized "print" VALUE exactly as it will appear # on the wire (any key order, any/no whitespace, newlines allowed). to_sign = '{"print":' + print_text + '}' # wrapper; see exact rule below json_bytes = UTF8_Encode(to_sign) signature = RSA_Sign(app_private_key, json_bytes) # RSA-SHA256, PKCS#1 v1.5 header = { "sign_ver": "v1.0", "sign_alg": "RSA_SHA256", "sign_string": Base64_Encode(signature), "cert_id": app_cert.serialNumber.lower() + app_cert.issuer, # NO separator "payload_len": len(json_bytes), # = len(to_sign), wrapper included } # The SAME print_text bytes must be emitted in the envelope (see below). return '{"header":' + JSON(header) + ',"print":' + print_text + '}' ``` **What is actually signed (verified on P2S hardware, 2026-07).** The firmware does **not** canonicalize. It verifies the signature over a byte string it reconstructs from the received envelope as: > `"{"` **+** everything from the start of the `"print"` key up to and including the final closing `}` of the whole message. In other words: take the on-wire envelope `{"header":{…},"print"…`, drop the `{"header":{…},` prefix, prepend a single `{`, and that byte string (length = `payload_len`) is what the signature must cover. Consequences, all confirmed on hardware: - **Key sorting is _not_ required.** Any object-key order verifies. - **Whitespace is _not_ stripped.** Spaces, indentation, even newlines inside the `print` section are fine. - **The only hard rules:** (1) the signed string must start with `{`; (2) the bytes of the `print` section used to compute the signature must be **byte-identical** to the bytes placed in the envelope, **including any surrounding whitespace**. If you sign `{"print" : }` (spaces around `:` and before `}`), the envelope must end with the identical `,"print" : }`. Add a space after the `{` when signing (`{ "print" : …`) and the envelope's separator must likewise become `}, "print" : …`. A trailing space in the signed string (`… } `) must also appear in the envelope. Any byte mismatch between "what was signed" and "what is on the wire" fails verification. This is why a compact, sorted serializer *works* but is not *mandatory*: it just happens to make "what I signed" equal "what I send" trivially. The robust rule for a reimplementation is: **serialize the `print` value once, sign `{` + those exact bytes + (whatever trailing bytes you will also emit), then emit those exact same bytes after `,"print":` (or `, "print":`, matching your prefix) in the envelope.** *(RN-5 gives the algorithm shape; the "no canonicalization, byte-exact wire match" rule is from on-hardware testing.)* Note the **`cert_id` serialization for MQTT is `serialNumber.lower() + issuer`** with no separator (e.g. `a4e8faaa…CN=GLOF3813734089.bambulab.com`). The HTTP header uses a *different* serialization (below). *(RN-5)* > **Unrelated pitfall that masquerades as a signature error:** a non-monotonic or reused `sequence_id` (e.g. always `"0"`) can get the command rejected even though the signature is valid. Use a monotonically increasing `sequence_id` before suspecting the signing path. **`gcode_line` on a secured (non-Developer-Mode) printer requires `param_enc`, not cleartext `param` (verified on P2S hardware, 2026-07).** A signed `gcode_line` whose G-code is carried in the cleartext `param` field is refused with `mqtt message verify failed`; the *same* G-code carried in a correctly formed `param_enc` is accepted (`result: SUCCESS`). The signature being valid is necessary but not sufficient — the firmware also insists that the G-code arrive encrypted under the device certificate. Matrix, all with **the same** `slicer_key.pem`, `cert_id`, signing procedure and a valid `sequence_id`: | `gcode_line` payload shape | Result on secured P2S | |---|---| | cleartext `param` only, no `param_enc` | `failed` / `mqtt message verify failed` | | cleartext `param` **and** `param_enc` together | `failed` / `mqtt message verify failed` | | `param_enc` only (device-encrypted), **no** cleartext `param` | `SUCCESS` | | `param_enc` only, with or without `user_id` | `SUCCESS` (either way — `user_id` is optional) | So the rules for `gcode_line` on secured firmware are: **(1)** put the G-code in `param_enc` (blockwise RSA under the device cert, see `EncryptField` below), **(2)** do **not** also send the cleartext `param`, **(3)** sign the envelope as usual. Developer Mode bypasses both the signature and the encryption requirement (see "Developer Mode requirement" in `README.md`), so a plain cleartext `param` works there. A genuine stock `gcode_line` captured from Studio (temperature control) confirms the shape — note `param_enc` present, `param` absent, and the 512-byte (= 2×256, two RSA-2048 blocks) ciphertext: ```json {"header":{"cert_id":"…","payload_len":778,"sign_alg":"RSA_SHA256","sign_string":"…","sign_ver":"v1.0"}, "print":{"command":"gcode_line","param_enc":"…684 base64 chars = 512 bytes…","sequence_id":"20006","user_id":"3575315859"}} ``` Its `sign_string` verifies against `slicer_key.pem` over the byte-exact `{"print":…}` (`payload_len` 778 matches), and its `param_enc` decodes to exactly two RSA-2048 blocks — i.e. Studio splits the G-code into ≤245-byte chunks, RSA-PKCS#1v1.5-encrypts each under the device public key, concatenates and Base64-encodes (the same `EncryptField` scheme as `url_enc`, just applied to `param`). Note that Studio still *prefers* a structured MQTT command over `gcode_line` when the printer advertises one (e.g. `back_to_center` for homing instead of `G28`, `xyz_ctrl` for jog, `set_nozzle_temp` for nozzle temperature). `gcode_line` is the path for operations that have no structured equivalent (and, as observed, temperature control on some firmware still rides `publish_gcode`): ```cpp // 3rd_party/BambuStudio/src/slic3r/GUI/DeviceCore/DevAxisCtrl.cpp int DevAxis::Ctrl_GoHome() { if (m_is_support_mqtt_homing) { // structured path preferred json j; j["print"]["command"] = "back_to_center"; ... return m_owner->publish_json(j); } // gcode fallback, only when firmware lacks the structured command: return m_owner->is_in_printing() ? m_owner->publish_gcode("G28 X\n") : m_owner->publish_gcode("G28 \n"); } ``` **Practical takeaway for a reimplementation:** to send raw G-code to a secured printer, encrypt it into `param_enc` under the device cert (blockwise RSA, `EncryptField` below), omit the cleartext `param`, and sign the envelope. *(Structured-vs-fallback dispatch and `publish_gcode` shape: Bambu Studio source; the "`param_enc` required, cleartext `param` refused" result and the captured-message block analysis are from on-hardware testing.)* **2. `EncryptField` — device-cert field encryption** (`print.project_file` → `url_enc`, `print.gcode_line` → `param_enc`): ```py def EncryptField(device_id, value): device_cert = getDeviceCert(device_id) # from app_cert_install reply / TLS leaf if device_cert is None: return value # no cert yet -> field stays cleartext plaintext = UTF8_Encode(value) out = b"" # Blockwise RSA: plaintext is split into <=245-byte chunks (RSA-2048 PKCS#1 v1.5 # ceiling = keylen - 11 = 245), each chunk encrypts to one 256-byte block, and the # blocks are concatenated. A short value -> 1 block (256 B); e.g. Studio's captured # temperature gcode_line -> 512 B == 2 blocks (see the gcode_line note above). for i in range(0, len(plaintext), 245): out += RSA_Encrypt(device_cert.publicKey, plaintext[i : i + 245]) # PKCS#1 v1.5 return Base64_Encode(out) ``` The middleware assigns the `*_enc` field; when no device cert is known yet it leaves the field cleartext. Confirmed on hardware (2026-07), on-wire the cleartext field is **replaced** by its encrypted form once a cert is known — for **both** commands: `project_file` sends `url_enc` with **no** cleartext `url`, and `gcode_line` sends `param_enc` with **no** cleartext `param` (a secured printer refuses `gcode_line` that still carries a cleartext `param`; a captured stock `project_file` likewise carries only `url_enc`). Developer Mode disables verification, so there the encrypted fields are optional and cleartext works. Other privileged commands are only signed, not field-encrypted. *(RN-5 for the scheme; the blockwise chunking and the cleartext-replacement rule for both fields are from on-hardware testing.)* **3. HTTP proof-of-possession** (secured-printer REST writes, e.g. `POST /my/task`): ```py # app_cert_certification_id header — DIFFERENT serialization from MQTT cert_id: headers["x-bbl-app-certification-id"] = app_cert.issuer + ":" + app_cert.serialNumber.lower() # possession token over the current time (NOT a signature over the request body): timestamp_ms = str(now_ms()) # 13-digit decimal string ciphertext = RSA_Encrypt(app_private_key, UTF8_Encode(timestamp_ms)) # raw priv-key op, no hash headers["x-bbl-device-security-sign"] = Base64_Encode(ciphertext) ``` The server recovers the timestamp and checks recency (replay protection). Two subtleties bite in practice: the timestamp must be in **milliseconds** (13 digits — a nanosecond value fails recency), and the `x-bbl-app-certification-id` serialization (`issuer:serial.lower()`) is **not** the MQTT `cert_id` order — sending the MQTT form 403s. *(RN-6)* Other `PrintParams` members Studio populates but **does not put into the MQTT command** itself: `nozzles_info`, `ams_mapping_info`, `extra_options`, `task_ext_change_assist`, `try_emmc_print`, `comments`. They feed the cloud-side `POST /v1/user-service/my/task` body and the timelapse-storage preflight (see below), not `project_file`. (`task_timelapse_use_internal` *does* reach the MQTT command, but indirectly — see the `cfg` row in the table above.) ##### Cross-ABI `project_file` reverse-engineering matrix The mapping above was verified by loading the stock `libbambu_networking.so` of each version below into a `BBL::PrintParams`-driven harness and capturing the resulting LAN MQTT `project_file` payload against an N7 in Developer Mode: | ABI tag | Plugin file | `cfg` baseline behaviour | New schema field(s) vs the prior tag | |---|---|---|---| | `02.05.00` | `02.05.00.56` | always `"0"` | (reference) | | `02.05.01` | `02.05.01.52` | always `"0"` | none | | `02.05.02` | `02.05.02.58` | always `"0"` | none | | `02.05.03` | `02.05.03.63` | `"4"` if `task_timelapse_use_internal=true`, else `"0"` | `cfg` becomes settable (the `PrintParams::task_timelapse_use_internal` ABI break) | | `02.06.00` | `02.06.00.50` | same as `02.05.03` | none in `project_file`; only new exports are filament-spool CRUD (`bambu_network_get_filament_spools`, `bambu_network_create_filament_spool`, `bambu_network_update_filament_spool`, `bambu_network_delete_filament_spools`, `bambu_network_get_filament_config`) — those are HTTP plumbing, not LAN MQTT | | `02.06.01` | `02.06.01.50` | same as `02.05.03` | none | **Bottom line:** the on-the-wire `project_file` schema is essentially frozen across `02.05.x` -> `02.06.x`. The only ABI-significant change for LAN print submission is the addition of `task_timelapse_use_internal` in `02.05.03`, which feeds the `cfg` bitmask (and only bit `0x4` has been observed so far). All field mappings below were also confirmed identical between `02.05.00` and `02.06.01` — only the `cfg` value differs between the two ABI families. ##### Per-`PrintParams`-field mapping (overlay matrix on ABI `02.05.03`) The following matrix was collected by toggling one `PrintParams` field at a time before handing the struct to the stock plugin and diffing the resulting `project_file` against an all-defaults baseline. It confirms — and in some cases corrects — the per-row notes in the table above. | `PrintParams` field overridden | Resulting `project_file` change | |---|---| | `task_use_ams=true`, `ams_mapping="[0]"`, `ams_mapping2='[{"ams_id":0,"slot_id":0}]'` | `use_ams: false → true`, `ams_mapping: [] → [0]`, `ams_mapping2: [] → [{"ams_id":0,"slot_id":0}]` | | `task_record_timelapse=false` (and `task_timelapse_use_internal=false`) | `timelapse: true → false`, `cfg: "4" → "0"` | | `task_timelapse_use_internal=false` (alone) | `cfg: "4" → "0"` (the `timelapse` boolean stays `true`) | | `task_bed_type="textured_plate"` | `bed_type: "auto" → "textured_plate"` | | `task_bed_type="supertack_plate"` | `bed_type: "auto" → "supertack_plate"` | | `task_bed_leveling=false`, `task_flow_cali=false`, `task_vibration_cali=false`, `task_layer_inspect=false` | All four matching booleans flip to `false` (`bed_leveling`, `flow_cali`, `vibration_cali`, `layer_inspect`) — direct one-to-one mapping | | `extruder_cali_manual_mode=0` | `extrude_cali_manual_mode` **appears** with value `0` (absent in baseline because `-1` is the default) | | `extruder_cali_manual_mode=1` | `extrude_cali_manual_mode` appears with value `1` | | `auto_offset_cali=2` | `nozzle_offset_cali: 0 → 2` (note the upstream rename) | | `auto_bed_leveling=2` | `auto_bed_leveling: 0 → 2` (coexists with the `bed_leveling` boolean) | | `auto_flow_cali=1` | `extrude_cali_flag: 0 → 1` (this is how the previously mysterious `extrude_cali_flag` is populated) | | `project_name="my-print"` | `subtask_name`, `file`, and `url` all switch in lock-step (``, `.gcode.3mf`, `ftp://.gcode.3mf`) — the FTP upload path follows suit | | `plate_index=2` | `param: "Metadata/plate_1.gcode" → "Metadata/plate_2.gcode"` | Fields that did **not** appear on this hardware (P2S/N7 single-extruder) regardless of overrides: `nozzle_mapping` (gated on multi-extruder, see the dedicated row above), `url_enc` (Developer Mode bypasses the encrypted-URL requirement), and the entire `header` envelope (Developer Mode disables signature verification — see §6.8.2 above). #### 6.8.3. Timelapse-storage preflight (`ipcam_get_media_info`) Right before `project_file` Studio runs a preflight against the printer's storage when the user enabled timelapse and the printer reports `is_support_internal_timelapse`. The check is **driven by Studio, not by the plugin**: `SelectMachineDialog::start_timelapse_storage_check()` -> `MachineObject::command_ipcam_check_timelapse_storage(storage, total_layer)` -> `MachineObject::publish_json()`, which routes through `bambu_network_send_message_to_printer` (LAN) or `bambu_network_send_message` (cloud) with this opaque payload: ```json { "camera": { "command": "ipcam_get_media_info", "sub_command": "is_timelapse_storage_enough", "sequence_id": "20021", "storage": "internal", "total_layer": 50 } } ``` The printer answers on the same channel with `result`, `is_enough`, `file_count`; Studio reads them in `DeviceManager.cpp:3548-3553` and either proceeds with `on_send_print()` or shows the "free up storage" dialog. From the plugin's perspective the frame is opaque — it is neither parsed nor formatted plugin-side, only forwarded byte-for-byte by the generic `send_message*` ABI calls. #### 6.8.4. MQTT AMS and pressure-advance (PA) calibration commands These commands are **not implemented in the network plugin** — Studio builds the JSON and sends it through the generic `bambu_network_send_message` / `bambu_network_send_message_to_printer` ABI calls; the plugin forwards the frame byte-for-byte. The formats below are reconstructed from Bambu Studio source (`DeviceManager.cpp`, `DevCalib.cpp`, `DevFilaSystemCtrl.cpp`). They share the same transport as `project_file` (§6.8.2): publish to `device//request` on LAN MQTT (or the cloud forwarder), with all command fields nested under a top-level `"print"` object. **Common request envelope** | Field | Type | Notes | |-------|------|-------| | `print.command` | string | Command name (see tables below) | | `print.sequence_id` | string | See §6.8.2 *MQTT `sequence_id`* | Per-command unique id (Studio: monotonic **20000–29999**; plugin: epoch-ms; cloud: `"0"`). Plugin forwards Studio's value unchanged. | **Common response envelope** The printer answers on the status/report channel inside the same `"print"` wrapper. Studio matches replies by `print.command` in `DeviceManager.cpp:2707-3513` and dispatches to `DevCalib::ParseV1_0()` or AMS parsers. | Field | When present | Meaning | |-------|--------------|---------| | `command` | always | Echoes the command name | | `sequence_id` | usually | Echoes the request | | `result` | on failure | `"fail"` — paired with `reason` | | `reason` | on failure | Human-readable cause (e.g. `"invalid nozzle_diameter"`, `"generate auto filament cali gcode failure"`) | | `err_code` | sometimes | Numeric error code; Studio shows a dialog when present | | `errno` | some AMS cmds | Negative int (e.g. `-2` chamber too hot, `-4` AMS too hot during filament change) | **Tray / slot indexing** Studio encodes AMS slots as a flat `tray_id`: ``` tray_id = ams_id * 4 + slot_id (standard AMS; ams_id < 16) ``` Virtual external spools use fixed ids: `255` = main external spool, `254` = deputy external spool (`DevDefs.h`). On unload via `ams_change_filament`, Studio sends `target: 255` and `slot_id: 255`. **Nozzle identifiers** Multi-extruder / new-auto-calibration paths carry: | Field | Format | Example | |-------|--------|---------| | `nozzle_diameter` | string | `"0.2"`, `"0.4"`, `"0.6"`, `"0.8"` | | `nozzle_id` | string | `"HS00-0.4"` — second char is volume type (`S` standard, `H` high-flow, `U` TPU high-flow) | | `nozzle_pos` | int | Physical nozzle rack position (optional) | | `nozzle_sn` | string | Nozzle serial (optional, paired with `nozzle_pos`) | --- ##### Pressure advance (PA) calibration — `extrusion_cali_*` MQTT command family for **pressure advance (K factor)** calibration and for managing PA profiles persisted on the printer. Related flow-ratio calibration uses the parallel `flowrate_*` commands (see end of this section). ###### What K and N are The printer firmware uses two coefficients for pressure advance compensation: | Coefficient | Meaning | Typical range | |-------------|---------|---------------| | **K** (`k_value`) | Pressure advance factor — how aggressively the extruder motor compensates for filament compressibility in the Bowden tube / hotend. Higher K = more compensation. | 0.01 – 0.10 (direct drive), 0.20 – 1.00+ (Bowden) | | **N** (`n_coef`) | Non-linearity coefficient — models the pressure/flow relationship when it deviates from linear. Used only by auto-calibration; manual calibration sets this to `0.0`. | 0.0 – 2.0 | Both coefficients are stored **on the printer**. Clients read, write, and select profiles via MQTT; the authoritative copy always lives in printer firmware storage. ###### Two firmware generations Everything in this family splits on one field: `cali_version` (`push_status` → `print.cali_version`; observed `0` on P2S). It defines two incompatible models: | | **Modern** (`cali_version >= 0`) | **Legacy** (`cali_version` absent) | |---|---|---| | Storage | Named profile table, multiple slots per `(filament_id, nozzle_id, nozzle_diameter)` key | One K/N value per tray, no table | | Per-tray PA fields in `push_status` | `cali_idx` only (absent on empty trays) | `k`, `n` | | Read active K/N | `extrusion_cali_get`, match entry by the tray's `cali_idx` | Read `k` / `n` directly from `push_status` | | Write | `extrusion_cali_set` batch shape (`filaments[]`) | `extrusion_cali_set` single-tray shape | | Activate | Separate `extrusion_cali_sel` step | No select step — writing **is** the activation | | Delete a profile | `extrusion_cali_del` | n/a | The rest of this section describes the modern model unless marked otherwise; for legacy firmware the whole story is "write K/N with single-tray `extrusion_cali_set`, read them back from `push_status`". ###### Storage model and reading the active profile (modern) The printer maintains a **profile table** keyed by `(filament_id, nozzle_id, nozzle_diameter)`. Each key can hold **multiple custom profile slots** identified by `cali_idx`. The default (factory) profile is not part of the table and is never returned by `extrusion_cali_get`. Each tray has one **active** profile; its `cali_idx` appears in the tray's `push_status` data and is changed with `extrusion_cali_sel`. To resolve the active profile's K/N for a tray: 1. Take the tray's `cali_idx` from `push_status`. 2. Send `extrusion_cali_get` for the tray's `(filament_id, nozzle_id, nozzle_diameter)`. 3. Find the response entry whose `cali_idx` matches. 4. **Match** → a custom profile is active; the entry carries its `name` and `k_value`. 5. **No match** → the default profile is active. Its K/N are not exposed over MQTT; Bambu Studio falls back to hardcoded per-filament defaults (`CalibUtils.cpp:54-80`): | Filament | K | N | |----------|---|---| | TPU 95A (`GFU01`) | 0.25 | 1.0 | | TPU 90A (`GFU03`) | 0.35 | 1.0 | | TPU 85A (`GFU04`) | 0.65 | 1.0 | | PETG, PA, Support PLA (`GFG00`, `GFG01`, `GFS00`, …) | 0.04 | 1.0 | | Everything else (PLA, ABS, …) | 0.02 | 1.0 | AMS root-level fields in `push_status` (`print.ams`): | Field | Type | Meaning | |-------|------|---------| | `cali_id` | int | Last calibration target tray id (`255` = external spool / none) | | `cali_stat` | int | Calibration status; low byte `0x01`–`0x04` = setup in progress, `0` = idle | ###### Capability gates | Flag / field | Source | Effect | |--------------|--------|--------| | `is_support_pa_calibration` | `fun` bit 7 / `home_flag` bit 16 / JSON `support_flow_calibration` (**naming error** — see §6.8.0) | Master gate for all `extrusion_cali_*` commands | | `m_calib.support_new_auto_calib` | `fun` bit 40 | Enables the multi-filament wizard path (batch `filaments[]` commands) | | `is_support_pa_mode` | `fun2` bit 3 | PA tuning mode toggle | **Bambu Studio UI overrides** (hardcoded, ignoring firmware flags): - **P1P/P1S** (`series_p1p`, model_id C11/C12): PA calibration UI is hidden. - **H2D** (`series_o`): flow-ratio calibration UI is hidden (PA remains available). ###### Two data lifecycles — run output vs stored profiles | Concept | MQTT command | Meaning | |---------|--------------|---------| | **Run output** (ephemeral) | `extrusion_cali_get_result` | Fresh K/N from the calibration job that just finished. **Not yet committed** to printer storage — discarded on reboot unless saved via `extrusion_cali_set`. | | **Stored profiles** (persistent) | `extrusion_cali_get` | K/N profiles already saved on the printer for a `(filament_id, nozzle, …)` key. Multiple slots per key, indexed by `cali_idx`; one is active per tray (`extrusion_cali_sel`). | ###### End-to-end calibration workflows **Automatic calibration** (firmware measures K/N by printing test patterns): ``` Studio Printer │ │ │─── extrusion_cali ────────────────>│ 1. Start calibration run │<── result: "success" ─────────────│ (printer prints test patterns, │ │ progress via push_status) │ ... wait for print to finish ...│ │ │ │─── extrusion_cali_get_result ────>│ 2. Fetch measured K/N │<── filaments[{k_value, n_coef}] ──│ (ephemeral, not saved yet) │ │ │ [user reviews results in UI] │ │ │ │─── extrusion_cali_set ───────────>│ 3. Commit to printer storage │<── tray_id, k_value, n_coef ──────│ (now persistent) │ │ │─── extrusion_cali_sel ───────────>│ 4. Mark as active for tray │<── tray_id, cali_idx ─────────────│ │ │ ``` **Manual calibration** (user prints a test pattern, visually picks the best K, enters it): ``` Studio Printer │ │ │─── extrusion_cali (mode=1) ──────>│ 1. Print PA test pattern │<── result: "success" ─────────────│ (line pattern with varying K) │ │ │ ... user inspects print ... │ │ ... user enters K in UI ... │ │ │ │─── extrusion_cali_set ───────────>│ 2. Save user-chosen K directly │ (k_value=, n_coef="0.0") │ (n_coef is always 0.0 for manual) │<── tray_id, k_value ──────────────│ │ │ │─── extrusion_cali_sel ───────────>│ 3. Mark as active for tray │<── tray_id, cali_idx ─────────────│ │ │ ``` **Browsing / managing existing profiles** (no calibration run): ``` Studio Printer │ │ │─── extrusion_cali_get ───────────>│ Read stored profiles for a │ (filament_id, nozzle_id, ...) │ filament/nozzle combination │<── filaments[{cali_idx, k_value}] ─│ │ │ │─── extrusion_cali_sel ───────────>│ Switch active profile for a tray │ (tray_id, cali_idx) │ │<── ok ────────────────────────────│ │ │ │─── extrusion_cali_del ───────────>│ Delete a specific profile slot │ (filament_id, cali_idx) │ │<── ok ────────────────────────────│ │ │ ``` ###### Print-time PA integration When Studio submits a print job via `project_file` (§6.8.2), two fields control PA behavior: | Wire field | `PrintParams` source | Meaning | |------------|---------------------|---------| | `extrude_cali_flag` | `auto_flow_cali` (enum, passed verbatim) | Tells firmware whether/how to run PA calibration before printing. `0` = skip, `1` = requested, `2` = auto (observed in genuine traffic, [issue #48](https://github.com/ClusterM/open-bamboo-networking/issues/48)); not a plain bool. | | `extrude_cali_manual_mode` | `extruder_cali_manual_mode` (0 = auto, 1 = manual, -1 = omit) | If PA cali is requested, which mode to use. Omitted entirely when the value is `-1` (default sentinel). | The firmware uses the **active profile** (selected via `extrusion_cali_sel`) for the tray's `(filament_id, nozzle)` combination. If `extrude_cali_flag=1`, the printer may re-run calibration before printing rather than using the stored profile. ###### Command reference **`extrusion_cali`** — start a PA calibration run Two request shapes: *Legacy single-tray:* ```json { "print": { "command": "extrusion_cali", "sequence_id": "42", "tray_id": 5, "nozzle_temp": 220, "bed_temp": 60, "max_volumetric_speed": 15.0 } } ``` *Multi-filament wizard* (`mode`: `0` = auto, `1` = manual): ```json { "print": { "command": "extrusion_cali", "sequence_id": "43", "nozzle_diameter": "0.4", "mode": 0, "filaments": [ { "tray_id": 5, "extruder_id": 0, "bed_temp": 60, "filament_id": "GFA00", "setting_id": "GFL99", "nozzle_temp": 220, "ams_id": 1, "slot_id": 1, "nozzle_id": "HS00-0.4", "nozzle_diameter": "0.4", "max_volumetric_speed": "15" } ] } } ``` **Response:** `{ "command": "extrusion_cali", "result": "success" }` or `{ "result": "fail", "reason": "…" }`. On success the printer runs the calibration gcode; progress is visible through ordinary `push_status` fields, not through this ack. **`extrusion_cali_set`** — write K/N coefficients to printer storage *Single-tray shape* (**legacy firmware**; also used by Studio when editing K without a profile table): ```json { "print": { "command": "extrusion_cali_set", "sequence_id": "44", "tray_id": 5, "k_value": 0.04, "n_coef": 1.4, "bed_temp": 60, "nozzle_temp": 220, "max_volumetric_speed": 15.0 } } ``` Notes on the single-tray shape: - `k_value` is the only user-controlled coefficient, sent as a JSON **number** (the batch shape sends strings). `n_coef` is hard-coded to `1.4` by Studio regardless of the UI value. - `bed_temp`, `nozzle_temp`, and `max_volumetric_speed` are included **only when all three are `>= 0`**; when the K value is edited from the AMS slot dialog they are omitted, leaving only `tray_id`, `k_value`, `n_coef`. - `setting_id` and `name` are not sent (commented out in the builder). - For the external spool use `tray_id` `255` (main) / `254` (deputy). - On legacy firmware there is no separate activation step — this write immediately becomes the tray's active K/N. *Batch shape* (**modern firmware**; save after the calibration wizard): ```json { "print": { "command": "extrusion_cali_set", "sequence_id": "45", "nozzle_diameter": "0.4", "filaments": [ { "tray_id": 5, "extruder_id": 0, "nozzle_id": "HS00-0.4", "nozzle_diameter": "0.4", "ams_id": 1, "slot_id": 1, "filament_id": "GFA00", "setting_id": "GFL99", "name": "Bambu PLA Basic", "k_value": "0.040", "n_coef": "0.0", "cali_idx": 2, "nozzle_pos": 0, "nozzle_sn": "SN123" } ] } } ``` Notes on the batch shape: - `k_value` and `n_coef` are sent as **strings** here (the single-tray shape sends `k_value` as a number). - For auto-calibration saves `n_coef` carries the measured value; for manual saves it is `"0.0"`. - `cali_idx` is included only when `>= 0` (omit it to create a new slot; provide it to overwrite an existing one). - `nozzle_pos` / `nozzle_sn` are included only when a nozzle position is known. **Response:** echoes `tray_id`, `k_value`, and either `n_value` (virtual tray) or `n_coef` (AMS tray). **`extrusion_cali_get`** — fetch stored PA profiles for a filament/nozzle combination: ```json { "print": { "command": "extrusion_cali_get", "sequence_id": "46", "filament_id": "GFA00", "extruder_id": 0, "nozzle_id": "HS00-0.4", "nozzle_diameter": "0.4", "nozzle_pos": 0, "nozzle_sn": "SN123" } } ``` `extruder_id`, `nozzle_id`, `nozzle_pos`, and `nozzle_sn` are omitted when not applicable. **Response** — real capture from a P2S (LAN MQTT, firmware `cali_version: 0`): ```json { "print": { "command": "extrusion_cali_get", "sequence_id": "9004", "result": "success", "reason": "", "is_from_mqtt": true, "filament_id": "P3507b3f", "extruder_id": 0, "nozzle_id": "HS00-0.4", "nozzle_diameter": "0.4", "filaments": [ { "cali_idx": 1, "extruder_id": 0, "filament_id": "P3507b3f", "k_value": "0.035000", "name": "AzureFilm PETG Original", "nozzle_diameter": "0.4", "nozzle_id": "HS00-0.4", "nozzle_pos": 0, "nozzle_sn": "N/A" } ] } } ``` Top-level fields echo the request (`filament_id`, `extruder_id`, `nozzle_id`, `nozzle_diameter`) plus `result`, `reason`, and `is_from_mqtt: true`. Per-entry fields: | Field | Type | Notes | |-------|------|-------| | `cali_idx` | int | Profile slot index. Only custom profiles are returned (the entry with `cali_idx` matching the tray's active `cali_idx` is the active custom profile). | | `filament_id` | string | | | `name` | string | Profile display name | | `k_value` | string | K as a string, e.g. `"0.035000"` | | `nozzle_id` | string | e.g. `"HS00-0.4"` | | `nozzle_diameter` | string | | | `extruder_id` | int | | | `nozzle_pos` | int | `0` when not applicable | | `nozzle_sn` | string | `"N/A"` when not applicable | **Note — observed vs. parser-tolerated fields.** The capture above is the complete per-entry payload this firmware sends for a stored PA profile. Bambu Studio's parser additionally *accepts* `tray_id`, `ams_id`, `slot_id`, `setting_id`, `n_coef`, and `confidence`, but this firmware does **not** send them for `extrusion_cali_get`; missing values fall back to defaults (`n_coef` → `0.0`, `confidence` → `0`). `n_coef` and `confidence` are populated in `extrusion_cali_get_result` (auto-calibration run output), not in stored-profile listings. Do not rely on them being present here. On failure: `{ "result": "fail", "reason": "…" }` with `filaments: []`. **`extrusion_cali_get_result`** — fetch the **latest auto-calibration run results** (ephemeral, not the stored table): ```json { "print": { "command": "extrusion_cali_get_result", "sequence_id": "47", "nozzle_diameter": "0.4" } } ``` **Response:** `filaments[]` in the same shape as `extrusion_cali_get`, but this is where `n_coef` and `confidence` are actually populated (measured by the run). When no run result exists, the printer returns a failure rather than an empty success — real capture from a P2S with no pending run: ```json { "print": { "command": "extrusion_cali_get_result", "sequence_id": "9100", "result": "fail", "reason": "Don't exist", "is_from_mqtt": true, "nozzle_diameter": "0.4", "filaments": [] } } ``` **`extrusion_cali_sel`** — set the active profile for a tray (**modern firmware only**; on legacy firmware there is no profile table — write K/N via single-tray `extrusion_cali_set` instead): ```json { "print": { "command": "extrusion_cali_sel", "sequence_id": "48", "tray_id": 5, "ams_id": 1, "slot_id": 1, "cali_idx": 2, "filament_id": "GFA00", "nozzle_diameter": "0.4", "nozzle_pos": 0, "nozzle_sn": "SN123" } } ``` - `cali_idx: -1` selects the default (factory) profile. After selection, the tray's `cali_idx` in `push_status` reflects the firmware's index for that profile (which may not match any custom profile returned by `extrusion_cali_get`). - For the external spool: `ams_id` = `255` (main) / `254` (deputy), `slot_id` = `0`, and `tray_id` = the same reserved value (`255` / `254`, **not** `ams_id * 4 + slot_id`). The rest of the frame is unchanged. - `nozzle_pos` / `nozzle_sn` are included only when a nozzle position is known. **Response:** echoes `tray_id`, `ams_id`, `slot_id`, `cali_idx`. **`extrusion_cali_del`** — delete one stored profile slot: ```json { "print": { "command": "extrusion_cali_del", "sequence_id": "49", "extruder_id": 0, "nozzle_id": "HS00-0.4", "filament_id": "GFA00", "cali_idx": 2, "nozzle_diameter": "0.4", "nozzle_pos": 0, "nozzle_sn": "SN123" } } ``` --- ##### Flow ratio calibration — `flowrate_*` Parallel to PA, Studio drives flow-ratio calibration with: | Command | Request keys | Response | |---------|--------------|----------| | `flowrate_cali` | `nozzle_diameter`, `tray_id`, `filaments[]` with `tray_id`, `bed_temp`, `filament_id`, `setting_id`, `nozzle_temp`, `def_flow_ratio`, `max_volumetric_speed`, `extruder_id`, `ams_id`, `slot_id` | `result` / `reason` (same pattern as `extrusion_cali`) | | `flowrate_get_result` | `nozzle_diameter` | `filaments[]` with `tray_id`, `nozzle_diameter`, `filament_id`, `setting_id`, `flow_ratio`, `confidence` | `flowrate_cali` sends `tray_id` and `nozzle_diameter` at the top level, with the per-filament parameters repeated inside `filaments[]`. **Note — live behavior.** On a P2S over LAN MQTT, `flowrate_get_result` produced **no response** (the request was accepted but nothing was published back within a few seconds), so its response shape is unverified against real hardware. The field list above is taken from the request builder and response parser in Bambu Studio source only. --- ##### AMS control commands All AMS MQTT commands use the same `"print"` envelope. Studio also has legacy **G-code fallbacks** for some operations (`M620 C/R/P` via `publish_gcode()`). **`ams_change_filament`** — load or unload filament (`command_ams_change_filament`, ~1537) Load: ```json { "print": { "command": "ams_change_filament", "sequence_id": "50", "curr_temp": 210, "tar_temp": 220, "ams_id": 1, "target": 5, "slot_id": 1, "extruder_id": 0 } } ``` `target` is the flat `tray_id` (`ams_id * 4 + slot_id`), or the raw `ams_id` when `tray_id == 0`. `extruder_id` is optional (multi-extruder). Unload: ```json { "print": { "command": "ams_change_filament", "sequence_id": "51", "curr_temp": 220, "tar_temp": 210, "ams_id": 1, "target": 255, "slot_id": 255 } } ``` **Response:** may include `errno` (negative) and `soft_temp` when chamber/AMS temperature blocks the operation (`DeviceManager.cpp:2860-2879`). **`ams_filament_setting`** — update tray metadata (`command_ams_filament_settings`, ~1602) ```json { "print": { "command": "ams_filament_setting", "sequence_id": "52", "ams_id": 1, "slot_id": 1, "tray_id": 1, "tray_info_idx": "GFA00", "setting_id": "GFL99", "tray_color": "FF112233", "nozzle_temp_min": 190, "nozzle_temp_max": 240, "tray_type": "PLA" } } ``` `tray_color` is RGBA hex without `#`. **`tray_id` semantics in this command differ from the calibration commands:** - For AMS trays, `tray_id` = `slot_id` (0–3), **not** the flat `ams_id * 4 + slot_id`. - For the external spool, `tray_id` is hardcoded to `254` (`VIRTUAL_TRAY_DEPUTY_ID`) for **both** the main (`ams_id` 255) and deputy (`ams_id` 254) spools — even `ams_id: 255` is sent with `tray_id: 254`. The actual addressing is carried by `ams_id`. Contrast with `extrusion_cali_sel`, where the external spool's `tray_id` is the real reserved id (255 main / 254 deputy). **Response:** echoes `ams_id`, `tray_id`, `tray_color`, `nozzle_temp_min`, `nozzle_temp_max`, `tray_info_idx`, `tray_type`. Studio updates its local AMS model from the ack (`DeviceManager.cpp:3459-3510`). **`ams_user_setting`** — global AMS RFID / remain-detection flags (`command_ams_user_settings`, ~1575) ```json { "print": { "command": "ams_user_setting", "sequence_id": "53", "ams_id": -1, "startup_read_option": true, "tray_read_option": true, "calibrate_remain_flag": false } } ``` `ams_id: -1` means all AMS units. Updated flags appear in periodic `push_status` under `print.ams` as `insert_flag`, `power_on_flag`, `calibrate_remain_flag` (`DevFilaSystem.cpp:522-531`). **`ams_control`** — resume / abort / pause an in-progress AMS filament operation (`command_ams_control`, ~1656) ```json { "print": { "command": "ams_control", "sequence_id": "54", "param": "resume" } } ``` Valid `param` values: `"resume"`, `"reset"`, `"pause"`, `"done"`, `"abort"`. **`ams_get_rfid`** — trigger RFID read for one slot (`command_ams_refresh_rfid2`, ~1638) ```json { "print": { "command": "ams_get_rfid", "sequence_id": "55", "ams_id": 1, "slot_id": 2 } } ``` Legacy path uses G-code `M620 R` instead. RFID progress is tracked via `tray_reading_bits` / `tray_read_done_bits` in `push_status.ams`. ##### AMS tray `state` in `push_status` (reverse-engineered) Path: `print.ams.ams[].tray[].state` (integer). Appears in periodic **`push_status`** deltas on `device//report`. Bambu Studio does **not** parse this field — presence comes from **`print.ams.tray_exist_bits`** (`DevFilaSystemParser::ParseAmsTrayInfo()`); metadata from `tray_type`, `tray_info_idx`, `tray_color`, etc. **Legacy encoding (values 0–3 only)** Older captures and community docs ([API_AMS_FILAMENT.md](https://github.com/coelacant1/Bambu-Lab-Cloud-API/blob/main/API_AMS_FILAMENT.md)) list only **0–3**. These already look like a **2-bit mask** (`state & 0x03`), not a sequential enum: | `state` | Bits | Community name | Tentative meaning | |--------:|------|----------------|-------------------| | **0** | `00` | Empty | No spool | | **1** | `01` | Loading | Spool engaged; metadata not ready (exact semantics unclear) | | **2** | `10` | Loaded | Metadata side set without full “ready” (rare; semantics unclear) | | **3** | `11` | Ready | Spool present **and** metadata applied — both low bits set when everything works | Bit **0** (`0x01`) — spool **physically present** / tray engaged. Bit **1** (`0x02`) — **metadata** side; should be **1** when the slot is fully configured (**3**). **Extended encoding (newer firmware — observed P2S + AMS, May 2026)** Firmware adds more OR-ed flags on top of the same low two bits: | Bit | Mask | Role (tentative) | |-----|------|------------------| | **0** | `0x01` | Spool present / tray engaged (unchanged) | | **1** | `0x02` | Metadata known or **cached in memory** (see below) | | **2** | `0x04` | RFID / motion — **TODO** (see below) | | **3** | `0x08` | **Steady** slot report — idle states are **`0x08 \| (state & 0x03)`** | | **4** | `0x10` | RFID / motion — **TODO** (see below) | Steady states with bit **3** set (successors to legacy **0–3**): | `state` | Low bits | Meaning | |--------:|----------|---------| | **8** | `00` | Hard empty — no spool, no remembered profile | | **9** | `01` | Spool present, **no** metadata — UI **?**; MQTT often only **`{"id","state":9}`** | | **10** | `10` | No spool, **cached** metadata for next insert | | **11** | `11` | Spool present + metadata — normal configured tray (check **`tray_type`** non-empty; after **Clear**, **`state`** may stay **11** with blank fields — see below) | So **`8 = 0x08\|0`**, **`9 = 0x08\|1`**, **`10 = 0x08\|2`**, **`11 = 0x08\|3`**. **Which encoding does this printer use?** - **`state ≥ 4`** → extended scheme (bit **3** and/or motion bits in play). - On extended firmware, raw **`1`**, **`2`**, **`3`** should **not** appear as steady values: idle reporting always sets bit **3**, so steady codes are **8–11**. Values **1–3** would only fit legacy firmware without the **`0x08`** layer. - If you only ever see **8–11** (and transient **5** / **17** / **21** / **27** during RFID), treat the slot as extended. **Bit 1 in the extended scheme** Bit **1** describes **metadata state**, not spool presence: - **`1` with bit `0` clear** → **`10`**: slot empty, but the printer **remembers** a profile (colour/type) for the next spool. - **`0` with bit `0` set** → **`9`**: spool **is** in the slot, but metadata was **cleared or invalidated** — UI **?**. Example: inserting a **non-RFID / third-party** spool after a configured **official Bambu** one; the printer detects presence but knows it is no longer the same known profile. **Clear / reset from the printer filament UI** **Clear** does **not** drop **`state`** to **9** / **10** — observed **`state: 11`** (`0x08\|0x03`, both low bits set). **`tray_exist_bits`** stays set. But filament fields in MQTT are **zeroed or emptied**, e.g.: ```json { "id": "0", "state": 11, "tray_type": "", "tray_info_idx": "", "tray_color": "00000000", "cols": ["00000000"], "nozzle_temp_min": "0", "nozzle_temp_max": "0", "remain": -1, "tag_uid": "0000000000000000", "tray_uuid": "00000000000000000000000000000000", … } ``` So **`state` bit 1 can stay set while the profile in JSON is already cleared** — do not treat **`state: 11`** alone as “fully configured”. **Practical “?” detection:** empty **`tray_type`** (and usually empty **`tray_info_idx`**) → UI **?** / unknown spool, same as steady **`9`**, even when **`state`** still reports **11**. Do **not** use `tag_uid` or `tray_uuid` all-zero for this — those are normal without RFID. Steady **9** with a spool installed typically sends only **`{"id":"N","state":9}`** — no `tray_type` / colour keys. **`state: 8`** can look the same shape but means an **empty** slot; use **`state`** (and **`tray_exist_bits`**) to tell them apart, not key count alone. **Clear** is different again: **full** tray object with blank/zero fields while **`state`** stays **11**. **Bits 2 and 4 — RFID scan transients** During **`ams_get_rfid`** (with read-on-insert enabled), **`state`** flashes through non-steady values for sub-second to a few seconds. **P2S observed** chain: **`17 → 21 → 5 → 21 → 5`**, then steady **8 / 9 / 10 / 11**; a neighbouring slot may show **`27`**. | `state` | Flags | Notes | |--------:|-------|-------| | **17** | `0x01\|0x10` | Brief prep phase | | **21** | `0x01\|0x04\|0x10` | Longest phase — slow RFID scan | | **5** | `0x01\|0x04` | Between **21** passes; **`21 ^ 5 = 0x10`** (only bit **4** toggles) | | **27** | `0x08\|0x03\|0x10` | Parked neighbour during scan | Bits **2** and **4** both change during RFID; **`tray_reading_bits`** / **`tray_read_done_bits`** in `print.ams` may correlate but were not fully mapped. **TODO:** confirm whether **`0x04`** vs **`0x10`** is feed vs tray rotation vs RFID channel; assignment may be swapped. **Related `print.ams` root-level fields** (OpenBambuAPI [`mqtt.md`](https://github.com/Doridian/OpenBambuAPI/blob/master/mqtt.md)): | Field | Role | |-------|------| | `tray_exist_bits` | Physical spool in slot (Studio uses this for presence) | | `tray_reading_bits` / `tray_read_done_bits` | RFID scan in progress / complete | | `tray_is_bbl_bits` | Recognised official Bambu RFID profile | | `tray_now` / `tray_tar` / `tray_pre` | Active / target / previous flat tray id | | `cali_id` | Last calibration target tray id (`255` = external spool / none) | | `cali_stat` | Calibration status; low byte parsed in `DevFilaSystem::IsAmsSettingUp()` — `0x01`–`0x04` = setup in progress, `0` = idle | **Per-tray PA fields** in `push_status` (`print.ams.ams[].tray[]`): When `print.cali_version >= 0`: | Field | Role | |-------|------| | `cali_idx` | Active PA profile index. Absent on empty trays. | When `print.cali_version` is absent (legacy firmware): | Field | Role | |-------|------| | `cali_idx` | Active PA profile index | | `k` | Current PA K-value | | `n` | Current PA N-coefficient | See §6.8.4 "Storage model and reading the active profile" for how to resolve K/N from these fields. **`ams_filament_drying`** — start or stop AMS drying (`DevFilaSystemCtrl.cpp`) Start (timed mode): ```json { "print": { "command": "ams_filament_drying", "sequence_id": "56", "ams_id": 0, "mode": 1, "filament": "PLA", "temp": 45, "duration": 4, "humidity": 0, "rotate_tray": true, "cooling_temp": 35, "close_power_conflict": false } } ``` Stop (`mode: 0`): same keys zeroed out. `mode` enum: `0` = Off, `1` = OnTime, `2` = OnHumidity (`DevFilaSystem.h:117-122`). **`auto_stop_ams_dry`** — emergency stop drying (`command_ams_drying_stop`, ~1671): ```json { "print": { "command": "auto_stop_ams_dry", "sequence_id": "57" } } ``` **`ams_reset`** — reset AMS state machine (`DevFilaSystemCtrl.cpp:11`): ```json { "print": { "command": "ams_reset", "sequence_id": "58" } } ``` **`print_option`** — AMS-related printer options (same command name as general print options, different keys) Auto-refill (`command_ams_switch_filament`, ~1751): ```json { "print": { "command": "print_option", "sequence_id": "59", "auto_switch_filament": true } } ``` Air-print detection (`command_ams_air_print_detect`, ~1765): ```json { "print": { "command": "print_option", "sequence_id": "60", "air_print_detect": true } } ``` **Legacy G-code AMS shortcuts** (still used on some code paths): | G-code | Purpose | |--------|---------| | `M620 C` | AMS calibration | | `M620 R` | RFID refresh (legacy) | | `M620 P` | Select tray (legacy) | --- **Evidence:** Bambu Studio source only — no stock-plugin MITM capture for these commands. Cross-reference with `3rd_party/OpenBambuAPI/mqtt.md` for partial AMS documentation (missing the `extrusion_cali_*` family). **`tray.state`** bitmask notes: LAN MQTT on **P2S + AMS (May 2026)**, unconfirmed in firmware. ### 6.9. User presets | Symbol | Signature | |--------|-----------| | `bambu_network_get_user_presets` | `int(void*, std::map>* user_presets)` | | `bambu_network_request_setting_id` | `std::string(void*, std::string name, std::map* values_map, unsigned int* http_code)` | | `bambu_network_put_setting` | `int(void*, std::string setting_id, std::string name, std::map* values_map, unsigned int* http_code)` | | `bambu_network_get_setting_list` | `int(void*, std::string bundle_version, ProgressFn, WasCancelledFn)` | | `bambu_network_get_setting_list2` | `int(void*, std::string bundle_version, CheckFn, ProgressFn, WasCancelledFn)` | | `bambu_network_delete_setting` | `int(void*, std::string setting_id)` | `CheckFn = std::function)>`, `ProgressFn = std::function`. All six entry points are thin wrappers over one REST resource — ``` /v1/iot-service/api/slicer/setting[/]?version=&public=false ``` — on the cloud API host; see §6.10.1 for base URL, headers and the common response envelope that apply here and to every other HTTP endpoint the plugin touches. Preset IDs are prefixed by type (observed in live responses): | Type | ID prefix | Public counterpart | |------|-----------|---------------------| | `print` (process) | `PPUS…` | `GP…` | | `filament` | `PFUS…` | `GFS…` / `GFL…` | | `printer` (machine) | `PMUS…` | `GM…` | #### 6.9.1. Per-method schema **`GET /slicer/setting?public=false&version=` — list metadata** (called from `get_setting_list` / `get_setting_list2`): ```json { "message": "success", "code": null, "error": null, "print": { "private": [ /* Meta, … */ ], "public": [] }, "printer": { "private": [ /* Meta, … */ ], "public": [] }, "filament": { "private": [ /* Meta, … */ ], "public": [] }, "settings": [] } ``` Every entry in `private[]` is metadata only — no `setting` payload: ```json { "setting_id": "PFUS7bf6d4b8df15d8", "name": "Bambu PLA Tough @BBL P1P 0.2 nozzle", "version": "0.0.0.0", "update_time": "2026-04-06 19:03:50", "base_id": null, "filament_id": null, "filament_vendor": null, "filament_type": null, "filament_is_support": null, "nozzle_temperature": null, "nozzle_hrc": null, "inherits": null, "nickname": null } ``` `update_time` is rendered as `"YYYY-MM-DD HH:MM:SS"` in UTC; `load_user_preset()` expects unix seconds, so the plugin converts. **`GET /slicer/setting/` — full preset** (observed only by direct probe; the stock plugin does **not** call it): ```json { "message": "success", "code": null, "error": null, "setting_id": "PFUS7bf6d4b8df15d8", "name": "Bambu PLA Tough @BBL P1P 0.2 nozzle", "type": "filament", "version": "0.0.0.0", "base_id": null, "filament_id": null, "nickname": null, "update_time": "2026-04-06 19:03:50", "public": false, "setting": { "activate_air_filtration": 0, "compatible_printers": "\"Bambu Lab P1P 0.2 nozzle\"", "filament_type": "\"PLA\"", "...": "..." } } ``` Values inside `setting` are already in the `ConfigOption::serialize()` form Studio's loader expects (quoted scalars, semicolon-separated lists, etc.). Some keys are echoed as native JSON numbers instead of strings; callers coerce. The `user_id` of the owner is **not** returned by either endpoint — `PresetCollection::load_user_preset()` requires it, so callers must inject their own from the authenticated session. **`POST /slicer/setting` — create** (called from `request_setting_id`). Request: ```json { "name": "", "type": "filament|print|printer", "version": "", "base_id": "", "filament_id": "", "setting": { "