Support the 02.08.01 network plug-in and resync the Bambu device workflow (#14837)

# Description

Support the 02.08.01 network plug-in and resync the Bambu device
workflow

Brings the Bambu network plug-in up to the 02.08.01 series and reworks
the plug-in
lifecycle around it (ABI gating, OTA install, hot reload), then catches
the device and
monitor side up to what that plug-in and current firmware expose — AMS
mapping, send-flow
checks, print options, and the device status pages.

**Included:**

_Network plug-in_
- Default to the 02.08.01 series and bind its new ABI symbols; bump the
reported
  `SLIC3R_VERSION` to 02.08.01.55
- Gate loading to ABI-compatible `AA.BB.CC` series, and store plug-in
identity by series
  rather than by exact build
- Fix the OTA update flow: install immediately, hot-reload without a
restart, and destroy
  the agent before unloading the DLL
- Version selector lists same-series OTA builds newest first, with
dynamic
  "(Latest)" / "(installed)" labels
- Restart printer discovery and reload saved LAN printers after a hot
reload

_Device layer_
- Move axis, calibration, chamber, status and upgrade handling into
DeviceCore; drop the
  transitional shims
- Resync the DeviceCore state models, accessory firmware versions,
cold-pull state and
  filament checks

_Device UI_
- Resync the AMS control/item widgets, device dialogs, device tab
widgets and status pages
- Add the AMS best-position popup, filament-change stop button, hub
version row and HMS
  fallback
- Device error dialog: print-failure snapshot, purification and
don't-remind actions

_Send flow_
- Resync the send flow and port the remaining pre-send checks and
advisories
- Warn when a filament will switch extruders mid-print; block TPU on the
l
  without firmware support (O1D/O1E)
- Map switch-bound AMS trays to both extruders and show combined nozzle
mapping
- Add the shared-PA-profile toggle

_Print options / timelapse_
- Restructure DevPrintOptions around a detection-option map and parse
the
  bits on the live push path
- Add firmware print-option toggles, plus timelapse storage-location
selection with a
  free-space check

_Fixes_
- Inverted MQTT jog on i3-architecture printers, plus assorted popup,
mapping and
  status-parse defects

_Tests_
- New Catch2 coverage for network version selection and device filament
mapping

Printers that don't advertise the new capabilities are unaffected —
every added behavior is
gated on a firmware or plug-in capability flag, and the only profile
chang
`support_print_check_firmware_for_tpu_left` to O1D/O1E.

# Screenshots/Recordings/Graphs

<img width="714" height="500" alt="image"
src="https://github.com/user-attachments/assets/33e54581-3fb0-4a3e-b80c-afa8adfabe4c"
/>

<img width="530" height="212" alt="image"
src="https://github.com/user-attachments/assets/d0c55fd5-74c3-4aab-81a5-786d0aaf8828"
/>

<img width="483" height="527" alt="image"
src="https://github.com/user-attachments/assets/71ceaad1-a082-41cb-aa1a-4b9abb30d9db"
/>


## Tests

<!--
> Please describe the tests that you have conducted to verify the
changes made in this PR.
-->

<!--
> A guide for users on how to download the artifacts from this PR.
-->

[How to Download Pull Requests Artifacts for
Testing](https://www.orcaslicer.com/wiki/how_to_download_pr_artifacts)
This commit is contained in:
SoftFever
2026-07-19 17:10:35 +08:00
committed by GitHub
347 changed files with 2225174 additions and 1235393 deletions

View File

@@ -59,7 +59,9 @@ TEST_CASE("NetworkLibraryVersionInfo::from_static", "[BambuNetworking]") {
REQUIRE(info.suffix == "");
REQUIRE(info.display_name == "02.03.00.62");
REQUIRE(info.url_override == "");
REQUIRE(info.is_latest == true);
// from_static no longer propagates the static is_latest flag; it is a placeholder
// that get_all_available_versions() assigns once the list is sorted newest-first.
REQUIRE(info.is_latest == false);
REQUIRE(info.warning == "");
REQUIRE(info.is_discovered == false);
}

View File

@@ -1,6 +1,8 @@
get_filename_component(_TEST_NAME ${CMAKE_CURRENT_LIST_DIR} NAME)
add_executable(${_TEST_NAME}_tests
${_TEST_NAME}_tests_main.cpp
test_dev_mapping.cpp
test_network_versions.cpp
test_action_source.cpp
test_plugin_host_api.cpp
test_plugin_capability_config.cpp

View File

@@ -0,0 +1,195 @@
// Match the include environment that libslic3r_gui TUs get from pchheader.hpp: Windows.h with
// WIN32_LEAN_AND_MEAN/NOMINMAX must come first so rpcndr.h's `byte` is processed before <cstddef>
// makes std::byte a competing candidate (otherwise the Windows COM headers pulled in via
// DeviceManager.hpp error with an ambiguous `byte`). wx/timer.h must precede DeviceManager.hpp,
// which includes DeviceErrorDialog.hpp (uses wxTimerEvent) before its own wx/timer.h include.
#ifdef WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <Windows.h>
#endif
#include <catch2/catch_all.hpp>
#include <wx/timer.h>
#include "slic3r/GUI/DeviceManager.hpp"
#include "slic3r/GUI/DeviceCore/DevMapping.h"
#include "slic3r/GUI/DeviceCore/DevFilaSystem.h"
#include <nlohmann/json.hpp>
using json = nlohmann::json;
using namespace Slic3r;
TEST_CASE("Switch-bound AMS trays map to the left extruder", "[DevMapping]")
{
MachineObject obj(nullptr, nullptr, "test", "test_dev", "127.0.0.1");
// aux bit 29 = Filament Track Switch installed (DevFilaSwitch.cpp:69-77)
obj.GetFilaSwitch()->ParseFilaSwitchInfo(json::parse(R"({"aux":"20000000"})"));
REQUIRE(obj.GetFilaSwitch()->IsInstalled());
// info bits: 0-3 type(1=AMS), 8-11 extruder(0xE=switch-bound), 24-27 bind_switch_in(0)
// tray_exist_bits bit 0 marks AMS 0 / tray 0 present so the mapping result survives the
// is_exists check in is_valid_mapping_result (DevFilaSystem.cpp:769).
// tray_info_idx/tray_type are intentionally omitted: the tray parse resolves the display
// filament type via MachineObject::setting_id_to_type(), which reads the GUI preset bundle
// (wxGetApp().preset_bundle) — unavailable in this headless unit test. The tray filament
// type (only needed for the type-match below) is set directly after the parse instead.
json print_push = json::parse(R"({
"ams": {
"tray_exist_bits": "1",
"ams": [ {
"id": "0",
"info": "00000E01",
"tray": [ { "id": "0", "tray_color": "FF0000FF" } ]
} ]
}
})");
DevFilaSystemParser::ParseV1_0(print_push, &obj, obj.GetFilaSystem().get(), false);
const auto& ams_list = obj.GetFilaSystem()->GetAmsList();
REQUIRE(ams_list.count("0") == 1);
REQUIRE(ams_list.at("0")->GetBindedExtruderSet().count(MAIN_EXTRUDER_ID) == 1);
REQUIRE(ams_list.at("0")->GetBindedExtruderSet().count(DEPUTY_EXTRUDER_ID) == 1);
DevAmsTray* tray = obj.GetFilaSystem()->GetAmsTray("0", "0");
REQUIRE(tray != nullptr);
tray->m_fila_type = "PLA";
FilamentInfo fila;
fila.id = 0;
fila.type = "PLA";
fila.color = "FF0000FF";
std::vector<FilamentInfo> result;
std::vector<bool> map_opt(4, false); // MappingOption: LEFT_AMS,RIGHT_AMS,LEFT_EXT,RIGHT_EXT (DevMapping.h:13-19)
map_opt[MappingOption::USE_LEFT_AMS] = true;
DevMappingUtil::ams_filament_mapping(&obj, {fila}, result, map_opt, {}, false);
// A switch-bound AMS feeds BOTH extruders, so a left-only mapping request
// must still land the filament on the AMS tray.
REQUIRE(result.size() == 1);
CHECK(result[0].tray_id == 0);
CHECK(result[0].ams_id == "0");
}
TEST_CASE("Without a switch the binding set equals the single bound extruder", "[DevMapping]")
{
MachineObject obj(nullptr, nullptr, "test", "test_dev", "127.0.0.1");
REQUIRE_FALSE(obj.GetFilaSwitch()->IsInstalled());
// AMS 0: info extruder nibble = MAIN (right). AMS 1: nibble = DEPUTY (left).
// AMS 2: no "info" key at all (old X1/P1 firmware) -> must default to MAIN.
// tray_exist_bits: bit ams_id*4+tray_id -> 0x111 marks tray 0 of each AMS.
json print_push = json::parse(R"({
"ams": {
"tray_exist_bits": "111",
"ams": [
{ "id": "0", "info": "00000001", "tray": [ { "id": "0", "tray_color": "FF0000FF" } ] },
{ "id": "1", "info": "00000101", "tray": [ { "id": "0", "tray_color": "00FF00FF" } ] },
{ "id": "2", "tray": [ { "id": "0", "tray_color": "0000FFFF" } ] }
]
}
})");
DevFilaSystemParser::ParseV1_0(print_push, &obj, obj.GetFilaSystem().get(), false);
// The invariant that keeps the binding-set mapping filter behavior-preserving for
// ordinary printers: GetBindedExtruderSet() == { GetExtruderId() }, info key or not.
const auto& ams_list = obj.GetFilaSystem()->GetAmsList();
REQUIRE(ams_list.count("0") == 1);
REQUIRE(ams_list.count("1") == 1);
REQUIRE(ams_list.count("2") == 1);
for (const char* id : {"0", "1", "2"}) {
const auto& ams = ams_list.at(id);
INFO("ams " << id);
REQUIRE(ams->GetBindedExtruderSet().size() == 1);
REQUIRE(ams->GetBindedExtruderSet().count(ams->GetExtruderId()) == 1);
}
REQUIRE(ams_list.at("0")->GetExtruderId() == MAIN_EXTRUDER_ID);
REQUIRE(ams_list.at("1")->GetExtruderId() == DEPUTY_EXTRUDER_ID);
REQUIRE(ams_list.at("2")->GetExtruderId() == MAIN_EXTRUDER_ID);
for (const char* id : {"0", "1", "2"}) {
DevAmsTray* tray = obj.GetFilaSystem()->GetAmsTray(id, "0");
REQUIRE(tray != nullptr);
tray->m_fila_type = "PLA";
}
// A left-only request must exclude the MAIN-bound AMSes: the red filament exactly
// matches AMS 0's red tray, so landing anywhere but AMS 1 (or unmapped) means the
// exclusion is broken.
FilamentInfo fila;
fila.id = 0;
fila.type = "PLA";
fila.color = "FF0000FF";
std::vector<FilamentInfo> result;
std::vector<bool> map_opt(4, false);
map_opt[MappingOption::USE_LEFT_AMS] = true;
DevMappingUtil::ams_filament_mapping(&obj, {fila}, result, map_opt, {}, false);
REQUIRE(result.size() == 1);
CHECK(result[0].ams_id != "0");
CHECK(result[0].ams_id != "2");
// The mirrored right-only request maps to the exact-match MAIN-bound AMS.
result.clear();
map_opt[MappingOption::USE_LEFT_AMS] = false;
map_opt[MappingOption::USE_RIGHT_AMS] = true;
DevMappingUtil::ams_filament_mapping(&obj, {fila}, result, map_opt, {}, false);
REQUIRE(result.size() == 1);
CHECK(result[0].ams_id == "0");
}
TEST_CASE("Switch-bound AMS with an invalid track is excluded from mapping", "[DevMapping]")
{
MachineObject obj(nullptr, nullptr, "test", "test_dev", "127.0.0.1");
obj.GetFilaSwitch()->ParseFilaSwitchInfo(json::parse(R"({"aux":"20000000"})"));
REQUIRE(obj.GetFilaSwitch()->IsInstalled());
// info bits 24-27 = 0xF: switch-bound (0xE) but the input track is not yet valid -
// the transient while the device is still homing the switch. The AMS must survive
// (display keeps working) with an EMPTY binding set that excludes it from mapping.
json print_push = json::parse(R"({
"ams": {
"tray_exist_bits": "1",
"ams": [ { "id": "0", "info": "0F000E01", "tray": [ { "id": "0", "tray_color": "FF0000FF" } ] } ]
}
})");
DevFilaSystemParser::ParseV1_0(print_push, &obj, obj.GetFilaSystem().get(), false);
const auto& ams_list = obj.GetFilaSystem()->GetAmsList();
REQUIRE(ams_list.count("0") == 1);
REQUIRE(ams_list.at("0")->GetBindedExtruderSet().empty());
REQUIRE_FALSE(ams_list.at("0")->GetSwitcherPos().has_value());
REQUIRE_FALSE(obj.GetFilaSwitch()->IsReady());
DevAmsTray* tray = obj.GetFilaSystem()->GetAmsTray("0", "0");
REQUIRE(tray != nullptr);
tray->m_fila_type = "PLA";
FilamentInfo fila;
fila.id = 0;
fila.type = "PLA";
fila.color = "FF0000FF";
std::vector<FilamentInfo> result;
std::vector<bool> map_opt(4, false);
map_opt[MappingOption::USE_LEFT_AMS] = true;
map_opt[MappingOption::USE_RIGHT_AMS] = true;
DevMappingUtil::ams_filament_mapping(&obj, {fila}, result, map_opt, {}, false);
REQUIRE(result.size() == 1);
CHECK(result[0].tray_id == -1);
// Without the switch, the same 0xE AMS is dropped from the list entirely.
MachineObject obj_no_switch(nullptr, nullptr, "test", "test_dev", "127.0.0.1");
REQUIRE_FALSE(obj_no_switch.GetFilaSwitch()->IsInstalled());
DevFilaSystemParser::ParseV1_0(print_push, &obj_no_switch, obj_no_switch.GetFilaSystem().get(), false);
REQUIRE(obj_no_switch.GetFilaSystem()->GetAmsList().count("0") == 0);
}

View File

@@ -0,0 +1,199 @@
#include <catch2/catch_all.hpp>
#include <boost/filesystem.hpp>
#include <boost/nowide/fstream.hpp>
#include "libslic3r/Utils.hpp"
#include "slic3r/Utils/bambu_networking.hpp"
using namespace Slic3r;
namespace fs = boost::filesystem;
namespace {
// Platform naming used by BBLNetworkPlugin::scan_plugin_versions().
#if defined(_MSC_VER) || defined(_WIN32)
static const char* PLUGIN_PREFIX = "bambu_networking_";
static const char* PLUGIN_EXT = ".dll";
#elif defined(__WXMAC__) || defined(__APPLE__)
static const char* PLUGIN_PREFIX = "libbambu_networking_";
static const char* PLUGIN_EXT = ".dylib";
#else
static const char* PLUGIN_PREFIX = "libbambu_networking_";
static const char* PLUGIN_EXT = ".so";
#endif
struct PluginFolderFixture
{
fs::path root;
std::string previous_data_dir;
PluginFolderFixture()
{
previous_data_dir = data_dir();
root = fs::temp_directory_path() / fs::unique_path("orca-netver-%%%%%%%%");
fs::create_directories(root / "plugins");
set_data_dir(root.string());
}
~PluginFolderFixture()
{
set_data_dir(previous_data_dir);
boost::system::error_code ec;
fs::remove_all(root, ec);
}
void add_plugin(const std::string& version)
{
boost::nowide::ofstream f((root / "plugins" / (PLUGIN_PREFIX + version + PLUGIN_EXT)).string());
f << "stub";
}
};
int count_version(const std::vector<NetworkLibraryVersionInfo>& versions, const std::string& v)
{
int n = 0;
for (const auto& info : versions)
if (info.version == v)
++n;
return n;
}
} // namespace
TEST_CASE("Series and managed classification", "[NetworkVersions]")
{
// The AA.BB.CC series is the stored identity of every modern build.
CHECK(network_plugin_series("02.08.01.53") == "02.08.01");
CHECK(network_plugin_series("02.08.01") == "02.08.01"); // idempotent
CHECK(network_plugin_series("02.08.01_custom") == "02.08.01");
CHECK(network_plugin_series("02.08.01.52-dev") == "02.08.01");
CHECK(network_plugin_series(BAMBU_NETWORK_AGENT_VERSION_LEGACY) == BAMBU_NETWORK_AGENT_VERSION_LEGACY);
CHECK(network_plugin_series("").empty());
// Only pure dotted-numeric builds collapse into their series entry; legacy and any
// custom-named build keep their own identity.
CHECK(is_series_managed_version("02.08.01"));
CHECK(is_series_managed_version("02.08.01.53"));
CHECK_FALSE(is_series_managed_version("02.08.01_custom"));
CHECK_FALSE(is_series_managed_version("02.08.01.52-dev"));
CHECK_FALSE(is_series_managed_version(BAMBU_NETWORK_AGENT_VERSION_LEGACY));
CHECK_FALSE(is_series_managed_version(""));
}
TEST_CASE_METHOD(PluginFolderFixture, "Managed builds fold into the series; customs are surfaced", "[NetworkVersions]")
{
add_plugin("02.08.01.55"); // managed, same series -> folded into the 02.08.01 row
add_plugin("02.09.00.10"); // managed, unknown series -> not listed
add_plugin("02.03.00.62"); // managed, series no longer whitelisted -> not listed
add_plugin("02.08.01_custom"); // custom, whitelisted series -> listed under it
add_plugin("02.08.01.52-dev"); // custom (dash-suffixed), whitelisted series -> listed
auto versions = get_all_available_versions();
// The specific managed build never gets its own row - the series represents it.
REQUIRE(count_version(versions, "02.08.01.55") == 0);
REQUIRE(count_version(versions, "02.08.01") == 1);
REQUIRE(count_version(versions, "02.09.00.10") == 0);
REQUIRE(count_version(versions, "02.03.00.62") == 0);
// Custom-named builds are distinct files kept under their own name.
REQUIRE(count_version(versions, "02.08.01_custom") == 1);
REQUIRE(count_version(versions, "02.08.01.52-dev") == 1);
// Newest series first, its customs nested under it (suffix sort: "" < ".52-dev" < "_custom"),
// legacy last.
REQUIRE(versions[0].version == "02.08.01");
REQUIRE(versions[1].version == "02.08.01.52-dev");
REQUIRE(versions[2].version == "02.08.01_custom");
REQUIRE(versions.back().version == BAMBU_NETWORK_AGENT_VERSION_LEGACY);
// Customs sort/render nested under their series (non-empty suffix, base = the series).
REQUIRE(versions[1].base_version == "02.08.01");
REQUIRE_FALSE(versions[1].suffix.empty());
REQUIRE(versions[2].base_version == "02.08.01");
REQUIRE_FALSE(versions[2].suffix.empty());
// "(Latest)" is the series row, never a nested custom build.
REQUIRE(versions[0].suffix.empty());
REQUIRE(versions[0].is_latest);
REQUIRE_FALSE(versions[1].is_latest);
REQUIRE_FALSE(versions[2].is_latest);
// The stored default that drives download and update-check decisions is now the series.
REQUIRE(std::string(get_latest_network_version()) == "02.08.01");
}
TEST_CASE_METHOD(PluginFolderFixture, "Only the loaded series is marked installed", "[NetworkVersions]")
{
add_plugin("02.08.01.55");
add_plugin("02.08.01_custom");
// The loaded plug-in reports its full build (02.08.01.55); the series row is what gets marked.
{
auto versions = get_all_available_versions("02.08.01.55");
int marked = 0;
for (const auto& info : versions)
if (info.is_loaded) { ++marked; REQUIRE(info.version == "02.08.01"); }
REQUIRE(marked == 1);
}
// A loaded custom build matches its own row, never the bare series.
{
auto versions = get_all_available_versions("02.08.01_custom");
int marked = 0;
for (const auto& info : versions)
if (info.is_loaded) { ++marked; REQUIRE(info.version == "02.08.01_custom"); }
REQUIRE(marked == 1);
}
// Nothing loaded marks nothing, even though libraries are on disk.
for (const auto& info : get_all_available_versions(""))
REQUIRE_FALSE(info.is_loaded);
}
TEST_CASE("Only whitelisted series pass the load gate", "[NetworkVersions]")
{
// The whitelisted series, its builds, and custom-named builds of that series.
REQUIRE(is_supported_network_version("02.08.01"));
REQUIRE(is_supported_network_version("02.08.01.52"));
REQUIRE(is_supported_network_version("02.08.01.55"));
REQUIRE(is_supported_network_version("02.08.01_custom"));
REQUIRE(is_supported_network_version("02.08.01.52-dev"));
REQUIRE(is_supported_network_version(BAMBU_NETWORK_AGENT_VERSION_LEGACY));
// Series whitelisted by previous Orca releases - their ABI no longer matches.
REQUIRE_FALSE(is_supported_network_version("02.03.00.62"));
REQUIRE_FALSE(is_supported_network_version("02.01.01.52"));
REQUIRE_FALSE(is_supported_network_version("02.00.02.50"));
// Unknown series, legacy siblings, and malformed values.
REQUIRE_FALSE(is_supported_network_version("02.09.00.10"));
std::string legacy = BAMBU_NETWORK_AGENT_VERSION_LEGACY;
std::string legacy_sibling = legacy.substr(0, 9) + (legacy.substr(9) == "99" ? "98" : "99");
REQUIRE_FALSE(is_supported_network_version(legacy_sibling));
REQUIRE_FALSE(is_supported_network_version(""));
REQUIRE_FALSE(is_supported_network_version("02.08"));
}
TEST_CASE_METHOD(PluginFolderFixture, "Legacy series never adopts discovered builds", "[NetworkVersions]")
{
// A different build of the legacy series must not be surfaced: is_legacy_version()
// matches exactly, so it would be loaded with the modern struct layout.
std::string legacy = BAMBU_NETWORK_AGENT_VERSION_LEGACY;
std::string legacy_sibling = legacy.substr(0, 9) + (legacy.substr(9) == "99" ? "98" : "99");
add_plugin(legacy_sibling);
auto versions = get_all_available_versions();
REQUIRE(count_version(versions, legacy_sibling) == 0);
REQUIRE(count_version(versions, legacy) == 1);
// With nothing else on disk, the series holds "(Latest)" even though its library is
// not installed.
for (const auto& info : versions) {
if (info.version == "02.08.01") {
REQUIRE(info.is_latest);
REQUIRE_FALSE(info.is_loaded);
}
}
}